Compare commits
1 Commits
main
...
desarrollo
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f753376e3 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -91,7 +91,3 @@ backups/
|
||||
# Local tools (AWS CLI)
|
||||
tools/
|
||||
|
||||
# Rached migration session artifacts (tokens / captures / samples)
|
||||
rached_*.json
|
||||
rached_*.txt
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const pages = [
|
||||
['dashboard', 'http://localhost:5000/'],
|
||||
['catalog', 'http://localhost:5000/catalog'],
|
||||
['tienda (capi)', 'http://localhost:5001/tienda'],
|
||||
];
|
||||
for (const [name, url] of pages) {
|
||||
const errs = [];
|
||||
const onErr = (e) => errs.push(String(e).slice(0, 140));
|
||||
const onCon = (m) => { if (m.type() === 'error' && !/favicon|net::ERR|404|sourcemap/i.test(m.text())) errs.push(m.text().slice(0, 140)); };
|
||||
page.on('pageerror', onErr); page.on('console', onCon);
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 20000 });
|
||||
await page.waitForTimeout(2000);
|
||||
console.log((errs.length ? '✗ ' : '✓ ') + name, errs.length ? errs.slice(0, 3) : '');
|
||||
} catch (e) { console.log('✗✗ ' + name + ' → ' + e.message.slice(0, 100)); }
|
||||
page.removeAllListeners('pageerror'); page.removeAllListeners('console');
|
||||
}
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -1,10 +0,0 @@
|
||||
const { chromium } = require('playwright');
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const bad = [];
|
||||
page.on('response', (r) => { if (r.status() >= 500) bad.push(r.status() + ' ' + r.url()); });
|
||||
await page.goto('http://localhost:5000/', { waitUntil: 'networkidle', timeout: 25000 });
|
||||
console.log('recursos con 500:', bad.length ? bad : 'ninguno');
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -1,32 +0,0 @@
|
||||
const { chromium } = require('playwright');
|
||||
const { execSync } = require('child_process');
|
||||
const JWT_SECRET = execSync("grep -oP 'POS_JWT_SECRET=\\K.*' /etc/systemd/system/nexus-pos.service | head -1").toString().trim();
|
||||
function tok() {
|
||||
const h = { alg: 'HS256', typ: 'JWT' };
|
||||
const now = Math.floor(Date.now()/1000);
|
||||
const p = { tenant_id: 32, employee_id: 1, name: 'Audit', role: 'owner', branch_id: 1,
|
||||
max_discount_pct: 100, permissions: ['*'], device_id: 'a', type: 'pos_access', exp: now+900, iat: now };
|
||||
const b64 = o => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const crypto = require('crypto');
|
||||
return b64(h)+'.'+b64(p)+'.'+crypto.createHmac('sha256', JWT_SECRET).update(b64(h)+'.'+b64(p)).digest('base64url');
|
||||
}
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const errs = [];
|
||||
page.on('pageerror', e => errs.push(String(e).slice(0,120)));
|
||||
page.on('console', m => { if (m.type()==='error' && !/favicon|404|net::ERR|sourcemap/i.test(m.text())) errs.push(m.text().slice(0,120)); });
|
||||
await page.addInitScript(t => {
|
||||
localStorage.setItem('pos_token', t);
|
||||
localStorage.setItem('pos_employee', JSON.stringify({id:1,name:'Audit',role:'owner'}));
|
||||
localStorage.setItem('pos_tenant_id','32');
|
||||
document.cookie='pos_role=owner; path=/pos; SameSite=Lax';
|
||||
}, tok());
|
||||
for (const p of ['/pos/fleet','/pos/supplier-catalog','/pos/historical-sales','/pos/diagrams']) {
|
||||
errs.length = 0;
|
||||
const resp = await page.goto('http://localhost:5001'+p, {waitUntil:'load', timeout:20000}).catch(e=>null);
|
||||
await page.waitForTimeout(2000);
|
||||
console.log((errs.length?'✗ ':'✓ ') + p + ' [' + (resp?resp.status():'?') + ']' + (errs.length? ' → '+errs[0]:''));
|
||||
}
|
||||
await browser.close();
|
||||
})();
|
||||
85
audit_js.js
85
audit_js.js
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/* Auditoría JS: carga cada página del POS y reporta errores de consola y handlers rotos. */
|
||||
const { chromium } = require('playwright');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const JWT_SECRET = execSync(
|
||||
"grep -oP 'POS_JWT_SECRET=\\K.*' /etc/systemd/system/nexus-pos.service | head -1"
|
||||
).toString().trim();
|
||||
|
||||
function makeToken() {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
tenant_id: 32, employee_id: 1, name: 'Audit Admin', role: 'owner',
|
||||
branch_id: 1, max_discount_pct: 100,
|
||||
permissions: ['pos.sell', 'pos.view', 'pos.remission', 'pos.discount', 'pos.cancel',
|
||||
'catalog.view', 'inventory.view', 'inventory.create', 'inventory.edit',
|
||||
'inventory.adjust', 'inventory.transfer', 'customers.view', 'customers.create',
|
||||
'invoicing.view', 'invoicing.create', 'reports.view', 'config.view', 'config.edit',
|
||||
'config.edit_prices', 'workshop.view', 'workshop.edit', 'dashboard.view'],
|
||||
device_id: 'audit', type: 'pos_access', exp: now + 900, iat: now,
|
||||
};
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const crypto = require('crypto');
|
||||
const sig = crypto.createHmac('sha256', JWT_SECRET)
|
||||
.update(b64(header) + '.' + b64(payload)).digest('base64url');
|
||||
return b64(header) + '.' + b64(payload) + '.' + sig;
|
||||
}
|
||||
|
||||
const PAGES = [
|
||||
'/pos/sale', '/pos/inventory', '/pos/customers', '/pos/config',
|
||||
'/pos/dashboard', '/pos/invoicing', '/pos/remission-notes', '/pos/catalog',
|
||||
'/pos/quotations', '/pos/reports', '/pos/accounting', '/pos/workshop',
|
||||
'/pos/whatsapp', '/pos/marketplace-external',
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
|
||||
const brokenHandlers = [];
|
||||
await page.addInitScript((token) => {
|
||||
localStorage.setItem('pos_token', token);
|
||||
localStorage.setItem('pos_employee', JSON.stringify({ id: 1, name: 'Audit Admin', role: 'owner' }));
|
||||
localStorage.setItem('pos_tenant_id', '32');
|
||||
document.cookie = 'pos_role=owner; path=/pos; SameSite=Lax';
|
||||
// Capturar errores de handlers inline (onclick con función inexistente)
|
||||
window.addEventListener('error', function (e) {
|
||||
if (e.message && /is not a function|is not defined/.test(e.message)) {
|
||||
(window.__broken = window.__broken || []).push(e.message + ' @ ' + location.pathname);
|
||||
}
|
||||
});
|
||||
}, makeToken());
|
||||
|
||||
for (const p of PAGES) {
|
||||
const errs = [];
|
||||
const onConsole = (m) => {
|
||||
if (m.type() === 'error') errs.push(m.text().slice(0, 160));
|
||||
};
|
||||
page.on('pageerror', (e) => errs.push('PAGEERROR: ' + String(e).slice(0, 160)));
|
||||
page.on('console', onConsole);
|
||||
|
||||
try {
|
||||
await page.goto('http://localhost:5001' + p, { waitUntil: 'load', timeout: 20000 });
|
||||
await page.waitForTimeout(2200);
|
||||
// Los errores capturados en window.__broken (handlers inline)
|
||||
const inline = await page.evaluate(() => (window.__broken || []).splice(0));
|
||||
const realErrs = errs.filter((t) =>
|
||||
!/favicon|net::ERR|Failed to load resource|404|sourcemap|DevTools/i.test(t));
|
||||
if (realErrs.length || inline.length) {
|
||||
console.log('✗ ' + p);
|
||||
realErrs.slice(0, 4).forEach((t) => console.log(' consola: ' + t));
|
||||
inline.slice(0, 6).forEach((t) => console.log(' handler: ' + t));
|
||||
} else {
|
||||
console.log('✓ ' + p);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('✗✗ ' + p + ' → ' + e.message.slice(0, 120));
|
||||
}
|
||||
page.removeAllListeners('console');
|
||||
page.removeAllListeners('pageerror');
|
||||
}
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -23,7 +23,6 @@
|
||||
<button data-mode="oem" onclick="setCatalogMode('oem')">OEM</button>
|
||||
<button data-mode="local" onclick="setCatalogMode('local')">Local</button>
|
||||
</div>
|
||||
<a href="/usados" class="header-back" id="usedPartsLink" title="Partes usadas y reconstruidas">Usados</a>
|
||||
<a href="/" class="header-back">← Inicio</a>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle">
|
||||
<span id="themeIcon">☾</span>
|
||||
@@ -87,7 +86,7 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="/catalog-public.js?v=2"></script>
|
||||
<script src="/catalog-public.js"></script>
|
||||
|
||||
<!-- AI Chat Widget -->
|
||||
<link rel="stylesheet" href="/chat-public.css">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
nxPartType: null, // {slug, name} ← Nexpart part type
|
||||
|
||||
region: 'north-america',
|
||||
mode: (localStorage.getItem('catalog_mode') === 'oem' ? 'oem' : 'local'),
|
||||
mode: (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem'),
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
@@ -64,7 +64,6 @@
|
||||
state.brand = state.model = state.year = state.engine = null;
|
||||
state.level = 'brands';
|
||||
loadBrands();
|
||||
waConfig(function () {}); // preload WhatsApp contact config
|
||||
};
|
||||
|
||||
// ── Region selector (global) ──
|
||||
@@ -110,31 +109,6 @@
|
||||
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) {
|
||||
@@ -755,7 +729,6 @@
|
||||
}
|
||||
|
||||
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\'">';
|
||||
@@ -809,26 +782,6 @@
|
||||
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) {
|
||||
@@ -878,7 +831,7 @@
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
waConfig(function () { html += waQuoteButtonHtml(p); body.innerHTML = html; });
|
||||
body.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
@@ -31,14 +31,14 @@
|
||||
}
|
||||
|
||||
var navLinks = [
|
||||
{ label: 'Demo', href: '/demo.html' },
|
||||
{ label: 'Tienda', href: '/tienda.html' },
|
||||
{ label: 'Demo', href: '/demo' },
|
||||
{ label: 'Tienda', href: '/tienda' },
|
||||
{ label: 'Cat\u00e1logo', href: '/index.html' },
|
||||
{ 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' }
|
||||
{ label: 'Captura', href: '/captura' },
|
||||
{ label: 'POS', href: '/pos' },
|
||||
{ label: 'Cuentas', href: '/cuentas' },
|
||||
{ label: 'Bodega', href: '/bodega' },
|
||||
{ label: 'Admin', href: '/admin' }
|
||||
];
|
||||
|
||||
var linksHTML = navLinks.map(function (link) {
|
||||
|
||||
2058
dashboard/server.py
2058
dashboard/server.py
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
smtp_smarthost: 'localhost:587'
|
||||
smtp_from: 'alerts@nexus.local'
|
||||
smtp_require_tls: false
|
||||
|
||||
route:
|
||||
group_by: ['alertname', 'severity']
|
||||
@@ -10,14 +12,19 @@ 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:
|
||||
# El contenedor llega al host por el gateway del bridge docker
|
||||
- url: 'http://172.17.0.1:5001/pos/api/notifications/webhook'
|
||||
- url: 'http://localhost:5001/pos/api/notifications/webhook'
|
||||
send_resolved: true
|
||||
http_config:
|
||||
authorization:
|
||||
type: Bearer
|
||||
credentials: 'c58db62766712e618a881dbe8de580960812e57a069ef92c9dd00e7e69158cb2'
|
||||
|
||||
inhibit_rules:
|
||||
- source_match:
|
||||
|
||||
@@ -3,8 +3,6 @@ version: "3.8"
|
||||
services:
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.51.0
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
container_name: nexus-prometheus
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"uid": "nexus-app",
|
||||
"title": "Nexus — Application",
|
||||
"tags": ["app", "flask", "nexus"],
|
||||
"tags": ["gunicorn", "flask"],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 36,
|
||||
"refresh": "30s",
|
||||
@@ -26,15 +26,15 @@
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Request Rate (by app / status)",
|
||||
"title": "Request Rate (nginx)",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "sum by (app, status) (rate(nexus_http_requests_total[5m]))",
|
||||
"legendFormat": "{{app}} {{status}}",
|
||||
"expr": "rate(nginx_http_requests_total[5m])",
|
||||
"legendFormat": "Requests/sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -49,20 +49,20 @@
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Response Time p95 / p99",
|
||||
"title": "Response Time (nginx)",
|
||||
"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 by (le) (rate(nexus_http_request_duration_seconds_bucket[5m])))",
|
||||
"expr": "histogram_quantile(0.95, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "histogram_quantile(0.99, sum by (le) (rate(nexus_http_request_duration_seconds_bucket[5m])))",
|
||||
"expr": "histogram_quantile(0.99, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "B"
|
||||
}
|
||||
@@ -78,15 +78,15 @@
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "In-flight Requests",
|
||||
"title": "Active Workers",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "sum by (app) (nexus_http_requests_in_flight)",
|
||||
"legendFormat": "{{app}}",
|
||||
"expr": "count by (instance) (node_processes_state{state=\"S\", cmdline=~\".*gunicorn.*\"})",
|
||||
"legendFormat": "Workers {{instance}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -108,8 +108,8 @@
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "sum by (app) (rate(nexus_http_requests_total{status=\"5xx\"}[5m]))",
|
||||
"legendFormat": "{{app}} 5xx/s",
|
||||
"expr": "rate(nginx_http_requests_total{status=~\"5..\"}[5m])",
|
||||
"legendFormat": "5xx/sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
@@ -124,22 +124,22 @@
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Master DB Connections",
|
||||
"title": "Memory per Worker",
|
||||
"type": "timeseries",
|
||||
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||
"expr": "nexus_db_connections",
|
||||
"legendFormat": "{{app}} ({{database}})",
|
||||
"expr": "process_resident_memory_bytes{cmdline=~\".*gunicorn.*\"} / 1024 / 1024",
|
||||
"legendFormat": "{{cmdline}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||
"unit": "short",
|
||||
"unit": "mbytes",
|
||||
"min": 0
|
||||
},
|
||||
"overrides": []
|
||||
|
||||
@@ -19,7 +19,7 @@ scrape_configs:
|
||||
|
||||
- job_name: 'node'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:9100']
|
||||
- targets: ['node-exporter:9100']
|
||||
|
||||
- job_name: 'postgres'
|
||||
static_configs:
|
||||
@@ -29,16 +29,12 @@ scrape_configs:
|
||||
static_configs:
|
||||
- targets: ['redis-exporter:9121']
|
||||
|
||||
# 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
|
||||
- job_name: 'nexus-pos'
|
||||
static_configs:
|
||||
- targets: ['172.17.0.1:5001']
|
||||
|
||||
- job_name: 'nexus_dashboard'
|
||||
- targets: ['host.docker.internal:5001']
|
||||
metrics_path: /metrics
|
||||
|
||||
- job_name: 'nexus-quart'
|
||||
static_configs:
|
||||
- targets: ['172.17.0.1:5000']
|
||||
|
||||
|
||||
- targets: ['host.docker.internal:5002']
|
||||
metrics_path: /metrics
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# Auditoría y Mejoras del Sistema Nexus POS — 15 de junio de 2026
|
||||
|
||||
## Resumen ejecutivo
|
||||
|
||||
Se realizó una revisión completa del sistema Nexus POS enfocada en corregir errores críticos y mayores, eliminar código muerto, cerrar brechas de seguridad menores y estandarizar la experiencia de usuario. También se aplicaron optimizaciones de rendimiento en consultas frecuentes y se mejoró la accesibilidad de los formularios.
|
||||
|
||||
- **Rama/base:** `main`
|
||||
- **Tenant de pruebas:** `tenant_refaccionaria_la_casita` (`tenant_id = 33`)
|
||||
- **Servicio:** `nexus-pos.service` (puerto 5001)
|
||||
- **Estado final:** operativo, tests pasando
|
||||
|
||||
---
|
||||
|
||||
## 1. Problemas críticos corregidos
|
||||
|
||||
| Problema | Solución | Archivos principales |
|
||||
|----------|----------|----------------------|
|
||||
| `@require_auth` sin paréntesis en `tasks_bp` | Se cambió al decorador correcto | `pos/blueprints/tasks_bp.py` |
|
||||
| Permiso inexistente `accounting.read` | Se reemplazó por `accounting.view` | `pos/blueprints/accounting_bp.py` |
|
||||
| `historical_sales.html` no cargaba `api.js` | Se incluyó el helper compartido | `pos/templates/historical_sales.html`, `pos/static/js/api.js` |
|
||||
| `featureProximamente` no estaba disponible en `customers.js` | Se expuso la función en el módulo | `pos/static/js/customers.js` |
|
||||
| `pos_engine` usaba `threading` sin importar y la caja sin `FOR UPDATE` | Se agregó `import threading` y lock pesimista de caja | `pos/services/pos_engine.py` |
|
||||
| Race condition en stock de ventas | Se agregó `SELECT ... FOR UPDATE` sobre `inventory_stock` | `pos/services/pos_engine.py` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Problemas mayores corregidos
|
||||
|
||||
| Problema | Solución | Archivos principales |
|
||||
|----------|----------|----------------------|
|
||||
| N+1 en `complete_layaway` | Se cargan todos los items en una sola consulta | `pos/services/pos_engine.py` |
|
||||
| Race conditions en folios CFDI y números de póliza | Se usó `pg_advisory_xact_lock(hashtext(prefix))` | `pos/services/cfdi_queue.py`, `pos/services/accounting_engine.py` |
|
||||
| N+1 en flotilla (schedules/history) | Se crearon endpoints bulk `/vehicles/schedules` y `/vehicles/history` | `pos/blueprints/fleet_bp.py`, `pos/static/js/fleet.js` |
|
||||
| N+1 en dashboard (ventas recientes) y `global_invoice` | Se consolidaron consultas | `pos/blueprints/dashboard_stats_bp.py`, `pos/services/global_invoice.py` |
|
||||
| `client_secret` de MercadoLibre expuesto en frontend | Se movió el flujo OAuth a backend con endpoint `/connect/init` | `pos/blueprints/marketplace_external_bp.py`, `pos/static/js/marketplace_external.js` |
|
||||
| Botones/filtros sin handler en config, customers y accounting | Se conectaron eventos y handlers faltantes | `pos/static/js/config.js`, `pos/static/js/customers.js`, `pos/static/js/accounting.v9.js`, `pos/templates/*.html` |
|
||||
| Botones/filtros sin handler en invoicing | Se implementaron filtros, export CSV y acciones | `pos/static/js/invoicing.js`, `pos/templates/invoicing.html` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Mejoras de rendimiento
|
||||
|
||||
- **Locks atómicos:** los folios de órdenes de servicio, CFDI y pólizas contables ahora se generan bajo `pg_advisory_xact_lock` para evitar duplicados bajo concurrencia.
|
||||
- **Consultas bulk:** flotilla y dashboard redujeron drásticamente el número de queries al backend.
|
||||
- **Virtual scroll:** se mantiene en tablas grandes (clientes, programas de mantenimiento, historial) para renderizado eficiente.
|
||||
- **Cache busting:** se actualizaron los query strings estáticos a `?v=32` y el Service Worker a `v32` para forzar la actualización de assets en navegadores y PWA.
|
||||
|
||||
---
|
||||
|
||||
## 4. Mejoras de UX y accesibilidad
|
||||
|
||||
- **Formularios accesibles:** se agregaron `aria-label`, `id` y `<label>` explícitos en:
|
||||
- POS: pago mixto, toggle de costo/margen, campos de vehículo del modal de cliente.
|
||||
- Clientes: búsqueda y filtros.
|
||||
- Catálogo: búsqueda y filtro de niveles.
|
||||
- Facturación: búsquedas de facturas, notas de crédito y complementos de pago.
|
||||
- **Estados de carga consistentes:** se añadió `renderLoadingState()` en `pos-utils.js` con estilo en `pos-ui.css`, y se aplicó en:
|
||||
- Clientes (`customers.js`)
|
||||
- Facturación (`invoicing.js`)
|
||||
- Flotilla (`fleet.js`): vehículos, programas, historial y alertas.
|
||||
- **Empty states normalizados:** los errores y listas vacías ahora usan `renderEmptyState()` en lugar de mensajes inline inconsistentes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Limpieza de código
|
||||
|
||||
- Se eliminaron las versiones obsoletas `pos/static/js/accounting.js` y `pos/static/js/accounting.min.js`.
|
||||
- Se consolidó la lógica contable en `pos/static/js/accounting.v9.js`.
|
||||
- Se removieron `console.log`/`console.error` de depuración de `accounting.v9.js`.
|
||||
- Se limpió encabezado comentado de `accounting.v9.js`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Archivos añadidos
|
||||
|
||||
```text
|
||||
pos/migrations/v4.10_fleet_permissions.sql
|
||||
pos/migrations/v4.7_workshop_business.sql
|
||||
pos/migrations/v4.8_workshop_permissions.sql
|
||||
pos/migrations/v4.9_workshop_customers_view.sql
|
||||
pos/static/js/accounting.v9.js
|
||||
pos/static/js/api.js
|
||||
scripts/allocate_existing_customer_payments.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Estadísticas del cambio
|
||||
|
||||
```text
|
||||
54 archivos modificados
|
||||
+2485 / -1300 líneas aproximadas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Validación
|
||||
|
||||
```bash
|
||||
# Sintaxis de JavaScript
|
||||
node --check pos/static/js/pos-utils.js
|
||||
node --check pos/static/js/customers.js
|
||||
node --check pos/static/js/invoicing.js
|
||||
node --check pos/static/js/fleet.js
|
||||
node --check pos/static/js/accounting.v9.js
|
||||
node --check pos/static/js/pos.js
|
||||
node --check pos/static/js/catalog.js
|
||||
|
||||
# Tests de backend
|
||||
python3 -m pytest pos/tests/test_service_order_integration.py \
|
||||
pos/tests/test_bulk_import.py \
|
||||
pos/tests/test_facturapi_service.py -q
|
||||
# Resultado: 35 passed
|
||||
```
|
||||
|
||||
Servicio `nexus-pos.service` verificado como **activo**.
|
||||
|
||||
---
|
||||
|
||||
## 9. Instrucciones de despliegue
|
||||
|
||||
1. Aplicar migraciones SQL nuevas según el orden numérico:
|
||||
- `v4.7_workshop_business.sql`
|
||||
- `v4.8_workshop_permissions.sql`
|
||||
- `v4.9_workshop_customers_view.sql`
|
||||
- `v4.10_fleet_permissions.sql`
|
||||
2. Reiniciar el servicio para cargar cambios de backend:
|
||||
```bash
|
||||
sudo systemctl restart nexus-pos.service
|
||||
```
|
||||
3. Refrescar la PWA en los navegadores/clientes para que `sw.js` actualice el cache a `v32`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Notas para el equipo
|
||||
|
||||
- Los permisos de flotilla (`fleet.view/create/edit/delete`) ya están sembrados para `owner`/`admin` y `view` para `accountant`/`workshop`.
|
||||
- El Service Worker se sirve correctamente en `/pos/sw.js` desde `static/pwa/sw.js`.
|
||||
- El helper global `api()` fue creado para páginas standalone como `historical_sales.html`.
|
||||
- No se realizaron mutaciones de git no solicitadas; el push a Gitea se hace como parte de esta entrega.
|
||||
@@ -320,30 +320,6 @@ python3 scripts/check_facturapi_tenants.py
|
||||
|
||||
---
|
||||
|
||||
## Mejoras A5 / A6 / A10 (2026-10-11)
|
||||
|
||||
Capacidades que no dependen de gran volumen de ventas históricas.
|
||||
|
||||
| # | Feature | Archivos | Qué hace |
|
||||
|---|---------|----------|----------|
|
||||
| **A10** | Venta cruzada "esto + esto" | `services/cross_sell.py`, `blueprints/pos_bp.py` (`POST /pos/api/recommendations`), `static/js/pos.js`, `templates/pos.html` | Sugiere complementos del carrito usando `part_kits.KIT_SUGGESTIONS` + co-ocurrencia real en `sale_items` (mismo `sale_id`). Prioriza piezas con stock; panel en el POS al agregar un artículo. Permiso `pos.sell`. |
|
||||
| **A5** | Anomalías de negocio | `services/anomaly_detector.py`, `blueprints/pos_bp.py` (`GET /pos/api/reports/anomalies`), `scripts/run_anomaly_scan.py` | z-score robusto (MAD) por empleado/SKU sobre `sale_items` + `sales` + `audit_log`: descuentos atípicos, ventas bajo costo, cancelaciones repetidas y ventas fuera de horario (TZ `America/Mexico_City`). Maneja *cold start* (`min_samples`, default 5) y nunca marca `unit_cost = 0` como bajo costo. Permiso `reports.view`. |
|
||||
| **A6** | Sinónimos + cross-refs en búsqueda | `services/meili_search.py` (`build_synonyms`, `apply_synonyms_to_all`), `scripts/index_meilisearch.py` (`synonyms` mode, `aliases`), `services/catalog_service.py` (`_search_inventory_by_alias`) | Sinónimos ES↔EN (curados + `services.translations.PART_TRANSLATIONS`, 653 términos) aplicados a `nexus_inventory_*` y `nexus_supplier_catalog` sin reindexar. Los SKU alias (`inventory_sku_aliases`) e interchange se indexan en `aliases` y además se consultan por SQL, así la búsqueda por número cruzado funciona de inmediato. |
|
||||
|
||||
Comandos de operación (A6):
|
||||
|
||||
```bash
|
||||
# Aplicar solo settings/sinónimos (no reindexa documentos)
|
||||
python3 scripts/index_meilisearch.py synonyms
|
||||
# Reindexar un tenant (incluye aliases) o todos
|
||||
python3 scripts/index_meilisearch.py tenant tenant_capi_autopartes
|
||||
python3 scripts/index_meilisearch.py all
|
||||
# Escaneo de anomalías (exit 1 si hay hallazgos de severidad alta)
|
||||
python3 scripts/run_anomaly_scan.py all 90
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mejoras Pendientes (Roadmap Actualizado)
|
||||
|
||||
### 🔴 Crítico — Deuda Técnica
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# Nexus Yonke — Documento Comercial (segmento partes usadas)
|
||||
|
||||
> Complemento del `docs/YONKE_ONBOARDING.md` (técnico). Aquí va el **posicionamiento, precios, segmento y copy** para vender el módulo a yonkes/deshuesaderos.
|
||||
|
||||
## 1. A quién le vendemos
|
||||
Yonkes, deshuesaderos y refaccionarias que venden **piezas usadas** (1‑de‑1, con vehículo donador y fotos). Hoy operan con un cuaderno, WhatsApp y MercadoLibre; sin sistema que entienda que **cada pieza es única**.
|
||||
|
||||
## 2. Propuesta de valor (diferenciadores)
|
||||
1. **Pieza única 1‑de‑1** con SKU automático (`USADO‑<VIN>‑<n>`): dos espejos iguales de donadores distintos **no se fusionan**.
|
||||
2. **Alta en <30 s** desde mostrador: **foto múltiple + VIN del donador** (decodificado solo) → pieza lista.
|
||||
3. **Búsqueda por donador** ("¿qué tengo de este Civic 2015?") además de "¿qué le queda a este auto?".
|
||||
4. **Visión por IA**: sugiere el **grado A/B/C/D** desde la foto y **identifica la pieza**.
|
||||
5. **Publicación en MercadoLibre como USADO** (grado, donador, hasta 10 fotos) desde el mismo sistema.
|
||||
6. **Consignación**: piezas de terceros con comisión y **estado de cuenta del consignante**.
|
||||
7. **Vitrina pública** de usados con SEO (Google encuentra tus piezas).
|
||||
8. **Marketplace B2B entre yonkes** (roadmap): intercambio de usadas entre deshuesaderos de la red.
|
||||
|
||||
## 3. Precio
|
||||
- Plan **`yonke`** — **MXN 299/mes**: sin límite de piezas, hasta 10 empleados, 1 sucursal, **sin catálogo TecDoc** (no lo necesita).
|
||||
- Upsell: multi‑sucursal, WhatsApp automatizado, marketplace B2B.
|
||||
|
||||
## 4. Onboarding guiado (checklist)
|
||||
1. Crear el tenant del negocio (plan `yonke`), activar el módulo **Yonke**.
|
||||
2. Otorgar `yonke.view`/`yonke.manage` a los roles (el owner ya los tiene).
|
||||
3. **Configurar la política de garantía de usados** (`yonke_default_warranty_days`, p.ej. 30).
|
||||
4. Cargar las **primeras 20 piezas** en `/pos/yonke` (VIN → Decodificar → fotos → guardar).
|
||||
5. Registrar el primer **donador completo** (auto + sus piezas de una pasada).
|
||||
6. Opcional: publicar 3 piezas en **MercadoLibre** y activar la **vitrina pública**.
|
||||
|
||||
## 5. Copy para landing (borrador)
|
||||
**Título:** *Tu yonke, con sistema. Piezas usadas, ordenadas como nunca.*
|
||||
|
||||
**Bajada:** *Cada pieza es única. Nexus Yonke lo entiende: registra la pieza en 30 segundos desde una foto y el VIN del donador, la encuentra por vehículo, le pone su grado con IA y la publica en MercadoLibre como usada. Consignación, etiquetas y vitrina pública, incluidos.*
|
||||
|
||||
**Bullets:**
|
||||
- "Alta por foto + VIN en menos de 30 segundos."
|
||||
- "Tu inventario en el patio, buscable por donador."
|
||||
- "Publica en MercadoLibre como USADO con grado y fotos."
|
||||
- "Consigna piezas de terceros y liquida con estado de cuenta."
|
||||
- "Vitrina pública indexada en Google."
|
||||
|
||||
**CTA:** *Prueba Nexus Yonke — MXN 299/mes, sin límite de piezas.*
|
||||
|
||||
## 6. Roadmap comercial del segmento
|
||||
- **Fase 1 (listo):** alta, búsqueda por donador, MeLi usado, consignación, plan.
|
||||
- **Fase 2:** vitrina pública + garantías/devolución + etiquetas + reportes (en curso).
|
||||
- **Fase 3:** **marketplace B2B entre yonkes** (`is_seller`), analítica del segmento, importación masiva.
|
||||
@@ -1,51 +0,0 @@
|
||||
# Nexus Yonke — Onboarding (negocios de partes usadas)
|
||||
|
||||
> Módulo para refaccionarias/yonkes/deshuesaderos que venden **piezas usadas** (piezas únicas 1-de-1,
|
||||
> con vehículo donador y varias fotos). No requiere catálogo TecDoc.
|
||||
|
||||
## Qué se habilitó
|
||||
|
||||
**Modelo de datos (migración `v4.27_yonke_m1`, aplicada a todos los tenants):**
|
||||
- `inventory`: `condition` (nuevo/usado/reconstruido), `condition_grade` (A/B/C/D), `is_unique_piece`,
|
||||
`serial_number`, `donor_vin`, `donor_make`, `donor_model`, `donor_year`, `donor_mileage_km`,
|
||||
`ownership` (propio/consigna), `acquisition_cost`.
|
||||
- `part_number` acepta `NULL` → dos piezas idénticas de donadores distintos **no se fusionan**.
|
||||
- `inventory_images` (fotos múltiples por pieza).
|
||||
- `warranties.warranty_days` (garantía por días).
|
||||
- `consignments` / `consignment_items` (piezas de terceros a consignación).
|
||||
|
||||
**Módulo `/pos/yonke`** (pestañas):
|
||||
1. **Piezas usadas** — listado con filtros (texto, condición, grado, propiedad) y paginación.
|
||||
2. **Nueva pieza** — alta rápida: nombre, precio, **VIN del donador + botón "Decodificar"** (NHTSA)
|
||||
que autocompleta marca/modelo/año, km, propiedad, cobertura en días y **varias fotos**. Genera SKU
|
||||
automático `USADO-<últimos 6 del VIN>-<n>` si no se captura número de parte.
|
||||
3. **Por donador** — "¿Qué tengo de este auto?" (agrupado por VIN) y piezas de un donador concreto.
|
||||
4. **Consignaciones** — alta de consignante, piezas, montos y **liquidación** (reporte por periodo).
|
||||
|
||||
**Publicación en MercadoLibre (M4):** las piezas usadas se publican con `condition="used"`, el grado
|
||||
en el título, datos del donador como atributos y **hasta 10 fotos**.
|
||||
|
||||
**Permisos:** `yonke.view` (ver) y `yonke.manage` (crear/editar). El rol `owner`/`admin` los tiene por
|
||||
defecto; otros roles se otorgan desde Configuración → Permisos (grupo "Yonke").
|
||||
|
||||
## Cómo dar de alta un cliente de este segmento
|
||||
|
||||
1. Alta normal del tenant (el flujo de tenants ya funciona; ver S1).
|
||||
2. En el panel admin, activar el módulo `yonke_enabled` del tenant (opcional).
|
||||
3. Otorgar `yonke.view`/`yonke.manage` a los roles que correspondan (por defecto el owner ya los tiene).
|
||||
4. En `/pos/yonke` → **Nueva pieza**: capturar la primera pieza (VIN → Decodificar → fotos → guardar).
|
||||
5. Repetir por pieza; usar **Por donador** para ubicar todo lo que se tiene de un auto.
|
||||
|
||||
## Plan comercial
|
||||
|
||||
El plan **`yonke`** (MXN 299/mes) está disponible en `services/billing.py`: sin límite de productos,
|
||||
hasta 10 empleados, 1 sucursal y sin catálogo TecDoc.
|
||||
|
||||
## Pendientes conocidos
|
||||
|
||||
- La garantía por días se registra vía `POST /pos/api/warranties` con `warranty_days`; **aún no se
|
||||
auto-registra al vender** (el POS no registra garantías automáticamente hoy). Implementar el hook al
|
||||
vender una pieza con cobertura.
|
||||
- Las categorías de ML para usados pueden exigir reglas propias; el mapeo de categorías por segmento
|
||||
queda como mejora.
|
||||
- `donor_vin` no se publica en ML (no hay atributo universal); se puede añadir a la descripción.
|
||||
@@ -1,106 +0,0 @@
|
||||
# Auditoría #2 — Mejoras y IA no conversacional (post F0–F4)
|
||||
|
||||
> **Fecha:** 11-oct-2026 · **Base:** 5 investigaciones nuevas con evidencia en `archivo:línea`, SQL real y estado del host.
|
||||
> **Contexto:** segunda pasada **sobre el estado ya mejorado** (F0–F4 hechos). Busca la capa que falta, incluidas **regresiones** y **puntos ciegos** de la primera pasada.
|
||||
|
||||
---
|
||||
|
||||
## 0. Resumen ejecutivo
|
||||
|
||||
La primera pasada dejó una base sólida, pero esta segunda encontró **regresiones y puntos ciegos**:
|
||||
|
||||
**Corregido YA en esta pasada:**
|
||||
1. **Redis caído otra vez** (18:31 UTC): `redis.conf` hacía `bind 127.0.0.1 172.17.0.1`; si `docker0` no está arriba, **Redis muere en bucle** ("Cannot assign requested address") y con él **todo Celery** (fitment, publicación MeLi en lote, webhook). → Arreglado (`bind 127.0.0.1 ::1`) + `vm.overcommit_memory=1` + `reset-failed`; Celery vivo.
|
||||
2. **30 de 35 endpoints `/api/admin/*` SIN auth** (solo `tenants*`/`users*` la tenían): `DELETE /api/admin/parts`, `POST /api/admin/import/<type>`, `upload-image`, `export/<type>`… expuestos. → Guard global `before_request` que exige token **ADMIN** en **toda** la superficie; verificado 401.
|
||||
|
||||
**Lo demás que falta** (priorizado):
|
||||
- **MeLi sigue "inerte"**: 52 publicaciones de Rached con `inventory_id=NULL` (0/52), 31 órdenes pagadas sin convertir, `module_marketplace=false`, sync congelado desde el 11-jun, 0 imágenes. No es la API: es el **mapeo SKU→inventario + imágenes**.
|
||||
- **Template de tenant congelado en 32 tablas** (un tenant real tiene ~91): funciona solo porque el alta aplica v1.1→v4.27 sobre un template *desactualizado* → frágil.
|
||||
- **Flags de módulo inconsistentes** (`*_enabled` en dashboard vs `module_*` en POS): activar MercadoLibre/WhatsApp en el panel **no** se refleja en el POS.
|
||||
- **Yonke construido y vacío**: sin flag de módulo, sin link de navegación, sin plan asignable, 0 piezas.
|
||||
- **Lealtad sin cerrar el ciclo** (acumula pero no se puede canjear con descuento real).
|
||||
- **Backups**: sin offsite, sin retención (7.2 GB), **sin alerta de backup**; y `expire_demos.py` no está cableado a ningún timer.
|
||||
- **Reset de demo no limpia PII** (clientes/conversaciones quedan).
|
||||
- **La IA de embeddings está construida pero SIN usar**: `semantic_search.embed()/find_similar()` **no tienen un solo caller** → es la palanca más barata para media docena de mejoras.
|
||||
|
||||
---
|
||||
|
||||
## 1. Regresiones / puntos ciegos operativos (con evidencia)
|
||||
|
||||
| # | Hallazgo | Evidencia | Estado |
|
||||
|---|---|---|---|
|
||||
| 1 | **Redis muere por `bind` a docker0** | `redis-server.log:847` `Cannot assign requested address 172.17.0.1`; `redis.conf:88` | ✅ arreglado |
|
||||
| 2 | **32 endpoints `/api/admin/*` sin auth** | `dashboard/server.py` (35 rutas, 3 con ADMIN) | ✅ arreglado (guard global) |
|
||||
| 3 | **Admin de catálogo roto (500)** | `/api/admin/stats`,`/groups` → 500 (tablas `parts`/`part_categories` retiradas, `server.py:2716-2743`) | pendiente |
|
||||
| 4 | **Backups Oct 2–10 inexistentes** + sin offsite + sin retención + **sin alerta de backup** | `backups/`; `BACKUP_S3_BUCKET` sin set | parcial (timer ya corre) |
|
||||
| 5 | **`expire_demos.py` sin timer** | grep `systemd/`=0 | pendiente |
|
||||
| 6 | **Template congelado (32 tablas) + drift** (`sale_payments` ausente) | `tenant_template` 32 vs tenant real 91 | pendiente |
|
||||
| 7 | **Reset de demo deja PII** (no trunca `customers`,`whatsapp_messages`,`wa_*`,`audit_log`) | `manager/services/tenant_service.py:188-240` | pendiente |
|
||||
| 8 | **14 DB `tenant_*` huérfanas** (6 con PII de prueba) vs 8 registradas | `pg_database` | pendiente |
|
||||
| 9 | **Flags de módulo inconsistentes** | dashboard escribe `meli_enabled`, POS lee `module_meli` (`config_bp.py:1269`), manager usa `module_*` | pendiente |
|
||||
| 10 | **Manager sin nginx/TLS/2FA; auditoría vacía; bug de comparación de versiones** (`"v4.9">="v4.10"`) | `manager/services/migration_service.py:82` | pendiente |
|
||||
| 11 | **Sin TLS en el host, sin firewall; `5432`/`5003` en 0.0.0.0** | `ss -tlnp`; `iptables -S` ACCEPT | pendiente (decisión) |
|
||||
| 12 | **`nexus` comparte rol en TODAS las DB; sin roles por tenant** | `has_database_privilege`=t | pendiente |
|
||||
| 13 | **nginx del repo difiere del vivo** (falta bloque wildcard de tenants; sobra `nexus_quart`/`login2`) | `nginx/nexus-pos.conf` vs `/etc/nginx/...` | pendiente |
|
||||
| 14 | **Sin analytics/tracking** en la cara pública (cero GA/GTM/pixel) | grep = 0 | pendiente |
|
||||
| 15 | **`i18n.js` muerto** (fuerza `es`, cero `t()`), `*.min.js` desactualizados | `i18n.js:322` | pendiente |
|
||||
| 16 | **Aislamiento multi-tenant por proceso**: 8 workers × N tenants × pool; sin PgBouncer; scans cross-tenant O(n) | `tenant_db.py`, `marketplace_external_bp.py:113` | pendiente |
|
||||
| 17 | **Token del webhook de Alertmanager en claro en el repo** | `docker/alertmanager/alertmanager.yml` | pendiente |
|
||||
|
||||
---
|
||||
|
||||
## 2. MeLi / Yonke / WhatsApp — estado real y brechas
|
||||
|
||||
**MercadoLibre (Rached):** 52 listings, **`inventory_id` 0/52**, 31 órdenes `paid` sin convertir (`nexus_sale_id` NULL), `sync_errors` 52/52 (jun-11), `last_sync_at` 2026-06-11, 0 imágenes, `module_marketplace=false`. Bloqueo operativo = **mapear SKU→inventario + subir imágenes + encender el módulo**.
|
||||
Ausente comercialmente (0 refs en código): **descripción de anuncio**, **facturación automática al convertir** (usa `_create_sale_manual` que salta el hook de CFDI/contabilidad), **persistir comisión/rentabilidad**, variaciones, `catalog_listing`/`price_to_win`, promociones, reputación, **SLA de preguntas** (4 sin responder, una de febrero), multi-cuenta, backoff de rate-limit (429).
|
||||
|
||||
**Yonke:** código completo (M1–M6), **0 datos**. Falta: flag `module_yonke` (no existe), link en navegación, plan asignable desde admin, tenant piloto, exposición pública de usados, categorías ML de usados, botón "publicar usado" en la UI.
|
||||
|
||||
**WhatsApp:** 8 bridges vivos + WA CRM (`:3010`) pero `module_whatsapp=false` en todos, WA CRM sin cablear (secret placeholder), **0 mensajes**; y el flujo WA **no escribe actividades en el CRM** (aunque sí factura pedidos).
|
||||
|
||||
---
|
||||
|
||||
## 3. IA no conversacional — la siguiente ola (con prontitud de datos)
|
||||
|
||||
### 3.1 La palanca infrautilizada
|
||||
`pos/services/semantic_search.py` tiene `embed()` (4096 dims), `find_similar()`, `cosine_similarity()`, `rerank()` — **el rerank está integrado y el resto NO tiene un solo caller**. Es la base más barata para: matching, dedup, alternativas, limpieza de fitment, búsqueda semántica de bodega y respuestas de compatibilidad.
|
||||
|
||||
### 3.2 Priorizado por impacto/esfuerzo
|
||||
|
||||
| Prio | Oportunidad | Punto de integración | Datos (real) | Enfoque | Esfuerzo |
|
||||
|---|---|---|---|---|---|
|
||||
| **1** | **Ligar inventario→catálogo a escala** (habilita casi todo) | `sku_matcher.py:82,138`; endpoint `/inventory/match-catalog` | 355k items sin ligar; 17,246 catálogo; 406,393 xrefs | exact+interchange (ya) **+ embeddings** en el fallback de nombres (hoy `difflib`) | M |
|
||||
| **2** | **Enriquecer catálogo: descripciones e imágenes** | `catalog_import_service.py:14`; `image_scraper.py:55` | 95% de `inventory.description` vacío; 0/17,246 `supplier_catalog` con imagen/desc; hay imágenes en los Excel de `data/` | LLM para descripción ES; cargar imágenes de proveedor + visión para validar | S–M |
|
||||
| **3** | **"Alternativas / piezas similares" (feature muerta)** | `catalog_service._get_alternatives:1859`; `dashboard/server.py:1915` | `supplier_catalog_interchange` (406k) + catálogo 17k | reconstruir sobre interchange + `find_similar()` (hoy consulta tablas TecDoc retiradas → vacío) | M |
|
||||
| **4** | **Anuncios MeLi: título + descripción + categoría** | `marketplace_external_service.build_item_payload:237`; `meli_service.search_categories:280` | nombre/marca/compat/donor | LLM (categoría vía `domain_discovery`) + descripción | S–M |
|
||||
| **5** | **Claves SAT de productos** (`clave_prod_serv`/`clave_unidad` 100% vacías) | `cfdi_builder.py:178`; `cfdi_facturapi_builder.py:83` | nombre/categoría/marca de 170k | reglas + LLM → CFDI correcto | S |
|
||||
| **6** | **Persistir comisión/rentabilidad por listing** | `marketplace_listings` (nuevas columnas); `_calculate_meli_net_price:387` (ya calcula, no guarda) | listings + costo | guardar fee/net/costo → margen por publicación | S |
|
||||
| **7** | **Facturación automática al convertir orden MeLi** | `convert_order_to_sale`/`_create_sale_manual:1759` (salta `pos_engine` CFDI) | órdenes MeLi | enrutar por `process_sale` o encolar CFDI | S |
|
||||
| **8** | **Extracción de pedidos desde WhatsApp (texto/voz/foto)** | `whatsapp_bp.py:376` (hoy menú rígido) | `whisper_local` (voz), `part_vision`/`invoice_ocr` (foto), catálogo | LLM/NER → cotización borrador | M–L |
|
||||
| **9** | **Limpieza de fitment ruidoso** | `qwen_fitment.py:113,298`; `inventory_vehicle_compat.py:322` | 41.5M filas (estrada ~131/pieza; `qwen` 99.5%) | apagar `qwen` por defecto en bulk-import + scorear ruido con embeddings/rerank | M |
|
||||
| **10** | **Búsqueda semántica de bodega** (150,778 items, multi-vendedor) | `peer_service`, `catalog_service.get_bodega_availability:2562` | 150k con nombre/SKU | embeddings + rerank (offline) | M |
|
||||
| **11** | **Métricas de negocio por tenant + anomalías multi-tenant + salud de cartera** | master rollup diario; `anomaly_detector.py` (intra-tenant) | por definir | rollup + z-score entre tenants | M |
|
||||
| **12** | **Dedup/normalización con embeddings** | `sku_matcher.match_name:82` | estrada: 6,121 grupos de `name` duplicado | `find_similar()` clustering | M |
|
||||
|
||||
### 3.3 No vale la pena todavía (cold-start honesto)
|
||||
- **Forecast de demanda por SKU, elasticidad/precios, predicción de quiebre, fraude/anomalías por SKU, churn/scoring por cliente**: hay **~70 líneas de venta en toda la plataforma** y **0 compras**. `cross_sell` estadístico y `anomaly_detector` se auto-marcan en `cold_start`. Dejar **diseñado, no invertir** hasta tener miles de transacciones por tenant.
|
||||
- **IA de ops**: triage de logs con LLM y agrupación de alertas con ML = **overkill** (volumen KB/día, 1 receiver). Lo útil es **sin IA**: alerta de backup/celery/tenant caído y `predict_linear` para disco.
|
||||
|
||||
---
|
||||
|
||||
## 4. Roadmap propuesto
|
||||
|
||||
| Fase | Foco | Entregable verificable |
|
||||
|---|---|---|
|
||||
| **F5 — Cerrar lo crítico (2-3 días)** | Redis estable (hecho) · auth admin (hecho) · admin catálogo sin 500 · `expire_demos` a timer · unificar flags de módulo · reset de demo con PII · limpiar DB huérfanas · alerta de backup | 0 endpoint admin sin auth; módulos coherentes dashboard↔POS; alerta de backup llega |
|
||||
| **F6 — MeLi de verdad (1 sem)** | Mapear los 52 listings → inventario · pipeline de imágenes · encender `module_marketplace` y `module_yonke` · publicar con descripción/categoría · persistir comisión · facturación automática al convertir | Publicar SKU con imagen desde el POS → vender en ML → orden convertida + CFDI encolado |
|
||||
| **F7 — IA de catálogo (2 sem)** | Ligado masivo con embeddings · descripciones + imágenes · "alternativas/similares" funcional · SAT keys · limpieza de fitment | ≥70% de items ligados a catálogo; alternativas devuelven resultados; fitment por pieza < 20 filas |
|
||||
| **F8 — Escala y cartera (2-3 sem)** | PgBouncer + pool · métricas de negocio por tenant + anomalías multi-tenant + salud de cartera · analytics en la cara pública · Yonke piloto · WA CRM por negocio · TLS/firewall/roles | panel de cartera; 30+ tenants sin agotar conexiones; 1 tenant Yonke en producción |
|
||||
|
||||
---
|
||||
|
||||
## 5. Métricas de éxito
|
||||
- **Confiabilidad:** 0 servicios en bucle; backups diarios verificados + offsite; alertas que llegan.
|
||||
- **Cierre:** 0 endpoints `/api/admin/*` sin auth; flags de módulo coherentes.
|
||||
- **MeLi:** 100% de listings con `inventory_id`; >0 órdenes convertidas + facturadas; publicaciones con imagen y descripción.
|
||||
- **IA de catálogo:** % items ligados a catálogo (0→≥70%); tiempo de alta de anuncio; búsquedas sin resultado.
|
||||
@@ -1,125 +0,0 @@
|
||||
# Auditoría completa de la plataforma + IA no conversacional — Nexus Autoparts
|
||||
|
||||
> **Fecha:** 11-oct-2026 · **Base:** 6 investigaciones con evidencia en `archivo:línea`, SQL real y estado del host.
|
||||
> **Énfasis solicitado:** áreas de mejora + **IA no conversacional** (predicción, clasificación, visión, OCR, anomalías, matching).
|
||||
|
||||
---
|
||||
|
||||
## 0. Resumen ejecutivo
|
||||
|
||||
La plataforma es hoy, en los datos, **un catálogo de autopartes con fitment masivo (≈21 M relaciones pieza-vehículo y ≈406 K equivalencias) más un POS funcional pero con poco volumen transaccional** (máximo 24 ventas en un tenant; la única serie temporal real son 4,582 ventas históricas de Atlas en 19 meses). El código de IA existente es **LLM + visión (QWEN/Hermes/Gemma) y Meilisearch**; no hay ML clásico instalado.
|
||||
|
||||
Hay tres frentes con problemas:
|
||||
|
||||
1. **Operación/seguridad (grave y barato de arreglar):** firewall abierto, Postgres con `trust` en toda la LAN, secretos en texto claro, **Redis caído 6 días → todo Celery muerto**, **alertas silenciosas** (sin `ALERT_PHONES`), backups que fallaron 5 días sin avisar.
|
||||
2. **Bugs de producto en caja** (visibles al usuario hoy) y funciones construidas pero **sin cablear** (garantías, notificaciones de taller, lealtad, venta cruzada, flotillas).
|
||||
3. **Calidad de datos** que bloquea el catálogo, la publicación en MercadoLibre y el SEO: **0 imágenes, 0 descripciones, `reorder_point` 100% NULL, 0 precios de proveedor, 52 listings sin `inventory_id`**.
|
||||
|
||||
**IA no conversacional: qué sí tiene sentido hoy** (no depende de volumen de ventas): auto-categorización de productos (ML y taxonomía), deduplicación/matching de SKUs, **OCR/VLM de facturas de proveedor**, **visión para graduar piezas usadas (Yonke)**, semantic search por embeddings, y **detección de anomalías de negocio** (descuentos/stock). **Qué NO vale la pena todavía:** forecast de demanda (cold start), triage de logs con LLM, agrupación de alertas con ML (overkill).
|
||||
|
||||
---
|
||||
|
||||
## 1. Operación y seguridad — P0 (hacer ya)
|
||||
|
||||
| # | Hallazgo | Evidencia | Acción |
|
||||
|---|---|---|---|
|
||||
| 1 | **Postgres con `trust` para toda la LAN** + puerto `5432` abierto en `0.0.0.0` | `pg_hba.conf`: `host all all 192.168.10.0/24 trust`; `iptables -S` → `INPUT ACCEPT` sin reglas; `ufw` no instalado | Quitar `trust` (usar `scram-sha-256`), `listen_addresses` a localhost/LAN acotada, firewall |
|
||||
| 2 | **Secretos en claro en unit files world-readable** | `nexus-pos.service` (QWEN/FACTURAPI/JWT/INTERNAL/MEILI keys), `nexus-manager.service`, `nexus.service`, `meilisearch.service` | `EnvironmentFile` modo `600` o `systemd-creds`; rotar llaves |
|
||||
| 3 | **Redis caído 6 días → Celery en bucle de muerte** | `systemctl status redis-server` = failed; `celery.log`: `Cannot connect to redis://localhost:6379/1 (55/100)` desde hace 6 días | Levantar Redis (o quitarlo del diseño); hoy **toda tarea Celery está caída** |
|
||||
| 4 | **Alertas silenciosas** | `/etc/nexus/alert.env` sin `ALERT_PHONES` → `alerts.log: ALERT no ALERT_PHONES configured`; `RedisDown` activo 6 días sin consecuencia | Poblar `alert.env`, segundo canal (email/Slack), alertas de backup/Celery/app-down |
|
||||
| 5 | **Backups: fallaron 5 días en silencio** (7–11 oct); solo locales; 3.5 GB zombi | `journalctl -u nexus-backup`; `BACKUP_S3_BUCKET` sin definir; cleanup no borra `.dump`/`.sql` viejos | Ya corregí el `((deleted++))`; **falta validar el timer 02:00**, offsite S3 y limpieza |
|
||||
| 6 | **Sin TLS en este host** | solo `listen 80`; no hay 443 | Confirmar proxy/Cloudflare delante, o terminar TLS |
|
||||
| 7 | **Endpoints expuestos** | Grafana `admin/nexus2026`; Prometheus/Alertmanager sin auth; webhook `/pos/api/notifications/webhook` POST **sin auth** | Auth + bind a localhost |
|
||||
| 8 | **Servicios como `root`** y **timers muertos** (`nexus-quart`, `cache-warm`, `mv-refresh`, `cron.d` inexistente) | `systemctl status` | Erradicar jobs muertos / matrices obsoletas |
|
||||
|
||||
**Observabilidad:** Prometheus/Grafana/Alertmanager corren y scrapean infra, pero **la app no expone `/metrics`** (`:5001/metrics` → 404; `grep prometheus_client` = 0) → el dashboard "Nexus — Application" está **vacío**. Falta `pg_stat_statements` cargado (`shared_preload_libraries` vacío) y `log_min_duration_statement` activo.
|
||||
|
||||
**Escalabilidad:** el pool es **por proceso** (`tenant_db.py:24-32`, `minconn=2, maxconn=20` × 8 workers gunicorn) → ~160 conexiones/tenant en el peor caso; **sin PgBouncer**. 8 tenants van sobrados; **a ~15-20 tenants calientes revienta `max_connections=200`**. A 100 es insostenible.
|
||||
|
||||
---
|
||||
|
||||
## 2. Bugs de producto en caja — P0/P1
|
||||
|
||||
| Sev | Bug | Evidencia |
|
||||
|---|---|---|
|
||||
| P0 | **Venta offline rota**: `totals['total']` no existe → `ReferenceError`, el ticket offline nunca se muestra | `pos/static/js/pos.js:1587` |
|
||||
| P0 | **Devolución muestra reembolso $0**: lee `res.refund_total`, el API devuelve `total_refund` | `pos.js:978` vs `pos_bp.py:2628` |
|
||||
| P0 | **Apartado liquidado no muestra la venta**: lee `res.sale_id`, el API devuelve `{id,...}` | `pos.js:1054` vs `pos_bp.py:2377` |
|
||||
| P0 | **"Facturar CFDI" no hace nada**: se manda `generate_cfdi` pero **ningún backend lo lee** | `pos.js:1519`; `grep generate_cfdi` en `pos/**/*.py` = 0 |
|
||||
| P0 | **Reembolsos no entran al corte de caja** → efectivo esperado sobreestimado | `pos_bp.py:2454` no inserta `cash_movements`; `cashregister_bp.py:195-314` no resta devoluciones |
|
||||
| P1 | **Apartado cancelado no revierte contabilidad** (pasivo fantasma 240) | `pos_bp.py:2389-2449` |
|
||||
| P1 | **Apartado se liquida siempre como efectivo** (aunque sea tarjeta) | `pos_bp.py:2291,2332` |
|
||||
| P1 | **Descuento/tope de precio solo se valida en venta directa** (cotización/apartado la evaden) | `pos_engine.py:290-295`; `pos_bp.py:822,1212,1704,1864` |
|
||||
| P1 | **Devoluciones sin control de rol** (solo `pos.sell`) | `pos_bp.py:2455` |
|
||||
| P1 | **500 por query param sin validar** (`?per_page=abc`) | `error.log`; `/pos/api/marketplace-ext/*` |
|
||||
|
||||
**Construido pero sin cablear:** garantías/RMA nunca se registran al vender (`warranty_bp.py:41`); `notify_order_ready`/`notify_maintenance_due` existen y no se llaman; **lealtad acumula pero no se puede canjear en el POS**; **venta cruzada muerta** (`part_kits.py` no se importa); flotillas fuera del menú (`sidebar.js` no incluye `fleet`); analytics de cliente (LTV/churn) calculado y no mostrado (`crm_bp.py:227`, `crm_engine.py:289`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Calidad de datos y catálogo
|
||||
|
||||
- **Imágenes: 0 en todos los tenants** (`image_url` vacío; `inventory_images` 0 filas). Bloquea publicar en ML, el catálogo público y el SEO.
|
||||
- **`supplier_catalog`**: 17,246 items, **0 con imagen, 0 con descripción**.
|
||||
- **`reorder_point`/`reorder_qty` 100% NULL**; `min_stock=0` en ~95% → **las alertas de stock bajo nunca disparan**; `reorder_alerts` = 0 filas.
|
||||
- **Filtro de sucursal roto en reorden**: filtra por `inventory.branch_id`, que es NULL al 100%.
|
||||
- **Motor de Proveedores/Órdenes de Compra completo y sin uso** (`purchase_orders` = 0 filas en todos los tenants); `supplier_catalog_prices` = 0; `catalog_part_id` 0/14,568 → sin precios/margen de proveedor.
|
||||
- **Fitment**: `inventory_vehicle_compat` de Estrada = **20.7 M filas / 129,101 items ≈ 160 por pieza** (99.5% salida QWEN, conf 0.843) — **ruidoso**; Capa/Rached usan `supplier_catalog` (0.95, limpio).
|
||||
- **Búsqueda**: Meili cubre inventario y proveedor, pero **el índice `nexus_parts` (1.55 M docs TecDoc) sigue re-indexándose y está muerto**; los aliases/cross-refs **no entran** a `smart_search`; sin sinónimos.
|
||||
- **Estrada y Originales Autopartes son clones exactos** (mismos 157,243 SKUs y 20,687,557 fitments) → cuéntalos una vez.
|
||||
|
||||
---
|
||||
|
||||
## 4. IA no conversacional — qué construir (y en qué orden)
|
||||
|
||||
### 4.1 Lo que ya existe (reutilizable)
|
||||
- `ai_chat.classify_part()` (`ai_chat.py:379`) → número OEM a `{name, brand, vehicle, category}`.
|
||||
- `ai_chat.chat_with_image()` (`ai_chat.py:309`) → **visión**: identifica una autoparte por foto (Gemma/QWEN).
|
||||
- `qwen_fitment.py` → compatibilidad pieza-vehículo validada contra la maestra (con `confidence`).
|
||||
- `whisper_local.py` → **ASR local** (faster-whisper, CPU) para notas de voz.
|
||||
- Meilisearch (keyword + typos; soporta vectores) con índices poblados.
|
||||
- `nexpart_taxonomy.py` → 558 etiquetas (dataset listo para clasificador); `wa_learning.get_learning_pairs_for_training()` (`:73`) → pares texto→producto etiquetados.
|
||||
- **Infra lista para OCR**: `tesseract` + `pytesseract` + `ffmpeg` + `pdftotext` ya instalados.
|
||||
- **No hay** `sklearn`/`torch`/`transformers`/`faiss`; **sin GPU** (12 vCPU / 47 GB). Inferencia en CPU o vía QWEN/Hermes.
|
||||
|
||||
### 4.2 Propuestas priorizadas por **viabilidad de datos** (no por moda)
|
||||
|
||||
| Prio | Implementación | Punto de integración | Datos (reales hoy) | Enfoque | Beneficio |
|
||||
|---|---|---|---|---|---|
|
||||
| **A1** | **Auto-categorización de productos** (taxonomía Nexpart + categoría ML) | `inventory_bp.py:129` (hoy no persiste) y `marketplace_external_service.py` (publish); ML ya tiene `domain_discovery` en `meli_service.py:280` | 171 K SKUs, 18 K sin categoría; taxonomía de 558 etiquetas | Reglas → embeddings (Meili) → LLM (`classify_part`) como fallback | Habilita navegación/filtros y **publicación en lote sin elegir categoría a mano** |
|
||||
| **A2** | **Deduplicación / matching de SKUs** (poblar `catalog_part_id` + aliases) | `catalog_import_service.py:57-76` | 406 K interchange + 9 K + 4 K aliases | Normalización + blocking por prefijo + similitud de embeddings | Desbloquea precios de proveedor, margen y enriquecimiento (hoy link 0%) |
|
||||
| **A3** | **OCR/VLM de facturas de proveedor** | `supplier_engine.receive_po:214` / compra directa `inventory_bp.py:1198` | Facturas PDF/foto (infra ya instalada) | `pdftotext`+Tesseract y VLM (`chat_with_image`) + validación contra catálogo | Elimina captura manual y **alimenta `supplier_catalog_prices`** (hoy vacío) |
|
||||
| **A4** | **Visión para graduar piezas usadas (Yonke)** | `yonke_bp.py:145` (subida de fotos) | Fotos de piezas (hoy 0) | VLM que sugiera `condition_grade` (A/B/C/D) y defectos; validar encuadre antes de publicar | Alta consistente <30 s, menos devoluciones, confianza del comprador |
|
||||
| **A5** | **Anomalías de negocio** (descuentos, merma de stock, precios mal capturados) | post-commit de venta `pos_engine.py:462` + job nocturno sobre `sale_items`/`inventory_operations`/`audit_log` | Ventas/ops por tenant | EWMA/z-score robusto o Isolation Forest por empleado/SKU/sucursal | Detecta fuga de margen que los topes no capturan (apartados/cotizaciones los evaden) |
|
||||
| **A6** | **Semantic search** (embeddings + sinónimos) | `meili_search.py:23`, `index_meilisearch.py:47` | Nombres/SKUs/cross-refs | Vectores en Meili (híbrido) + sinónimos + indexar interchange | "balatas"→"Brake Pad"; búsqueda por número cruzado |
|
||||
| **A7** | **Clasificación/priorización de preguntas de MeLi** (+ auto-respuesta por plantilla) | `service.py:1151` (`sync_questions` → `_upsert_question`) | 18 preguntas (4 sin responder desde mayo) | Clasificador de intención (compat/precio/envío) + plantillas + `vehicle_compatibility` | SLA (ML penaliza respuestas lentas) y más conversión |
|
||||
| **A8** | **Enriquecimiento de imágenes** (scraping + tagging por nombre de parte) | `image_scraper.bulk_import:71`, `image_service.save_image:74` | `supplier_catalog` 0 imágenes | Scraping existente + VLM para validar/clasificar | Desbloquea catálogo público, SEO y publicación ML |
|
||||
| **A9** | **Score de riesgo de crédito (fiado)** | validación en `pos_engine.py:314`, `get_customer` `customers_bp.py:133` | Clientes + aging (pocas ventas aún) | Regresión logística/heurística ponderada (mora>30d) | Menos incobrables; subir límites a buenos clientes |
|
||||
| **A10** | **Venta cruzada "esto + esto"** | hook muerto `part_kits.build_kit_text`; `pos.js:403` al agregar al carrito | `sale_items` + `KIT_SUGGESTIONS` | Reglas de asociación/co-ocurrencia, fallback a kits | +5-15% ticket típico |
|
||||
| **A11** | **Forecast de demanda / punto de reorden** | `reorder_engine.py:26,78` | **Cold start**: máx 24 ventas; única serie = Atlas `historical_sales` (4,582 / 19 meses) | Media móvil/Exponencial (ROP = μ·LT + z·σ); ML real cuando haya volumen | ⚠️ Hoy **bloqueado por datos**; arrancar con reglas y migrar |
|
||||
| **A12** | **Churn / próxima compra** | `crm_engine.py:289` (hoy umbrales fijos) | 52% de compradores de Atlas recompran | BG/NBD o regresión de intervalo intercompra | Campañas de reactivación dirigidas |
|
||||
|
||||
### 4.3 Lo que NO vale la pena (honestidad)
|
||||
- **Triage de logs con LLM**: el volumen de errores es de KB/día; deduplicar por firma con reglas gana y cuesta cero.
|
||||
- **Agrupación de alertas con ML**: hoy hay **un** receiver y **cero** alertas útiles; arreglar el routing da el 100% del valor.
|
||||
- **Predicción de capacidad con IA**: `predict_linear(disk[6h], 7d)` en PromQL ya responde; es una alerta, no un modelo.
|
||||
- **Forecast de demanda hoy**: sin historial transaccional, cualquier modelo sería ruido.
|
||||
|
||||
---
|
||||
|
||||
## 5. Roadmap propuesto
|
||||
|
||||
| Fase | Foco | Entregable verificable |
|
||||
|---|---|---|
|
||||
| **F0 — Blindar (2-3 días)** | Seguridad (pg_hba/firewall/secretos), Redis arriba, alerting, validar backup timer + offsite, 500 de query params, borrar jobs muertos | Celery vivo; alerta de backup llega a un humano; `5432` cerrado |
|
||||
| **F1 — Caja correcta (1 sem)** | Bugs P0 de `pos.js`, reembolsos→caja, reversa de apartado, forzar topes de descuento también en cotización/apartado, cablear/eliminar `generate_cfdi` | Corte de caja cuadra con devoluciones; ticket offline funciona |
|
||||
| **F2 — IA de datos (2-3 sem)** | A1 auto-categorización, A2 dedup SKUs, A3 OCR facturas, A8 imágenes | ≥90% SKUs categorizados; factura de proveedor → líneas en <1 min; catálogo con imágenes |
|
||||
| **F3 — IA en el flujo (2-3 sem)** | A4 visión Yonke, A6 semantic search, A5 anomalías, A7 preguntas, A10 venta cruzada | Grado sugerido por foto; búsqueda por sinónimo; alerta de descuento anómalo; sugeridos en carrito |
|
||||
| **F4 — Escala y producto (3-4 sem)** | PgBouncer + pool, `/metrics` de app + `pg_stat_statements`, A9 crédito, A12 churn, A11 forecast cuando haya datos, WA CRM piloto | 30+ tenants sin agotar conexiones; dashboard con métricas reales; score de crédito en fiado |
|
||||
|
||||
---
|
||||
|
||||
## 6. Métricas de éxito
|
||||
- **Seguridad/ops:** 0 servicios DB con `trust`; 0 secretos en unit files legibles; alertas de backup/Celery entregadas; 30 días de backups verificados (local + offsite).
|
||||
- **Producto:** corte de caja cuadra con devoluciones; 0 `ReferenceError` en POS; 100% de SKUs categorizados; catálogo público con >80% de items con imagen.
|
||||
- **IA:** ≥90% de facturas de proveedor parseadas sin captura manual; grado de pieza usada sugerido y aceptado en ≥70% de altas; búsquedas sin resultado reducidas >30% (semantic); ≥1 alerta real de anomalía accionada por mes.
|
||||
@@ -1,199 +0,0 @@
|
||||
# Plan Maestro de Mejoras — Nexus Autoparts
|
||||
|
||||
> **Fecha:** 11 Oct 2026 · **Base:** auditoría completa del sistema (6 investigaciones con evidencia en archivo:línea, SQL y curl)
|
||||
> **Énfasis:** MercadoLibre (eje comercial) + apertura del mercado de **partes usadas** (yonkes/deshuesaderos)
|
||||
|
||||
---
|
||||
|
||||
## 0. Resumen ejecutivo
|
||||
|
||||
El sistema es un **esqueleto potente con el ciclo comercial roto en los últimos metros**. Lo que funciona bien es la base (motor de ventas con locks y partida doble, aislamiento multi-tenant, compatibilidad vehicular, OAuth de MeLi con refresh persistido, WA CRM recién instalado). Lo que está roto es justo lo que genera ingresos:
|
||||
|
||||
| # | Bloqueante | Impacto | Evidencia |
|
||||
|---|---|---|---|
|
||||
| 1 | **Backups caídos desde el 1-oct** | Sin respaldo fresco de ningún tenant (riesgo existencial) | `scripts/backup_selective.sh:299-303` — `((deleted++))` devuelve 1 y mata el script bajo `set -euo pipefail` |
|
||||
| 2 | **Alta de tenants falla** | No se puede onboardear ningún cliente nuevo | `tenant_manager.py:201-211` aplica `v1.2_subdomain.sql` (migración de *master*) al tenant → rollback |
|
||||
| 3 | **CFDI 100% roto** | Ninguna factura se timbra | `invoicing_bp.py:65` — `SELECT … codigo_postal FROM branches` (la columna es `cp`) |
|
||||
| 4 | **Órdenes MeLi nunca se convierten a venta** | 31/31 órdenes de Rached sin vender en el POS | `marketplace_external_service.py:1619` — `inventory_record_sale(..., reference=)` no existe en la firma |
|
||||
| 5 | **Publicar en MeLi desde el POS está muerto** | Validar/Publicar no hacen nada (ReferenceError) | `inventory.js:2069,2135` llaman `_collectMeliCustomData()`, que no existe |
|
||||
| 6 | **Sync de stock MeLi falla 52/52** | Envía `price:0` a ML cada 15 min (0 éxitos desde junio) | `tasks.py:135-145` — listings con `inventory_id NULL` |
|
||||
| 7 | **Webhook MeLi nunca procesa** | Sin notificaciones en tiempo real, público y sin firma | `marketplace_external_bp.py:775-790` — consulta `tenant_config` en la BD maestra (no existe ahí) |
|
||||
| 8 | **Catálogo público con 500s** | El visitante nuevo ve error en buscador, categorías y detalle | `dashboard/server.py:977,1281,1994` contra tablas TecDoc retiradas |
|
||||
| 9 | **Plan `demo` → fallback `free`** | Estrada (157,243 productos) bloqueada en 100 productos | `billing.py:25-28` + `tenants.plan='demo'` |
|
||||
| 10 | **`/api/admin/tenants*` sin autenticación** | Cualquiera en internet lista y modifica módulos de tenants | `dashboard/server.py:4660,4674,4691` |
|
||||
|
||||
**Y el hallazgo estratégico:** el negocio de **partes usadas es hoy imposible de operar** en el sistema (no existe condición, ni pieza única, ni vehículo donador, ni multi-foto) — y MeLi publica todo como `"condition":"new"` hardcodeado. Abrir ese mercado requiere un cambio de modelo de datos, no cosmético.
|
||||
|
||||
---
|
||||
|
||||
## 1. MercadoLibre — cerrar el ciclo antes de soñar en grande
|
||||
|
||||
### 1.1 Fase A — Desbloquear lo que ya está construido (1 semana)
|
||||
|
||||
| Tarea | Detalle | Esfuerzo |
|
||||
|---|---|---|
|
||||
| A1 | Implementar `_collectMeliCustomData()`, `_pollMeliAsync()`, `_renderPublishResult()` en `inventory.js` (los inputs de título/precio/stock y atributos **ya se generan pero nunca se envían**) y mandar `custom_data` al backend (`marketplace_external_service.py:669-673`) | M |
|
||||
| A2 | Corregir `convert_order_to_sale`: usar `record_sale(conn, inventory_id, branch_id, quantity, sale_id=…)` — hoy `TypeError` → rollback → 31 órdenes sin convertir (`service.py:1619`, `inventory_engine.py:199`) | S |
|
||||
| A3 | `import_existing_listings`: poblar `inventory_id` por `seller_custom_field`/`part_number`/alias y **saltar el push cuando `price<=0`**; el `ON CONFLICT` no aplica con NULL (`service.py:1785`) | S |
|
||||
| A4 | Webhook: resolver tenant por `meli_user_id` consultando cada `tenant_config` de tenant (no la maestra), validar `X-Signature` con `validate_webhook_signature` (ya existe, nunca se llama) y procesar el `resource` correcto (no un sync completo) | M |
|
||||
| A5 | Normalizar `status` de preguntas (`UPPER`) — hoy 4 preguntas reales no se pueden responder por comparar `'unanswered'` vs `UNANSWERED` (`marketplace_external.js:516`) | S |
|
||||
| A6 | Pasar `tenant_conn` a `_get_meli_service` en `sync_meli_stock_price_task` (`tasks.py:112`) para no perder el refresh token rotado | S |
|
||||
| A7 | Pausar/reactivar automáticamente por stock (0 → pausar, >0 → reactivar) en la cola `meli_sync_queue` + drenarla por timer | M |
|
||||
| A8 | Sincronizar órdenes en todos los estados (`cancelled`, `payment_required`…) y corregir `buyer_nickname` (ML ya no manda `from.nickname`) | S |
|
||||
| A9 | Corregir el **estimador de comisiones por artículo** (busca `#meliFee-<id>` en un DOM que nunca lo crea, `inventory.js:2295`) | S |
|
||||
| A10 | Definir/otorgar el permiso `marketplace.manage` (no existe en ningún tenant) y proteger los GET del módulo | S |
|
||||
|
||||
**Resultado:** el ciclo MeLi→POS (publicar → vender → convertir → facturar) cierra por primera vez.
|
||||
|
||||
### 1.2 Fase B — Publicar en volumen (2-3 semanas) ← el verdadero cuello de botella
|
||||
|
||||
| Tarea | Detalle | Esfuerzo |
|
||||
|---|---|---|
|
||||
| B1 | **Pipeline masivo de imágenes** — el bloqueo real: 0 imágenes en 6,851 SKUs de Rached, 0 en 14,568 de Capi, 1 en 157,243 de Estrada. ML exige imagen. Usar los servicios de scraping existentes (`services/image_scraper.py`, `image_service.py`) + una cola por lotes con revisión | L |
|
||||
| B2 | **Selección y publicación por lotes real**: hoy la UI solo selecciona una página de 50 y un solo `category_id` para todo el lote. Agregar: seleccionar todo el resultado filtrado, categorización asistida por IA (nombre+marca→categoría ML con `domain_discovery`), y confirmación por grupos | M |
|
||||
| B3 | **Rate-limit/backoff** en el worker de publicación (Celery) con reintentos y progreso visible (barra + reporte de fallidos por motivo) | M |
|
||||
| B4 | **Ciclo export→edición→reimportación** para fitment e imágenes (hoy el export no incluye compat ni image_url) | M |
|
||||
|
||||
### 1.3 Fase C — Vender más (3-4 semanas)
|
||||
|
||||
| Oportunidad | Hoy | Qué agregar |
|
||||
|---|---|---|
|
||||
| **Variaciones** | Nada (0 referencias a `variations`) | Agrupar SKUs en una publicación con `variations`/`attribute_combinations` (mismo producto, varias medidas) |
|
||||
| **Catálogo de MeLi** | Solo `category_id` | Detectar categorías que **exigen catálogo** (`catalog_listing`, `catalog_product_id`), avisar en la UI y usar `price_to_win` para competir por la oferta ganadora |
|
||||
| **Promociones** | Nada | Oferta del día, descuentos por volumen, `sale_price` por publicación |
|
||||
| **Envíos** | Solo me2/custom, sin etiquetas | Flex, generación de etiqueta, `mark_ready_to_ship` (existe y nunca se usa), tracking |
|
||||
| **Preguntas** | Manual y roto | Auto-respuesta con `ai_chat.py` + plantillas por categoría + SLA visible |
|
||||
| **Reputación** | Nada | Panel con `/users/{id}/reputation`: nivel, reclamos, cancelaciones, tiempo de despacho |
|
||||
| **Rentabilidad por publicación** | Solo el estimado al publicar (no se guarda) | Persistir comisión + envío + costo en `marketplace_listings` y panel de margen real por SKU |
|
||||
| **Facturación de ventas MeLi** | Ningún enganche a CFDI | Encolar CFDI automático al convertir orden (con RFC del comprador si lo pide) |
|
||||
| **Cancelaciones/reintegros/claims** | Solo estado local | Llamar a la API de ML (cancelar, devoluciones, mediaciones) con SLA |
|
||||
| **Multi-cuenta** | 1 cuenta MeLi por tenant | Varias cuentas MeLi por tenant (varias marcas) con selección por publicación |
|
||||
| **Panel comercial** | Nada | Ventas MeLi vs POS, conversión, SKUs sin stock con publicación activa, top ventas, alertas de publicaciones caídas |
|
||||
|
||||
### 1.4 Fase D — Robustez operativa
|
||||
|
||||
- Onboarding asistido de credenciales MeLi (hoy es manual y el fallo típico —ACCA— es un `client_secret` regenerado que nadie detecta hasta que el cliente llama; ya mejoramos el mensaje, falta un **chequeo proactivo diario** que avise antes).
|
||||
- Versionar el timer de sync en el repo (hoy vive solo en `/etc/systemd/system`).
|
||||
- Pruebas automatizadas del módulo (hoy: **cero** tests de MeLi).
|
||||
|
||||
---
|
||||
|
||||
## 2. Abrir el mercado: negocios de SOLO partes usadas (yonkes/deshuesaderos)
|
||||
|
||||
### 2.1 Por qué hoy no se puede (evidencia)
|
||||
|
||||
| Requisito del yonke | Estado | Dónde se rompe |
|
||||
|---|---|---|
|
||||
| Pieza única (1-de-1, sin código de parte) | ❌ | `part_number` es **NOT NULL + UNIQUE global** (`v4.0_multi_branch.sql:214`); dos espejos iguales de donadores distintos **se fusionan** en el import |
|
||||
| Saber de qué auto salió (donador) | ❌ | No hay `donor_vin`/`donor_mileage`; la compat existente es "sirve para", no "salió de" |
|
||||
| Condición nuevo/usado/A/B/C | ❌ | **0 columnas** de condición en todo el esquema; MeLi publica `"condition":"new"` hardcodeado |
|
||||
| Fotos múltiples (ángulos + daños) | ❌ | Un solo `image_url` varchar; cada subida **reemplaza** la anterior (`inventory_bp.py:995`) |
|
||||
| Garantía por días (15/30 días) | ⚠️ | Solo `warranty_months` (mínimo 1 mes) |
|
||||
| Buscar "todo lo que tengo de un Civic 2015" | ❌ | La navegación por vehículo exige `part_number` para poblar compat |
|
||||
| Consignación / piezas de terceros | ❌ | No existe modelo de propiedad; todo el stock se asume propio |
|
||||
| Valuación con costo 0 | ⚠️ | Vender funciona, pero los reportes de valoración/ABC dan $0 (`inventory_bp.py:1879`) |
|
||||
|
||||
### 2.2 Plan de producto: "Nexus Yonke" (5-6 semanas)
|
||||
|
||||
**M1 — Modelo de datos (1 semana)**
|
||||
- `inventory`: `condition` (`nuevo|usado|reconstruido`), `condition_grade` (A/B/C/D), `is_unique_piece`, `serial_number`, `donor_vin`, `donor_make/model/year`, `donor_mileage_km`, `ownership` (`propio|consigna`), `acquisition_cost`.
|
||||
- **Relajar unicidad**: índice único **parcial** `WHERE part_number IS NOT NULL` + auto-SKU para piezas únicas (`USADO-<VIN-corto>-<secuencia>`), manteniendo el flujo de partes nuevas intacto.
|
||||
- Nueva tabla `inventory_images (inventory_id, url, order, is_primary)` con migración del `image_url` actual.
|
||||
- `warranties`: `warranty_days` + política por defecto de usados en `tenant_config`.
|
||||
- Nuevas tablas `consignments` / `consignment_items` (tercero, pieza, precio pactado, comisión, estado: en_consigna/vendida/devuelta/liquidada).
|
||||
|
||||
**M2 — Alta rápida en mostrador (1-2 semanas)**
|
||||
- Flujo "Registrar pieza usada": **foto múltiple** (cámara del tablet) + **VIN del donador** (el decodificador NHTSA ya existe: `/pos/api/catalog/vin`) + pieza + precio + condición. Objetivo: **< 30 segundos por pieza**.
|
||||
- **Ingreso de donador completo**: registrar el auto (VIN, km, color) y generar N piezas colgadas de ese donador con un solo flujo (hoy: pieza por pieza).
|
||||
- Etiqueta propia por pieza única (código de barras ya implementado) para control físico en el patio.
|
||||
|
||||
**M3 — Búsqueda por donador (1 semana)**
|
||||
- Filtro/búsqueda por vehículo **donador** (marca/modelo/año/color/km) sin requerir `part_number`; indexar esos campos en Meilisearch.
|
||||
- Buscador "¿qué tengo de este auto?" + "¿qué tengo compatible con este auto?" (compat comprador, ya existe) como dos pestañas distintas.
|
||||
|
||||
**M4 — MeLi para usados (1 semana)**
|
||||
- `condition` dinámico en el payload (usado/remanufacturado) + grado en el título/atributos.
|
||||
- Mapeo de categorías ML para usados (ML tiene categorías de autopartes usadas con reglas propias).
|
||||
- Fotos múltiples al anuncio (ML acepta hasta 10; hoy se manda 1).
|
||||
|
||||
**M5 — Consignación y garantía (1-2 semanas)**
|
||||
- Liquidación a consignantes (reporte por periodo, comisión pactada, estado de cuenta del consignante).
|
||||
- Garantía de usados por días con política por defecto y comprobante impreso.
|
||||
- Reportes del segmento: rotación por donador, piezas sin foto, días en patio, margen por pieza.
|
||||
|
||||
**M6 — Plan y onboarding comercial (3-4 días)**
|
||||
- Plan **"Yonke"** (precio menor): sin catálogo TecDoc, límites amplios, módulos ajustados, onboarding sin importación de catálogo nuevo (alta por escaneo/foto).
|
||||
- Landing y material para el segmento (hoy `grep yonke|usadas` en docs/pitch → 0 resultados).
|
||||
- **Marketplace B2B entre yonkes** (`is_seller`, hoy nunca activado en ningún tenant) como diferenciador: intercambio de piezas usadas entre deshuesaderos de la red.
|
||||
|
||||
---
|
||||
|
||||
## 3. Núcleo comercial y fiscal
|
||||
|
||||
**CFDI (crítico):**
|
||||
- Corregir `codigo_postal` → `cp` (`invoicing_bp.py:65`) — desbloquea ingreso, egreso, PDF y factura global.
|
||||
- **Complementos de pago (REP)** para PPD: `build_pago_payload` existe y nunca se invoca; hoy toda venta a crédito incumple SAT.
|
||||
- Subir CSD por sucursal (hoy `has_csd:false` → ni timbrando funcionaría).
|
||||
- Devoluciones: encolar CFDI **egreso** automático + reembolso trazado en caja.
|
||||
- Moneda/tipo de cambio en facturación (`invoicing_bp.py:83` no lee `currency`).
|
||||
|
||||
**Contabilidad:**
|
||||
- Pólizas para abonos de clientes, cortes de caja, devoluciones y apartados (4 funciones de `accounting_engine` existen y **nunca se llaman**).
|
||||
- `record_return_entry` (no existe).
|
||||
- Dejar de perder asientos en silencio: los hooks `try/except: pass` deben loggear (ya se perdió 1 de 18 ventas sin aviso).
|
||||
- **Exportables para el contador**: pólizas, balanza, estado de resultados, DIOT (hoy solo antigüedad de saldos).
|
||||
- CxP real con tabla de pagos a proveedores (`paid = 0 # TODO`).
|
||||
- Caja: pagos mixtos no suman al efectivo esperado; apartados **duplican** el efectivo en el corte.
|
||||
|
||||
---
|
||||
|
||||
## 4. Catálogo, búsqueda y datos
|
||||
|
||||
1. **Arreglar el catálogo público** (visitante nuevo ve 500): `/api/catalog/search`, `/api/search`, `/api/categories`, modo OEM y detalle de parte. Modo `local` por defecto + degradación elegante (el POS ya tiene el guard `_oem_blocked`, el dashboard no).
|
||||
2. **Conectar Meilisearch de verdad**: los índices `nexus_inventory_*` ya están poblados y actualizados a diario, **ningún código los consulta**; la búsqueda local hace seq-scan de 157k filas (175 ms).
|
||||
3. **Calidad de fitment**: el fitment QWEN generó **20.5M filas ruidosas** (Corolla 2012 → 411,685 "compatibles" ≈ 40% del catálogo). Política de confianza por fuente + validación + priorizar la compat de proveedor (limpia, 29,760 MYEs). Decidir el destino del catálogo maestro (los 15 GB de JSON TecDoc siguen en disco).
|
||||
4. **Imágenes masivas** (bloquea catálogo público y MeLi) — mismo pipeline de B1.
|
||||
5. **Import/export round-trip** con compat, imágenes y min/max/reorder.
|
||||
6. **Import con vehículo roto**: `bulk-import` devuelve 500 si el CSV trae make/model/year (consulta tablas de vehículos en la conexión del tenant) — justo el flujo de "para qué carro sirve".
|
||||
7. **Multi-sucursal real**: todo el stock vive en la sucursal 1 y `inventory.branch_id` es NULL; transferencias/conteos/reorden implementados y **sin uso** (reorden además filtra por la columna equivocada y `reorder_point` es NULL al 100%).
|
||||
8. **Etiquetas**: código de barras generado pero **sin endpoint de impresión de etiquetas** (solo tickets).
|
||||
|
||||
---
|
||||
|
||||
## 5. SaaS, onboarding y escala
|
||||
|
||||
| Tema | Estado | Acción |
|
||||
|---|---|---|
|
||||
| Alta de tenants | ❌ Rota (v1.2/v1.6 de master en el runner de tenants) | Marcar como master-only/mover al runner master |
|
||||
| Template de tenant | Congelado en 32 tablas (le faltan ~56) | Refrescar template en cada alta o eliminarlo del flujo |
|
||||
| Planes | `demo`/`standard` no existen en `PLANS` → fallback a límites de `free` | Agregar planes reales o mapear; aplicar la expiración de demos |
|
||||
| Fuga de ingresos | Upgrade a `enterprise` **gratis** desde el POS + demos vencidas sin efecto + `subscriptions` desincronizada | Quitar el autoservicio, sincronizar `tenants.plan`↔`subscriptions.plan`, job diario de expiración |
|
||||
| Seguridad multi-tenant | `/api/admin/tenants*` del dashboard **sin auth** | Proteger + quitar `?tenant=` de la superficie pública |
|
||||
| Módulos | Dos nomenclaturas (`module_*` vs `*_enabled`) con defaults contradictorios | Unificar y sembrar en el template |
|
||||
| Drift de esquema | `v3.5_whatsapp_state_machine` marcada `SKIP` → 6 tenants sin `wa_customer_links`/`wa_learning_sessions` → **el chatbot WhatsApp falla en esos tenants** | Aplicar la migración real |
|
||||
| Bases huérfanas | 6 DBs `tenant_*` sin fila en `tenants` (~84 MB) | Limpiar |
|
||||
| Reset de demo | No limpia clientes ni conversaciones (filtra PII entre prospectos) | Completar el truncado |
|
||||
| Escala a 100 tenants | Pool 2-20 conexiones × 8 workers × 100 tenants > límite de Postgres; todo con superusuario | PgBouncer + roles por tenant + métricas por tenant |
|
||||
| Secretos | `POS_JWT_SECRET`/API keys en unit files legibles por grupo | EnvironmentFile root-only + rotación |
|
||||
|
||||
---
|
||||
|
||||
## 6. Roadmap propuesto (6 sprints)
|
||||
|
||||
| Sprint | Foco | Entregable verificable |
|
||||
|---|---|---|
|
||||
| **S1 — Estabilizar (1 sem)** | Backups (fix `((deleted++))` + verificar drill), alta de tenants, auth de `/api/admin/tenants*`, plan `demo`, expiración de demos | Respaldo fresco de 9 bases verificado con restore; un tenant nuevo creado de punta a punta |
|
||||
| **S2 — MeLi cierra el ciclo (1 sem)** | Fase A completa (10 tareas) | Publicar 1 SKU desde el POS → vender en ML → orden convertida a venta → CFDI encolado |
|
||||
| **S3 — Volumen MeLi (2 sem)** | Imágenes masivas + lotes + rate-limit + export/import con compat | 200+ SKUs publicados sin intervención manual, con progreso y reporte de fallos |
|
||||
| **S4 — Yonke MVP (2-3 sem)** | M1+M2+M3 (modelo, alta rápida por donador, búsqueda por donador) | Un deshuesadero de prueba carga 50 piezas usadas con foto y VIN, y las encuentra por vehículo |
|
||||
| **S5 — Fiscal y contabilidad (2 sem)** | CFDI (cp, REP, CSD, egresos), pólizas faltantes, caja (mixtos/apartados), exportables contador | Factura timbrada real; estado de resultados cuadra; DIOT exportable |
|
||||
| **S6 — Vender más + abrir mercado (2-3 sem)** | MeLi Fase C (variaciones, catálogo, reputación, rentabilidad, preguntas IA) + Yonke M4-M6 (ML usados, consignación, plan Yonke) | Panel de rentabilidad por publicación; primera pieza usada publicada en ML como `used` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Métricas de éxito
|
||||
|
||||
- **Confiabilidad:** 30 días consecutivos con respaldo verificado; 0 tenants con esquema incompleto; 0 endpoints 500 en el barrido automatizado.
|
||||
- **MercadoLibre:** conversión de órdenes → venta en el POS (hoy 0%, meta 100%); publicaciones sincronizadas sin error (hoy 0/52, meta 100%); ≥200 SKUs publicados en volumen; % de órdenes facturadas automáticamente.
|
||||
- **Yonke:** tiempo de alta de una pieza usada < 30 s; 100% de piezas usadas con foto y VIN de donador; búsqueda por donador con resultados < 500 ms; 1 cliente del segmento en producción.
|
||||
- **Comercial:** tenants de pago con plan correcto aplicado (hoy 3 con plan desincronizado), fin de la fuga por autoservicio, ≥1 cliente nuevo onboardeado sin intervención manual.
|
||||
@@ -8,7 +8,6 @@ Panel de control central para gestionar instancias multi-tenant de Nexus POS.
|
||||
- **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd)
|
||||
- **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
|
||||
@@ -22,15 +21,12 @@ 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
|
||||
@@ -197,29 +193,6 @@ systemctl start nexus-manager
|
||||
- Ve a **Migraciones** para ver la versión de schema de cada tenant
|
||||
- **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants
|
||||
|
||||
### Cartera (métricas, salud y anomalías)
|
||||
- La sección **Cartera** muestra el score de salud/riesgo de churn por tenant y
|
||||
las anomalías detectadas entre tenants (z-score robusto con mediana + MAD).
|
||||
- Los datos vienen de la tabla maestra `tenant_metrics_daily`, que se llena con
|
||||
el rollup diario:
|
||||
|
||||
```bash
|
||||
python3 scripts/rollup_tenant_metrics.py # hoy, tenants activos
|
||||
python3 scripts/rollup_tenant_metrics.py --days 30 # backfill últimos 30 días
|
||||
python3 scripts/rollup_tenant_metrics.py --all # incluir inactivos
|
||||
```
|
||||
|
||||
El script crea la tabla de forma idempotente (no usa el runner de migraciones
|
||||
de tenants) y es seguro re-ejecutarlo para la misma fecha.
|
||||
|
||||
- Endpoints (requieren `Authorization: Bearer <token>`):
|
||||
- `GET /api/tenants/metrics?days=14` — rollup + `{health_score, band, signals}` por tenant
|
||||
- `GET /api/portfolio/anomalies?date=YYYY-MM-DD&z=3.0&min_tenants=5` — anomalías entre tenants
|
||||
|
||||
Con menos de `min_tenants` tenants en la cohorte el detector devuelve
|
||||
`cold_start: true` y no emite hallazgos. La "actividad" usa `audit_log` como
|
||||
proxy porque no se persisten logins individuales.
|
||||
|
||||
---
|
||||
|
||||
## Notas de seguridad
|
||||
|
||||
@@ -15,7 +15,6 @@ 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():
|
||||
@@ -32,7 +31,6 @@ 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("/")
|
||||
@@ -59,10 +57,6 @@ 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")
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Portfolio operations blueprint: business metrics, health and anomalies.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
``GET /api/tenants/metrics`` rollup window + health score per tenant
|
||||
``GET /api/portfolio/anomalies`` cross-tenant anomalies (median + MAD z-score)
|
||||
"""
|
||||
from datetime import date as date_cls
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from services import portfolio_service
|
||||
|
||||
from blueprints.auth_bp import require_manager_auth
|
||||
|
||||
portfolio_bp = Blueprint("portfolio", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@portfolio_bp.route("/tenants/metrics", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def tenant_metrics():
|
||||
days = request.args.get("days", 14, type=int)
|
||||
include_inactive = request.args.get("all", "false").lower() == "true"
|
||||
return jsonify(portfolio_service.get_tenant_metrics(
|
||||
days=days, include_inactive=include_inactive))
|
||||
|
||||
|
||||
@portfolio_bp.route("/portfolio/anomalies", methods=["GET"])
|
||||
@require_manager_auth
|
||||
def portfolio_anomalies():
|
||||
target = request.args.get("date")
|
||||
if target:
|
||||
try:
|
||||
date_cls.fromisoformat(target)
|
||||
except ValueError:
|
||||
return jsonify({"error": "date must be YYYY-MM-DD"}), 400
|
||||
|
||||
kwargs = {
|
||||
"date": target,
|
||||
"include_inactive": request.args.get("all", "false").lower() == "true",
|
||||
}
|
||||
z = request.args.get("z", type=float)
|
||||
if z is not None:
|
||||
kwargs["z_threshold"] = z
|
||||
min_tenants = request.args.get("min_tenants", type=int)
|
||||
if min_tenants is not None:
|
||||
kwargs["min_tenants"] = min_tenants
|
||||
|
||||
return jsonify(portfolio_service.get_portfolio_anomalies(**kwargs))
|
||||
@@ -1,352 +0,0 @@
|
||||
"""Portfolio-level business metrics: cross-tenant anomaly detection and a
|
||||
tenant health / churn-risk heuristic.
|
||||
|
||||
This is deliberately *not* conversational AI. At a portfolio of ~8 tenants the
|
||||
honest tool is a robust statistic and a transparent heuristic, so every number
|
||||
here is explainable:
|
||||
|
||||
* ``detect_portfolio_anomalies`` compares each tenant against the cohort of the
|
||||
other tenants on the same day using a median + MAD robust z-score. MAD is
|
||||
used instead of the mean/standard deviation because with a handful of points
|
||||
a single runaway tenant would otherwise inflate the scale and hide its own
|
||||
anomaly.
|
||||
* ``compute_health`` is a weighted penalty/bonus heuristic over usage signals
|
||||
(sales recency, activity, module usage, error counts and trend).
|
||||
|
||||
Both functions are pure (no DB access) so they can be unit-tested and reused
|
||||
from the manager API and from scripts. Cold start is explicit: with fewer than
|
||||
``min_tenants`` observations in the cohort the detector reports
|
||||
``cold_start=True`` and raises no flags rather than inventing signal.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
# Below this many tenants in the cohort we do not trust cross-tenant statistics.
|
||||
MIN_COHORT = 5
|
||||
|
||||
# Robust z threshold. 3.0 robust sigmas with 8 tenants is already conservative
|
||||
# (MAD is small, so ~3 is a genuinely unusual point, not a normal spread).
|
||||
DEFAULT_Z = 3.0
|
||||
|
||||
# Metrics compared across tenants. Everything is derived from the daily rollup.
|
||||
COHORT_METRICS = [
|
||||
("ventas_total", "Ventas (MXN)"),
|
||||
("ventas_count", "Tickets"),
|
||||
("ticket_promedio", "Ticket promedio"),
|
||||
("clientes_nuevos", "Clientes nuevos"),
|
||||
("actividad_count", "Eventos de actividad"),
|
||||
("errores_5xx", "Errores 5xx"),
|
||||
("items_activos", "Items activos"),
|
||||
("skus", "SKUs"),
|
||||
("clientes", "Clientes"),
|
||||
("fitment_pct", "Cobertura fitment (%)"),
|
||||
]
|
||||
|
||||
# Health bands, high to low.
|
||||
BANDS = [
|
||||
(80, "healthy", "Saludable"),
|
||||
(60, "stable", "Estable"),
|
||||
(40, "at_risk", "En riesgo"),
|
||||
(0, "critical", "Crítico"),
|
||||
]
|
||||
|
||||
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||
|
||||
|
||||
def _median(values):
|
||||
"""Median of a list of floats (None for empty)."""
|
||||
vals = sorted(float(v) for v in values if v is not None)
|
||||
n = len(vals)
|
||||
if n == 0:
|
||||
return None
|
||||
mid = n // 2
|
||||
if n % 2:
|
||||
return vals[mid]
|
||||
return (vals[mid - 1] + vals[mid]) / 2.0
|
||||
|
||||
|
||||
def robust_stats(values):
|
||||
"""Return ``(median, scale)`` for a sample.
|
||||
|
||||
``scale`` is the MAD when non-zero. When most values are identical
|
||||
(MAD == 0, common for sparse metrics such as daily sales) it falls back to
|
||||
a scaled mean absolute deviation so a real outlier is still measurable;
|
||||
when the sample is completely constant it returns 0.0 and the caller skips
|
||||
the metric instead of dividing by zero.
|
||||
"""
|
||||
vals = [float(v) for v in values if v is not None]
|
||||
if not vals:
|
||||
return None, None
|
||||
med = _median(vals)
|
||||
mad = _median([abs(v - med) for v in vals])
|
||||
if mad > 1e-9:
|
||||
return med, mad
|
||||
meanad = sum(abs(v - med) for v in vals) / len(vals)
|
||||
if meanad > 1e-9:
|
||||
return med, meanad * 1.2533
|
||||
return med, 0.0
|
||||
|
||||
|
||||
def robust_z(value, med, scale):
|
||||
"""Robust z-score ``0.6745 * (x - med) / scale`` or None if undefinable."""
|
||||
if value is None or med is None or scale is None or scale <= 1e-9:
|
||||
return None
|
||||
return 0.6745 * (float(value) - med) / scale
|
||||
|
||||
|
||||
def detect_portfolio_anomalies(rows, date=None, metrics=None,
|
||||
min_tenants=MIN_COHORT, z_threshold=DEFAULT_Z):
|
||||
"""Flag tenants whose metric is unusually high/low versus the cohort.
|
||||
|
||||
``rows`` is a list of dicts, one per tenant for a single day, each carrying
|
||||
the metric keys from :data:`COHORT_METRICS` plus ``tenant_id`` and
|
||||
``tenant_name``. Returns a report dict with ``anomalies`` sorted by
|
||||
strength and a ``cold_start`` flag.
|
||||
"""
|
||||
metrics = metrics or [m for m, _ in COHORT_METRICS]
|
||||
labels = dict(COHORT_METRICS)
|
||||
cohort_size = len(rows)
|
||||
|
||||
anomalies = []
|
||||
evaluated = []
|
||||
skipped = []
|
||||
|
||||
if cohort_size < min_tenants:
|
||||
for key in metrics:
|
||||
skipped.append({"metric": key, "reason": "cohorta insuficiente",
|
||||
"cohort_size": cohort_size})
|
||||
|
||||
for key in metrics:
|
||||
values = [r.get(key) for r in rows if r.get(key) is not None]
|
||||
if cohort_size < min_tenants:
|
||||
continue
|
||||
if len(values) < min_tenants:
|
||||
skipped.append({"metric": key, "reason": "cohorta insuficiente",
|
||||
"cohort_size": len(values)})
|
||||
continue
|
||||
med, scale = robust_stats(values)
|
||||
if not scale:
|
||||
skipped.append({"metric": key, "reason": "sin varianza en la cohorte",
|
||||
"cohort_size": len(values)})
|
||||
continue
|
||||
evaluated.append({
|
||||
"metric": key,
|
||||
"label": labels.get(key, key),
|
||||
"cohort_median": round(med, 2),
|
||||
"cohort_mad": round(scale, 2),
|
||||
"cohort_size": len(values),
|
||||
})
|
||||
for row in rows:
|
||||
value = row.get(key)
|
||||
z = robust_z(value, med, scale)
|
||||
if z is None or abs(z) <= z_threshold:
|
||||
continue
|
||||
abs_z = abs(z)
|
||||
severity = "high" if abs_z >= z_threshold * 1.5 else "medium"
|
||||
anomalies.append({
|
||||
"tenant_id": row.get("tenant_id"),
|
||||
"tenant_name": row.get("tenant_name"),
|
||||
"metric": key,
|
||||
"label": labels.get(key, key),
|
||||
"value": round(float(value), 2),
|
||||
"cohort_median": round(med, 2),
|
||||
"cohort_mad": round(scale, 2),
|
||||
"z": round(z, 2),
|
||||
"direction": "peak" if z > 0 else "drop",
|
||||
"severity": severity,
|
||||
})
|
||||
|
||||
anomalies.sort(key=lambda a: abs(a["z"]), reverse=True)
|
||||
|
||||
by_tenant = {}
|
||||
for a in anomalies:
|
||||
entry = by_tenant.setdefault(a["tenant_id"], {
|
||||
"tenant_id": a["tenant_id"],
|
||||
"tenant_name": a["tenant_name"],
|
||||
"findings": [],
|
||||
})
|
||||
entry["findings"].append({
|
||||
"metric": a["metric"], "label": a["label"], "value": a["value"],
|
||||
"z": a["z"], "direction": a["direction"], "severity": a["severity"],
|
||||
})
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"date": str(date) if date else None,
|
||||
"cold_start": cohort_size < min_tenants,
|
||||
"cohort_size": cohort_size,
|
||||
"thresholds": {
|
||||
"method": "robust z-score (median + MAD)",
|
||||
"z_threshold": z_threshold,
|
||||
"min_tenants": min_tenants,
|
||||
},
|
||||
"metrics_evaluated": evaluated,
|
||||
"metrics_skipped": skipped,
|
||||
"anomalies": anomalies,
|
||||
"by_tenant": by_tenant,
|
||||
"summary": {
|
||||
"flagged_tenants": len(by_tenant),
|
||||
"findings": len(anomalies),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _band_for(score):
|
||||
for threshold, key, label in BANDS:
|
||||
if score >= threshold:
|
||||
return key, label
|
||||
return "critical", "Crítico"
|
||||
|
||||
|
||||
def _days_since(ts, now):
|
||||
if ts is None:
|
||||
return None
|
||||
if isinstance(ts, str):
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=UTC)
|
||||
return max(0, (now - ts).days)
|
||||
|
||||
|
||||
def compute_health(tenant, history, now=None):
|
||||
"""Heuristic health / churn-risk score for one tenant.
|
||||
|
||||
``tenant`` is the master row (``id``, ``name``, ``plan``, ``is_active``,
|
||||
optional ``expires_at``). ``history`` is the tenant's rollup rows, oldest
|
||||
first. Returns ``{"health_score", "band", "band_label", "signals",
|
||||
"cold_start", "components"}``.
|
||||
|
||||
The score starts at 100 and applies transparent penalties. Activity is
|
||||
measured from ``audit_log`` events because the platform does not persist
|
||||
per-login rows; the API response notes this proxy explicitly.
|
||||
"""
|
||||
now = now or datetime.now(UTC)
|
||||
signals = []
|
||||
components = {}
|
||||
score = 100.0
|
||||
|
||||
def add_penalty(points, code, severity, detail):
|
||||
nonlocal score
|
||||
score -= points
|
||||
signals.append({"code": code, "severity": severity, "detail": detail})
|
||||
|
||||
if not history:
|
||||
return {
|
||||
"health_score": None,
|
||||
"band": "unknown",
|
||||
"band_label": "Sin datos",
|
||||
"cold_start": True,
|
||||
"signals": [{"code": "no_metrics", "severity": "medium",
|
||||
"detail": "Sin rollup de métricas; ejecuta "
|
||||
"scripts/rollup_tenant_metrics.py"}],
|
||||
"components": {},
|
||||
}
|
||||
|
||||
latest = history[-1]
|
||||
recent7 = history[-7:]
|
||||
prev7 = history[-14:-7]
|
||||
|
||||
# ── 1. Tenant lifecycle ────────────────────────────────────────────────
|
||||
if tenant and not tenant.get("is_active", True):
|
||||
add_penalty(25, "tenant_inactive", "high", "Tenant desactivado")
|
||||
expires_at = (tenant or {}).get("expires_at")
|
||||
if expires_at is not None:
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=UTC)
|
||||
if expires_at < now:
|
||||
add_penalty(15, "subscription_expired", "high",
|
||||
"Suscripción vencida")
|
||||
|
||||
# ── 2. Sales recency ───────────────────────────────────────────────────
|
||||
days_sale = _days_since(latest.get("ultima_venta_at"), now)
|
||||
components["dias_desde_ultima_venta"] = days_sale
|
||||
if days_sale is None:
|
||||
add_penalty(20, "no_sales_ever", "high",
|
||||
"Sin ventas registradas en el histórico")
|
||||
elif days_sale > 30:
|
||||
add_penalty(30, "stale_sales", "high",
|
||||
f"Sin ventas en {days_sale} días")
|
||||
elif days_sale > 14:
|
||||
add_penalty(15, "slow_sales", "medium",
|
||||
f"Última venta hace {days_sale} días")
|
||||
elif days_sale > 7:
|
||||
add_penalty(6, "quiet_sales", "low",
|
||||
f"Última venta hace {days_sale} días")
|
||||
|
||||
# ── 3. Activity recency (proxy for logins) ─────────────────────────────
|
||||
days_act = _days_since(latest.get("ultima_actividad_at"), now)
|
||||
components["dias_desde_ultima_actividad"] = days_act
|
||||
act7 = sum((r.get("actividad_count") or 0) for r in recent7)
|
||||
components["actividad_7d"] = act7
|
||||
if days_act is None:
|
||||
add_penalty(15, "no_activity", "medium",
|
||||
"Sin actividad operativa registrada")
|
||||
elif days_act > 14:
|
||||
add_penalty(12, "stale_activity", "medium",
|
||||
f"Sin actividad en {days_act} días")
|
||||
elif days_act > 7:
|
||||
add_penalty(6, "slow_activity", "low",
|
||||
f"Última actividad hace {days_act} días")
|
||||
elif act7 == 0:
|
||||
add_penalty(8, "idle_week", "low", "Sin eventos de actividad en 7 días")
|
||||
|
||||
# ── 4. Trend (last 7 days vs previous 7) ───────────────────────────────
|
||||
cur7 = sum(float(r.get("ventas_total") or 0) for r in recent7)
|
||||
prv7 = sum(float(r.get("ventas_total") or 0) for r in prev7)
|
||||
components["ventas_7d"] = round(cur7, 2)
|
||||
components["ventas_7d_previas"] = round(prv7, 2)
|
||||
if prv7 > 0:
|
||||
change = (cur7 - prv7) / prv7
|
||||
components["tendencia_ventas_pct"] = round(change * 100, 1)
|
||||
if change <= -0.5:
|
||||
add_penalty(18, "sales_drop", "high",
|
||||
f"Ventas 7d cayeron {abs(change) * 100:.0f}% vs semana previa")
|
||||
elif change <= -0.2:
|
||||
add_penalty(8, "sales_slowdown", "medium",
|
||||
f"Ventas 7d bajaron {abs(change) * 100:.0f}% vs semana previa")
|
||||
elif change >= 0.2:
|
||||
score += 5
|
||||
signals.append({"code": "sales_growth", "severity": "low",
|
||||
"detail": f"Ventas 7d crecieron {change * 100:.0f}%"})
|
||||
elif cur7 > 0:
|
||||
signals.append({"code": "sales_new", "severity": "low",
|
||||
"detail": "Ventas en los últimos 7 días sin semana previa comparable"})
|
||||
|
||||
# ── 5. Errors ──────────────────────────────────────────────────────────
|
||||
err7 = sum((r.get("errores_5xx") or 0) for r in recent7)
|
||||
components["errores_5xx_7d"] = err7
|
||||
if err7 > 0:
|
||||
add_penalty(min(20, 4 + err7), "api_errors", "medium",
|
||||
f"{err7} errores 5xx de la API en 7 días")
|
||||
|
||||
# ── 6. Module usage / engagement ───────────────────────────────────────
|
||||
modules = latest.get("uso_modulos") or {}
|
||||
enabled = [k for k, v in modules.items()
|
||||
if k.startswith("module_") and v]
|
||||
components["modulos_activos"] = len(enabled)
|
||||
if not enabled:
|
||||
add_penalty(6, "no_modules", "low",
|
||||
"Ningún módulo opcional activado")
|
||||
|
||||
# ── 7. Catalog ─────────────────────────────────────────────────────────
|
||||
items = latest.get("items_activos") or 0
|
||||
components["items_activos"] = items
|
||||
if items == 0:
|
||||
add_penalty(15, "empty_catalog", "high", "Inventario activo vacío")
|
||||
|
||||
score = max(0.0, min(100.0, score))
|
||||
band, band_label = _band_for(score)
|
||||
signals.sort(key=lambda s: SEVERITY_ORDER.get(s["severity"], 9))
|
||||
|
||||
return {
|
||||
"health_score": round(score, 1),
|
||||
"band": band,
|
||||
"band_label": band_label,
|
||||
"cold_start": False,
|
||||
"signals": signals,
|
||||
"components": components,
|
||||
"activity_proxy": "audit_log (no se persisten logins individuales)",
|
||||
}
|
||||
@@ -1,522 +0,0 @@
|
||||
"""Portfolio operations service: per-tenant business metrics rollup, health
|
||||
scores and cross-tenant anomaly detection for the manager.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* The daily rollup lives in the **master** database table
|
||||
``tenant_metrics_daily`` (one row per tenant per day). It is created
|
||||
idempotently here and by ``scripts/rollup_tenant_metrics.py`` — the tenant
|
||||
migration runner is intentionally not involved.
|
||||
* Point-in-time counts (inventory, customers, employees, db size, fitment)
|
||||
are snapshots taken at collection time; daily activity metrics (sales,
|
||||
new customers, audit events, API calls) are computed for the requested day.
|
||||
* Data sources are guarded: optional tables (``audit_log``,
|
||||
``api_request_logs``, ``inventory_vehicle_compat``) may be absent in an older
|
||||
tenant and simply yield 0 / NULL instead of failing the rollup.
|
||||
* DSNs are resolved from environment exactly like the manager config
|
||||
(``MASTER_DB_URL`` / ``TENANT_DB_URL_TEMPLATE``) with a local fallback, so the
|
||||
script can run standalone without the manager's required secrets.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import date as date_cls
|
||||
from datetime import datetime, time, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from services.business_metrics import (
|
||||
DEFAULT_Z,
|
||||
MIN_COHORT,
|
||||
compute_health,
|
||||
detect_portfolio_anomalies,
|
||||
)
|
||||
|
||||
BUSINESS_TZ = os.environ.get("PORTFOLIO_TZ", "America/Mexico_City")
|
||||
|
||||
_PGUSER = os.environ.get("PGUSER", "postgres")
|
||||
MASTER_DB_URL = (
|
||||
os.environ.get("MASTER_DB_URL")
|
||||
or os.environ.get("DATABASE_URL")
|
||||
or f"postgresql://{_PGUSER}@localhost/nexus_autoparts"
|
||||
)
|
||||
TENANT_DB_URL_TEMPLATE = (
|
||||
os.environ.get("TENANT_DB_URL_TEMPLATE")
|
||||
or f"postgresql://{_PGUSER}@localhost/{{db_name}}"
|
||||
)
|
||||
|
||||
DDL = """
|
||||
CREATE TABLE IF NOT EXISTS tenant_metrics_daily (
|
||||
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
ventas_count INTEGER NOT NULL DEFAULT 0,
|
||||
ventas_total NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
ticket_promedio NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||
ventas_canceladas INTEGER NOT NULL DEFAULT 0,
|
||||
clientes_nuevos INTEGER NOT NULL DEFAULT 0,
|
||||
items_activos INTEGER NOT NULL DEFAULT 0,
|
||||
skus INTEGER NOT NULL DEFAULT 0,
|
||||
clientes INTEGER NOT NULL DEFAULT 0,
|
||||
empleados_activos INTEGER NOT NULL DEFAULT 0,
|
||||
fitment_pct NUMERIC(5,2),
|
||||
uso_modulos JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
ai_calls INTEGER NOT NULL DEFAULT 0,
|
||||
api_calls INTEGER NOT NULL DEFAULT 0,
|
||||
errores_5xx INTEGER NOT NULL DEFAULT 0,
|
||||
actividad_count INTEGER NOT NULL DEFAULT 0,
|
||||
ultima_venta_at TIMESTAMPTZ,
|
||||
ultima_actividad_at TIMESTAMPTZ,
|
||||
db_size_mb NUMERIC(12,2),
|
||||
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (tenant_id, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_metrics_daily_date
|
||||
ON tenant_metrics_daily (date);
|
||||
"""
|
||||
|
||||
UPSERT = """
|
||||
INSERT INTO tenant_metrics_daily (
|
||||
tenant_id, date, ventas_count, ventas_total, ticket_promedio,
|
||||
ventas_canceladas, clientes_nuevos, items_activos, skus, clientes,
|
||||
empleados_activos, fitment_pct, uso_modulos, ai_calls, api_calls,
|
||||
errores_5xx, actividad_count, ultima_venta_at, ultima_actividad_at,
|
||||
db_size_mb, collected_at
|
||||
) VALUES (
|
||||
%(tenant_id)s, %(date)s, %(ventas_count)s, %(ventas_total)s,
|
||||
%(ticket_promedio)s, %(ventas_canceladas)s, %(clientes_nuevos)s,
|
||||
%(items_activos)s, %(skus)s, %(clientes)s, %(empleados_activos)s,
|
||||
%(fitment_pct)s, %(uso_modulos)s, %(ai_calls)s, %(api_calls)s,
|
||||
%(errores_5xx)s, %(actividad_count)s, %(ultima_venta_at)s,
|
||||
%(ultima_actividad_at)s, %(db_size_mb)s, NOW()
|
||||
)
|
||||
ON CONFLICT (tenant_id, date) DO UPDATE SET
|
||||
ventas_count = EXCLUDED.ventas_count,
|
||||
ventas_total = EXCLUDED.ventas_total,
|
||||
ticket_promedio = EXCLUDED.ticket_promedio,
|
||||
ventas_canceladas = EXCLUDED.ventas_canceladas,
|
||||
clientes_nuevos = EXCLUDED.clientes_nuevos,
|
||||
items_activos = EXCLUDED.items_activos,
|
||||
skus = EXCLUDED.skus,
|
||||
clientes = EXCLUDED.clientes,
|
||||
empleados_activos = EXCLUDED.empleados_activos,
|
||||
fitment_pct = EXCLUDED.fitment_pct,
|
||||
uso_modulos = EXCLUDED.uso_modulos,
|
||||
ai_calls = EXCLUDED.ai_calls,
|
||||
api_calls = EXCLUDED.api_calls,
|
||||
errores_5xx = EXCLUDED.errores_5xx,
|
||||
actividad_count = EXCLUDED.actividad_count,
|
||||
ultima_venta_at = EXCLUDED.ultima_venta_at,
|
||||
ultima_actividad_at = EXCLUDED.ultima_actividad_at,
|
||||
db_size_mb = EXCLUDED.db_size_mb,
|
||||
collected_at = NOW()
|
||||
"""
|
||||
|
||||
|
||||
def get_master_conn():
|
||||
return psycopg2.connect(MASTER_DB_URL, connect_timeout=10)
|
||||
|
||||
|
||||
def tenant_dsn(db_name):
|
||||
return TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
|
||||
|
||||
def ensure_metrics_table(conn=None):
|
||||
"""Create ``tenant_metrics_daily`` if missing. Idempotent."""
|
||||
own = conn is None
|
||||
conn = conn or get_master_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(DDL)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
finally:
|
||||
if own:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_tenants(conn=None, include_inactive=True):
|
||||
own = conn is None
|
||||
conn = conn or get_master_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
where = "" if include_inactive else "WHERE t.is_active = true"
|
||||
cur.execute(f"""
|
||||
SELECT t.id, t.name, t.db_name, t.plan, t.is_active, t.created_at,
|
||||
s.expires_at
|
||||
FROM tenants t
|
||||
LEFT JOIN subscriptions s ON s.tenant_id = t.id
|
||||
{where}
|
||||
ORDER BY t.id
|
||||
""")
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows = [dict(zip(cols, r)) for r in cur.fetchall()]
|
||||
cur.close()
|
||||
return rows
|
||||
finally:
|
||||
if own:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Collection ────────────────────────────────────────────────────────────
|
||||
|
||||
def _q(cur, sql, params=None, default=None):
|
||||
"""Run a scalar query, returning ``default`` if the object is missing."""
|
||||
try:
|
||||
cur.execute(sql, params or ())
|
||||
row = cur.fetchone()
|
||||
return row[0] if row and row[0] is not None else default
|
||||
except Exception:
|
||||
cur.connection.rollback()
|
||||
return default
|
||||
|
||||
|
||||
def _load_modules(cur):
|
||||
cfg = {}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM tenant_config")
|
||||
cfg = dict(cur.fetchall())
|
||||
except Exception:
|
||||
cur.connection.rollback()
|
||||
|
||||
def flag(key):
|
||||
return (cfg.get(key) or "").lower() == "true"
|
||||
|
||||
return {
|
||||
"module_whatsapp": flag("module_whatsapp"),
|
||||
"module_marketplace": flag("module_marketplace"),
|
||||
"module_meli": flag("module_meli"),
|
||||
"module_catalog": flag("module_catalog"),
|
||||
"whatsapp_enabled": flag("whatsapp_enabled"),
|
||||
"vehicle_compat_source": cfg.get("vehicle_compat_source"),
|
||||
"facturapi": bool(cfg.get("cfdi_facturapi_key")),
|
||||
}
|
||||
|
||||
|
||||
def _collect_tenant(tenant, start_dt, end_dt, dates):
|
||||
"""Collect one tenant's rollup rows for ``dates`` inside [start, end)."""
|
||||
conn = psycopg2.connect(tenant_dsn(tenant["db_name"]), connect_timeout=10)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
items_activos = _q(cur, "SELECT COUNT(*) FROM inventory WHERE is_active", default=0)
|
||||
skus = _q(cur, "SELECT COUNT(DISTINCT part_number) FROM inventory "
|
||||
"WHERE is_active AND part_number IS NOT NULL", default=0)
|
||||
clientes = _q(cur, "SELECT COUNT(*) FROM customers WHERE is_active", default=0)
|
||||
empleados = _q(cur, "SELECT COUNT(*) FROM employees WHERE is_active", default=0)
|
||||
db_size = _q(cur, "SELECT pg_database_size(current_database()) / 1048576.0")
|
||||
ultima_venta = _q(cur, "SELECT MAX(created_at) FROM sales "
|
||||
"WHERE status = 'completed'")
|
||||
ultima_actividad = _q(cur, "SELECT MAX(created_at) FROM audit_log")
|
||||
modules = _load_modules(cur)
|
||||
|
||||
icv = _q(cur, "SELECT to_regclass('public.inventory_vehicle_compat') IS NOT NULL",
|
||||
default=False)
|
||||
if icv:
|
||||
fit = _q(cur, "SELECT COUNT(*) FROM inventory i WHERE i.is_active AND ("
|
||||
"i.vehicle_compatibility IS NOT NULL OR EXISTS ("
|
||||
"SELECT 1 FROM inventory_vehicle_compat c "
|
||||
"WHERE c.inventory_id = i.id))", default=0)
|
||||
else:
|
||||
fit = _q(cur, "SELECT COUNT(*) FROM inventory WHERE is_active "
|
||||
"AND vehicle_compatibility IS NOT NULL", default=0)
|
||||
fitment_pct = round(100.0 * fit / items_activos, 2) if items_activos else None
|
||||
|
||||
# Daily sales (completed / cancelled).
|
||||
sales = {}
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT (created_at AT TIME ZONE %s)::date,
|
||||
COUNT(*) FILTER (WHERE status = 'completed'),
|
||||
COALESCE(SUM(total) FILTER (WHERE status = 'completed'), 0),
|
||||
COUNT(*) FILTER (WHERE status = 'cancelled')
|
||||
FROM sales
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY 1
|
||||
""", (BUSINESS_TZ, start_dt, end_dt))
|
||||
for d, vc, vt, vx in cur.fetchall():
|
||||
sales[d] = (vc or 0, float(vt or 0), vx or 0)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
nuevos = {}
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT (created_at AT TIME ZONE %s)::date, COUNT(*)
|
||||
FROM customers
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY 1
|
||||
""", (BUSINESS_TZ, start_dt, end_dt))
|
||||
nuevos = dict(cur.fetchall())
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
actividad = {}
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT (created_at AT TIME ZONE %s)::date, COUNT(*)
|
||||
FROM audit_log
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY 1
|
||||
""", (BUSINESS_TZ, start_dt, end_dt))
|
||||
actividad = dict(cur.fetchall())
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
apis = {}
|
||||
has_api = _q(cur, "SELECT to_regclass('public.api_request_logs') IS NOT NULL",
|
||||
default=False)
|
||||
if has_api:
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT (created_at AT TIME ZONE %s)::date,
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status_code >= 500),
|
||||
COUNT(*) FILTER (WHERE path ~*
|
||||
'(/ai/|/chat|vision|semantic|classif|categoriz|/ml/)')
|
||||
FROM api_request_logs
|
||||
WHERE created_at >= %s AND created_at < %s
|
||||
GROUP BY 1
|
||||
""", (BUSINESS_TZ, start_dt, end_dt))
|
||||
for d, calls, err5, ai in cur.fetchall():
|
||||
apis[d] = (calls or 0, err5 or 0, ai or 0)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
rows = []
|
||||
for d in dates:
|
||||
vc, vt, vx = sales.get(d, (0, 0.0, 0))
|
||||
api_calls, err5, ai = apis.get(d, (0, 0, 0))
|
||||
rows.append({
|
||||
"tenant_id": tenant["id"],
|
||||
"date": d,
|
||||
"ventas_count": vc,
|
||||
"ventas_total": round(vt, 2),
|
||||
"ticket_promedio": round(vt / vc, 2) if vc else 0,
|
||||
"ventas_canceladas": vx,
|
||||
"clientes_nuevos": nuevos.get(d, 0),
|
||||
"items_activos": items_activos or 0,
|
||||
"skus": skus or 0,
|
||||
"clientes": clientes or 0,
|
||||
"empleados_activos": empleados or 0,
|
||||
"fitment_pct": fitment_pct,
|
||||
"uso_modulos": Json(modules),
|
||||
"ai_calls": ai,
|
||||
"api_calls": api_calls,
|
||||
"errores_5xx": err5,
|
||||
"actividad_count": actividad.get(d, 0),
|
||||
"ultima_venta_at": ultima_venta,
|
||||
"ultima_actividad_at": ultima_actividad,
|
||||
"db_size_mb": round(db_size, 2) if db_size is not None else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def run_rollup(date=None, days=1, tenant_ids=None, include_inactive=True):
|
||||
"""Collect and upsert rollup rows. Idempotent on (tenant_id, date).
|
||||
|
||||
``date`` is the last day of the range (defaults to today in BUSINESS_TZ);
|
||||
``days`` extends the range backwards (1 == a single day).
|
||||
"""
|
||||
tz = ZoneInfo(BUSINESS_TZ)
|
||||
end_date = date or datetime.now(tz).date()
|
||||
if isinstance(end_date, str):
|
||||
end_date = date_cls.fromisoformat(end_date)
|
||||
dates = [end_date - timedelta(days=i) for i in range(days - 1, -1, -1)]
|
||||
|
||||
start_dt = datetime.combine(dates[0], time.min, tzinfo=tz)
|
||||
end_dt = datetime.combine(dates[-1] + timedelta(days=1), time.min, tzinfo=tz)
|
||||
|
||||
conn = get_master_conn()
|
||||
try:
|
||||
ensure_metrics_table(conn)
|
||||
tenants = list_tenants(conn, include_inactive=include_inactive)
|
||||
if tenant_ids:
|
||||
tenants = [t for t in tenants
|
||||
if t["id"] in tenant_ids or t["db_name"] in tenant_ids]
|
||||
|
||||
written = 0
|
||||
errors = []
|
||||
per_tenant = []
|
||||
for t in tenants:
|
||||
try:
|
||||
rows = _collect_tenant(t, start_dt, end_dt, dates)
|
||||
except Exception as e: # noqa: BLE001 — report and continue
|
||||
errors.append({"tenant_id": t["id"], "db_name": t["db_name"],
|
||||
"error": str(e)})
|
||||
continue
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
for row in rows:
|
||||
cur.execute(UPSERT, row)
|
||||
conn.commit()
|
||||
written += len(rows)
|
||||
per_tenant.append({
|
||||
"tenant_id": t["id"], "tenant_name": t["name"],
|
||||
"db_name": t["db_name"], "rows": len(rows),
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
conn.rollback()
|
||||
errors.append({"tenant_id": t["id"], "db_name": t["db_name"],
|
||||
"error": str(e)})
|
||||
finally:
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"date_from": str(dates[0]),
|
||||
"date_to": str(dates[-1]),
|
||||
"days": days,
|
||||
"rows_written": written,
|
||||
"tenants": per_tenant,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ─── Readers ───────────────────────────────────────────────────────────────
|
||||
|
||||
METRIC_FIELDS = [
|
||||
"ventas_count", "ventas_total", "ticket_promedio", "ventas_canceladas",
|
||||
"clientes_nuevos", "items_activos", "skus", "clientes",
|
||||
"empleados_activos", "fitment_pct", "ai_calls", "api_calls",
|
||||
"errores_5xx", "actividad_count", "db_size_mb",
|
||||
]
|
||||
|
||||
|
||||
def _serialize_row(row):
|
||||
out = {}
|
||||
for k, v in row.items():
|
||||
if isinstance(v, (datetime, date_cls)):
|
||||
out[k] = v.isoformat()
|
||||
elif hasattr(v, "quantize"): # Decimal
|
||||
out[k] = float(v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _latest_date(conn):
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT MAX(date) FROM tenant_metrics_daily")
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def get_tenant_metrics(days=14, include_inactive=True):
|
||||
"""Rollup window + health score for every tenant (manager API payload)."""
|
||||
days = max(1, min(int(days), 90))
|
||||
conn = get_master_conn()
|
||||
try:
|
||||
ensure_metrics_table(conn)
|
||||
latest = _latest_date(conn)
|
||||
tenants = list_tenants(conn, include_inactive=include_inactive)
|
||||
if latest is None:
|
||||
return {
|
||||
"rollup_available": False,
|
||||
"date": None,
|
||||
"window_days": days,
|
||||
"notes": {
|
||||
"hint": "Sin datos; ejecuta scripts/rollup_tenant_metrics.py",
|
||||
"activity_proxy": "audit_log (no se persisten logins individuales)",
|
||||
},
|
||||
"tenants": [
|
||||
{"tenant_id": t["id"], "name": t["name"],
|
||||
"db_name": t["db_name"], "plan": t["plan"],
|
||||
"is_active": t["is_active"],
|
||||
"health": compute_health(t, []), "metrics": None}
|
||||
for t in tenants
|
||||
],
|
||||
}
|
||||
|
||||
start = latest - timedelta(days=days - 1)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT tenant_id, date, ventas_count, ventas_total, ticket_promedio,
|
||||
ventas_canceladas, clientes_nuevos, items_activos, skus,
|
||||
clientes, empleados_activos, fitment_pct, uso_modulos,
|
||||
ai_calls, api_calls, errores_5xx, actividad_count,
|
||||
ultima_venta_at, ultima_actividad_at, db_size_mb
|
||||
FROM tenant_metrics_daily
|
||||
WHERE date >= %s AND date <= %s
|
||||
ORDER BY tenant_id, date
|
||||
""", (start, latest))
|
||||
cols = [d[0] for d in cur.description]
|
||||
by_tenant = {}
|
||||
for r in cur.fetchall():
|
||||
row = dict(zip(cols, r)) # raw: tz-aware datetimes for health
|
||||
by_tenant.setdefault(row["tenant_id"], []).append(row)
|
||||
cur.close()
|
||||
|
||||
out = []
|
||||
for t in tenants:
|
||||
history = by_tenant.get(t["id"], [])
|
||||
health = compute_health(t, history)
|
||||
out.append({
|
||||
"tenant_id": t["id"],
|
||||
"name": t["name"],
|
||||
"db_name": t["db_name"],
|
||||
"plan": t["plan"],
|
||||
"is_active": t["is_active"],
|
||||
"health": health,
|
||||
"metrics": _serialize_row(history[-1]) if history else None,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"rollup_available": True,
|
||||
"date": str(latest),
|
||||
"window_days": days,
|
||||
"notes": {
|
||||
"activity_proxy": "audit_log (no se persisten logins individuales)",
|
||||
"point_in_time": "items/skus/clientes/empleados/fitment son snapshot al momento del rollup",
|
||||
},
|
||||
"tenants": out,
|
||||
}
|
||||
|
||||
|
||||
def get_portfolio_anomalies(date=None, z_threshold=DEFAULT_Z, min_tenants=MIN_COHORT,
|
||||
include_inactive=False):
|
||||
"""Cross-tenant anomaly report built from the daily rollup."""
|
||||
conn = get_master_conn()
|
||||
try:
|
||||
ensure_metrics_table(conn)
|
||||
target = date or _latest_date(conn)
|
||||
if target is None:
|
||||
return detect_portfolio_anomalies([], date=None,
|
||||
min_tenants=min_tenants,
|
||||
z_threshold=z_threshold)
|
||||
|
||||
where_active = "" if include_inactive else "AND t.is_active = true"
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT m.tenant_id, t.name, m.date,
|
||||
m.ventas_total, m.ventas_count, m.ticket_promedio,
|
||||
m.clientes_nuevos, m.actividad_count, m.errores_5xx,
|
||||
m.items_activos, m.skus, m.clientes, m.fitment_pct
|
||||
FROM tenant_metrics_daily m
|
||||
JOIN tenants t ON t.id = m.tenant_id
|
||||
WHERE m.date = %s {where_active}
|
||||
ORDER BY m.tenant_id
|
||||
""", (target,))
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
row = _serialize_row(dict(zip(cols, r)))
|
||||
row["tenant_name"] = row.pop("name")
|
||||
rows.append(row)
|
||||
cur.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
report = detect_portfolio_anomalies(rows, date=target,
|
||||
min_tenants=min_tenants,
|
||||
z_threshold=z_threshold)
|
||||
report["tenants_scanned"] = len(rows)
|
||||
return report
|
||||
@@ -193,59 +193,40 @@ def reset_tenant(tenant_id, keep_config=True):
|
||||
db_name = tenant["db_name"]
|
||||
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||
|
||||
# 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",
|
||||
tables_to_truncate = [
|
||||
"inventory_operations",
|
||||
"inventory",
|
||||
"sale_items",
|
||||
"sales",
|
||||
"customer_payments",
|
||||
"cash_register_closings",
|
||||
"cash_register_movements",
|
||||
"cash_registers",
|
||||
"invoices",
|
||||
"accounting_entries",
|
||||
"journal_entries",
|
||||
"service_orders",
|
||||
"fleet_vehicles",
|
||||
"crm_activities",
|
||||
"quotations",
|
||||
"quotation_items",
|
||||
"savings_transactions",
|
||||
"savings_accounts",
|
||||
"supplier_orders",
|
||||
"supplier_order_items",
|
||||
"warranty_claims",
|
||||
"notifications",
|
||||
"inventory_uploads",
|
||||
]
|
||||
|
||||
conn = psycopg2.connect(dsn)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
# 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
|
||||
for table in tables_to_truncate:
|
||||
try:
|
||||
cur.execute(f'TRUNCATE TABLE public."{table}" RESTART IDENTITY CASCADE')
|
||||
truncated += 1
|
||||
cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE")
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
pass # Table may not exist
|
||||
conn.commit()
|
||||
success = True
|
||||
except Exception as e:
|
||||
@@ -256,7 +237,7 @@ def reset_tenant(tenant_id, keep_config=True):
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return {"success": success, "tenant_id": tenant_id, "tables_reset": truncated}
|
||||
return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)}
|
||||
|
||||
|
||||
def delete_tenant(tenant_id):
|
||||
|
||||
@@ -10,7 +10,6 @@ const routes = {
|
||||
"#dashboard": "dashboard",
|
||||
"#demos": "demos",
|
||||
"#tenants": "tenants",
|
||||
"#portfolio": "portfolio",
|
||||
"#health": "health",
|
||||
"#migrations": "migrations"
|
||||
};
|
||||
@@ -30,7 +29,6 @@ function navigate() {
|
||||
dashboard: "Dashboard",
|
||||
demos: "Crear Demos",
|
||||
tenants: "Tenants",
|
||||
portfolio: "Cartera y Salud",
|
||||
health: "Salud del Sistema",
|
||||
migrations: "Migraciones"
|
||||
};
|
||||
@@ -40,7 +38,6 @@ 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();
|
||||
}
|
||||
@@ -274,77 +271,6 @@ document.getElementById("tenant-search")?.addEventListener("input", (e) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Portfolio (business metrics + health + anomalies) ─────────────────────
|
||||
const BAND_TAG = {
|
||||
healthy: "success",
|
||||
stable: "info",
|
||||
at_risk: "warning",
|
||||
critical: "danger",
|
||||
unknown: "default"
|
||||
};
|
||||
|
||||
async function loadPortfolio() {
|
||||
const tbody = document.getElementById("portfolio-table");
|
||||
const aBody = document.getElementById("anomalies-table");
|
||||
|
||||
const metricsRes = await api("/api/tenants/metrics?days=14");
|
||||
if (!metricsRes || metricsRes.status !== 200) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" class="text-muted text-center">Error al cargar métricas</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const m = metricsRes.data;
|
||||
document.getElementById("portfolio-meta").textContent = m.rollup_available
|
||||
? `Último día: ${m.date} · ventana ${m.window_days}d`
|
||||
: "Sin rollup";
|
||||
|
||||
if (!m.rollup_available) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" class="text-muted text-center">Sin datos de rollup. Ejecuta <code>scripts/rollup_tenant_metrics.py</code></td></tr>`;
|
||||
} else {
|
||||
tbody.innerHTML = m.tenants.map(t => {
|
||||
const h = t.health || {};
|
||||
const x = t.metrics || {};
|
||||
const score = h.health_score == null ? "—" : h.health_score;
|
||||
const signals = (h.signals || []).map(s => s.code).join(", ") || "—";
|
||||
const fitment = x.fitment_pct == null ? "—" : `${x.fitment_pct}%`;
|
||||
return `<tr>
|
||||
<td><strong>${escapeHtml(t.name)}</strong><br><span class="text-muted" style="font-size:11px">${escapeHtml(t.db_name)}</span></td>
|
||||
<td>${tag(`${score} · ${h.band_label || h.band}`, BAND_TAG[h.band] || "default")}</td>
|
||||
<td>${x.ventas_count ?? 0} / $${(x.ventas_total ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>$${(x.ticket_promedio ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${(x.items_activos ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${(x.clientes ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${fitment}</td>
|
||||
<td style="font-size:11px" class="text-muted">${escapeHtml(signals)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
const anomRes = await api("/api/portfolio/anomalies");
|
||||
if (!anomRes || anomRes.status !== 200) {
|
||||
aBody.innerHTML = `<tr><td colspan="6" class="text-muted text-center">Error al cargar anomalías</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const a = anomRes.data;
|
||||
document.getElementById("anomalies-note").textContent =
|
||||
`Día ${a.date || "—"} · cohorte ${a.cohort_size} tenants · z≥${a.thresholds?.z_threshold} (${a.thresholds?.method})` +
|
||||
(a.cold_start ? " · COLD START: cohorte insuficiente" : "");
|
||||
|
||||
if (!a.anomalies || !a.anomalies.length) {
|
||||
aBody.innerHTML = `<tr><td colspan="6" class="text-muted text-center">Sin anomalías entre tenants</td></tr>`;
|
||||
return;
|
||||
}
|
||||
aBody.innerHTML = a.anomalies.map(x => `
|
||||
<tr>
|
||||
<td>${escapeHtml(x.tenant_name)}</td>
|
||||
<td>${escapeHtml(x.label)}</td>
|
||||
<td>${(x.value ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${(x.cohort_median ?? 0).toLocaleString("es-MX")}</td>
|
||||
<td>${x.z}</td>
|
||||
<td>${tag(x.direction === "peak" ? "Pico inusual" : "Caída inusual", x.direction === "peak" ? "info" : "danger")}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
// ─── Health ────────────────────────────────────────────────────────────────
|
||||
async function loadHealth() {
|
||||
const res = await api("/api/health");
|
||||
|
||||
@@ -56,10 +56,6 @@
|
||||
<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>
|
||||
@@ -244,63 +240,6 @@
|
||||
</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">
|
||||
|
||||
@@ -74,7 +74,7 @@ server {
|
||||
}
|
||||
|
||||
location = / {
|
||||
return 302 /pos/login2;
|
||||
return 302 /pos/login;
|
||||
}
|
||||
|
||||
location / {
|
||||
|
||||
437
pos/app.py
437
pos/app.py
@@ -1,201 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from flask import Flask, request, redirect, make_response, g
|
||||
from flask import Flask
|
||||
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():
|
||||
@@ -206,200 +10,17 @@ 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
|
||||
response = make_response(send_from_directory('static/pwa', 'sw.js',
|
||||
mimetype='application/javascript'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
# ─── Manejo global de tablas retiradas (esquema TecDoc, junio 2026) ───
|
||||
# Los endpoints que aún consultan el catálogo OEM antiguo devolvían 500 con
|
||||
# SQL crudo. Aquí se degradan a una respuesta limpia y vacía.
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def _handle_retired_schema(e):
|
||||
if isinstance(e, HTTPException):
|
||||
return e
|
||||
try:
|
||||
from psycopg2.errors import UndefinedTable, UndefinedColumn
|
||||
if isinstance(e, (UndefinedTable, UndefinedColumn)):
|
||||
name = getattr(e, 'diag', None)
|
||||
table = getattr(name, 'table_name', '') if name else ''
|
||||
return jsonify({
|
||||
'data': [],
|
||||
'oem_disabled': True,
|
||||
'error': 'Función no disponible en este despliegue',
|
||||
'detail': f'La tabla "{table}" fue retirada del catálogo maestro.'
|
||||
if table else 'Consulta sobre esquema retirado.',
|
||||
}), 200
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
from flask import send_from_directory
|
||||
return send_from_directory('static/pwa', 'sw.js',
|
||||
mimetype='application/javascript')
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -499,25 +120,11 @@ 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')
|
||||
@@ -525,23 +132,11 @@ def create_app():
|
||||
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
|
||||
|
||||
@app.route('/pos/login')
|
||||
def pos_login_legacy():
|
||||
# Redirect to the new login path to bypass any stale browser/SW cache
|
||||
# of the old /pos/login page that did not send employee_id.
|
||||
response = make_response(redirect('/pos/login2'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
return response
|
||||
|
||||
@app.route('/pos/login2')
|
||||
def pos_login():
|
||||
response = make_response(render_template('login.html',
|
||||
return render_template('login.html',
|
||||
tenant_id=getattr(g, 'tenant_id', None),
|
||||
tenant_name=getattr(g, 'tenant_name', None),
|
||||
tenant_subdomain=getattr(g, 'tenant_subdomain', None)))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
|
||||
|
||||
@app.route('/pos/supplier-catalog')
|
||||
def supplier_catalog_page():
|
||||
@@ -573,11 +168,7 @@ def create_app():
|
||||
|
||||
@app.route('/pos/dashboard')
|
||||
def pos_dashboard():
|
||||
response = make_response(render_template('dashboard.html'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
return render_template('dashboard.html')
|
||||
|
||||
@app.route('/pos/config')
|
||||
def pos_config():
|
||||
@@ -619,18 +210,6 @@ def create_app():
|
||||
def pos_historical_sales():
|
||||
return render_template('historical_sales.html')
|
||||
|
||||
@app.route('/pos/remission-notes')
|
||||
def pos_remission_notes():
|
||||
response = make_response(render_template('remission_notes.html'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
@app.route('/pos/yonke')
|
||||
def pos_yonke():
|
||||
return render_template('yonke.html')
|
||||
|
||||
@app.route('/pos/static/<path:filename>')
|
||||
def pos_static(filename):
|
||||
return send_from_directory('static', filename)
|
||||
@@ -686,7 +265,7 @@ def create_app():
|
||||
|
||||
cur.execute("""
|
||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
|
||||
i.category_id, COALESCE(s.stock, 0) AS stock,
|
||||
i.category, COALESCE(s.stock, 0) AS stock,
|
||||
COALESCE(sv.total_sold, 0) AS total_sold
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
|
||||
@@ -7,62 +7,16 @@ NUMERIC(14,2) in the database.
|
||||
"""
|
||||
|
||||
import json
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from datetime import date, datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.accounting_engine import create_manual_entry, record_supplier_payment_entry
|
||||
from services.accounting_engine import create_manual_entry
|
||||
from services.audit import log_action
|
||||
|
||||
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'])
|
||||
@@ -353,11 +307,29 @@ def create_entry():
|
||||
|
||||
# ─── Financial Reports ──────────────────────────────
|
||||
|
||||
def _trial_balance_rows(conn, year, month):
|
||||
"""Balanza de comprobacion rows for a fiscal month."""
|
||||
@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)
|
||||
cur = conn.cursor()
|
||||
|
||||
period_start, period_end = _period_bounds(year, month)
|
||||
year = int(request.args.get('year', date.today().year))
|
||||
month = int(request.args.get('month', date.today().month))
|
||||
|
||||
# Period start/end dates
|
||||
period_start = f'{year}-{month:02d}-01'
|
||||
if month == 12:
|
||||
period_end = f'{year + 1}-01-01'
|
||||
else:
|
||||
period_end = f'{year}-{month + 1:02d}-01'
|
||||
|
||||
cur.execute("""
|
||||
WITH initial_balances AS (
|
||||
@@ -410,62 +382,31 @@ def _trial_balance_rows(conn, year, month):
|
||||
})
|
||||
|
||||
cur.close()
|
||||
return rows
|
||||
|
||||
|
||||
@accounting_bp.route('/trial-balance', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def trial_balance():
|
||||
"""Balanza de comprobacion for a period.
|
||||
|
||||
Query params:
|
||||
year: int (default current year)
|
||||
month: int (default current month)
|
||||
|
||||
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
year = int(request.args.get('year', date.today().year))
|
||||
month = int(request.args.get('month', date.today().month))
|
||||
|
||||
rows = _trial_balance_rows(conn, year, month)
|
||||
|
||||
conn.close()
|
||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
||||
|
||||
|
||||
@accounting_bp.route('/trial-balance/export', methods=['GET'])
|
||||
@accounting_bp.route('/income-statement', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def export_trial_balance():
|
||||
"""Export the balanza de comprobacion as CSV (default) or JSON.
|
||||
def income_statement():
|
||||
"""Estado de resultados for a period.
|
||||
|
||||
Query params: year, month, format=csv|json
|
||||
Query params: year, month
|
||||
|
||||
Groups: Ingresos (400), Costos (500), Gastos (600)
|
||||
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
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)
|
||||
year = int(request.args.get('year', date.today().year))
|
||||
month = int(request.args.get('month', date.today().month))
|
||||
|
||||
period_start = f'{year}-{month:02d}-01'
|
||||
if month == 12:
|
||||
period_end = f'{year + 1}-01-01'
|
||||
else:
|
||||
period_end = f'{year}-{month + 1:02d}-01'
|
||||
|
||||
cur.execute("""
|
||||
SELECT a.id, a.code, a.name, a.type,
|
||||
@@ -513,216 +454,17 @@ def _income_statement_data(conn, year, month):
|
||||
utilidad_neta = round(utilidad_bruta - total_gastos, 2)
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
return jsonify({
|
||||
'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'])
|
||||
@require_auth('accounting.view')
|
||||
@@ -823,141 +565,21 @@ def balance_sheet():
|
||||
@accounting_bp.route('/aging', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def aging_report():
|
||||
"""Antiguedad de saldos.
|
||||
"""Antiguedad de saldos (accounts receivable aging).
|
||||
|
||||
Returns individual credit sales with outstanding balance when type=receivable
|
||||
(default), or purchase orders payable to suppliers when type=payable.
|
||||
Groups outstanding credit sales by age:
|
||||
- Corriente (not yet due)
|
||||
- 1-30 dias
|
||||
- 31-60 dias
|
||||
- 61-90 dias
|
||||
- 90+ dias
|
||||
"""
|
||||
report_type = request.args.get('type', 'receivable')
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
rows = []
|
||||
if report_type == 'payable':
|
||||
paid_by_po = _supplier_paid_totals(cur)
|
||||
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
|
||||
cur.execute("""
|
||||
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
|
||||
po.status, s.id, s.name
|
||||
FROM purchase_orders po
|
||||
JOIN suppliers s ON s.id = po.supplier_id
|
||||
WHERE po.status NOT IN ('paid', 'cancelled')
|
||||
ORDER BY po.created_at DESC
|
||||
""")
|
||||
for r in cur.fetchall():
|
||||
po_id = r[0]
|
||||
invoice = r[1] or f'OC-{po_id}'
|
||||
total = float(r[2]) if r[2] else 0
|
||||
created_at = r[3]
|
||||
expected_date = r[4]
|
||||
status = r[5]
|
||||
vendor_name = r[7]
|
||||
|
||||
paid = paid_by_po.get(po_id, 0.0)
|
||||
balance = round(total - paid, 2)
|
||||
if balance <= 0:
|
||||
continue
|
||||
|
||||
days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
|
||||
if days_overdue > 0:
|
||||
po_status = 'overdue'
|
||||
label = 'Vencida'
|
||||
elif paid > 0:
|
||||
po_status = 'partial'
|
||||
label = 'Parcial'
|
||||
else:
|
||||
po_status = 'pending'
|
||||
label = 'Pendiente'
|
||||
|
||||
rows.append({
|
||||
'po_id': po_id,
|
||||
'invoice': invoice,
|
||||
'vendor_name': vendor_name,
|
||||
'issue_date': created_at.isoformat() if created_at else None,
|
||||
'due_date': expected_date.isoformat() if expected_date else None,
|
||||
'total': total,
|
||||
'paid': paid,
|
||||
'balance': balance,
|
||||
'days_overdue': days_overdue,
|
||||
'status': po_status,
|
||||
'status_label': label,
|
||||
})
|
||||
else:
|
||||
# Accounts receivable: credit sales to customers
|
||||
cur.execute("""
|
||||
SELECT s.id, s.total, s.created_at, s.status,
|
||||
c.id, c.name, c.rfc,
|
||||
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
|
||||
FROM sales s
|
||||
JOIN customers c ON c.id = s.customer_id
|
||||
WHERE s.sale_type = 'credit'
|
||||
AND s.status = 'completed'
|
||||
ORDER BY s.created_at DESC
|
||||
""")
|
||||
for r in cur.fetchall():
|
||||
sale_id = r[0]
|
||||
total = float(r[1]) if r[1] else 0
|
||||
created_at = r[2]
|
||||
status = r[3]
|
||||
customer_name = r[5]
|
||||
payments_total = float(r[7]) if r[7] else 0
|
||||
paid = payments_total
|
||||
balance = round(total - paid, 2)
|
||||
|
||||
if balance <= 0:
|
||||
continue
|
||||
|
||||
due_date = created_at + timedelta(days=30) if created_at else None
|
||||
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
|
||||
|
||||
if days_overdue > 0:
|
||||
sale_status = 'overdue'
|
||||
label = 'Vencida'
|
||||
elif paid > 0:
|
||||
sale_status = 'partial'
|
||||
label = 'Parcial'
|
||||
else:
|
||||
sale_status = 'pending'
|
||||
label = 'Vigente'
|
||||
|
||||
rows.append({
|
||||
'sale_id': sale_id,
|
||||
'invoice': f'VTA-{sale_id}',
|
||||
'customer_name': customer_name,
|
||||
'issue_date': created_at.isoformat() if created_at else None,
|
||||
'due_date': due_date.isoformat() if due_date else None,
|
||||
'total': total,
|
||||
'paid': paid,
|
||||
'balance': balance,
|
||||
'days_overdue': days_overdue,
|
||||
'status': sale_status,
|
||||
'status_label': label,
|
||||
})
|
||||
|
||||
totals = {
|
||||
'count': len(rows),
|
||||
'total': round(sum(r['total'] for r in rows), 2),
|
||||
'paid': round(sum(r['paid'] for r in rows), 2),
|
||||
'balance': round(sum(r['balance'] for r in rows), 2),
|
||||
}
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'data': rows, 'totals': totals})
|
||||
|
||||
|
||||
@accounting_bp.route('/aging-summary', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def aging_summary():
|
||||
"""Customer-level accounts receivable summary (used by reports)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
|
||||
s.id as sale_id,
|
||||
s.total - COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as balance,
|
||||
s.created_at,
|
||||
s.id as sale_id, s.total, s.created_at,
|
||||
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
|
||||
FROM customers c
|
||||
JOIN sales s ON s.customer_id = c.id
|
||||
@@ -970,11 +592,6 @@ def aging_summary():
|
||||
customers = {}
|
||||
for r in cur.fetchall():
|
||||
cust_id = r[0]
|
||||
balance = round(float(r[6]) if r[6] else 0, 2)
|
||||
if balance <= 0:
|
||||
continue
|
||||
days = r[8] or 0
|
||||
|
||||
if cust_id not in customers:
|
||||
customers[cust_id] = {
|
||||
'id': r[0], 'name': r[1], 'rfc': r[2],
|
||||
@@ -984,25 +601,29 @@ def aging_summary():
|
||||
'total': 0,
|
||||
}
|
||||
|
||||
if days <= 0:
|
||||
customers[cust_id]['corriente'] += balance
|
||||
elif days <= 30:
|
||||
customers[cust_id]['d1_30'] += balance
|
||||
elif days <= 60:
|
||||
customers[cust_id]['d31_60'] += balance
|
||||
elif days <= 90:
|
||||
customers[cust_id]['d61_90'] += balance
|
||||
else:
|
||||
customers[cust_id]['d90_plus'] += balance
|
||||
amount = float(r[6]) if r[6] else 0
|
||||
days = r[8] or 0
|
||||
|
||||
customers[cust_id]['total'] += balance
|
||||
if days <= 0:
|
||||
customers[cust_id]['corriente'] += amount
|
||||
elif days <= 30:
|
||||
customers[cust_id]['d1_30'] += amount
|
||||
elif days <= 60:
|
||||
customers[cust_id]['d31_60'] += amount
|
||||
elif days <= 90:
|
||||
customers[cust_id]['d61_90'] += amount
|
||||
else:
|
||||
customers[cust_id]['d90_plus'] += amount
|
||||
|
||||
customers[cust_id]['total'] += amount
|
||||
|
||||
result = list(customers.values())
|
||||
# Round all amounts
|
||||
for c in result:
|
||||
c['credit_balance'] = round(c['total'], 2)
|
||||
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
|
||||
c[key] = round(c[key], 2)
|
||||
|
||||
# Totals row
|
||||
totals = {
|
||||
'corriente': round(sum(c['corriente'] for c in result), 2),
|
||||
'd1_30': round(sum(c['d1_30'] for c in result), 2),
|
||||
@@ -1010,7 +631,6 @@ def aging_summary():
|
||||
'd61_90': round(sum(c['d61_90'] for c in result), 2),
|
||||
'd90_plus': round(sum(c['d90_plus'] for c in result), 2),
|
||||
'total': round(sum(c['total'] for c in result), 2),
|
||||
'count': len(result),
|
||||
}
|
||||
|
||||
cur.close()
|
||||
@@ -1018,256 +638,6 @@ def aging_summary():
|
||||
return jsonify({'data': result, 'totals': totals})
|
||||
|
||||
|
||||
@accounting_bp.route('/aging/export', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def export_aging():
|
||||
"""Export receivables or payables to CSV/PDF."""
|
||||
report_type = request.args.get('type', 'receivable')
|
||||
fmt = request.args.get('format', 'csv').lower()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
if report_type == 'payable':
|
||||
# Payables always CSV for now
|
||||
paid_by_po = _supplier_paid_totals(cur)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
|
||||
'Total', 'Pagado', 'Saldo', 'Dias vencido', 'Estado'])
|
||||
cur.execute("""
|
||||
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
|
||||
po.status, s.name
|
||||
FROM purchase_orders po
|
||||
JOIN suppliers s ON s.id = po.supplier_id
|
||||
WHERE po.status NOT IN ('paid', 'cancelled')
|
||||
ORDER BY po.created_at DESC
|
||||
""")
|
||||
for r in cur.fetchall():
|
||||
po_id = r[0]
|
||||
invoice = r[1] or f'OC-{po_id}'
|
||||
total = float(r[2]) if r[2] else 0
|
||||
created_at = r[3]
|
||||
expected = r[4]
|
||||
status = r[5]
|
||||
vendor = r[6]
|
||||
paid = paid_by_po.get(po_id, 0.0)
|
||||
balance = round(total - paid, 2)
|
||||
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
|
||||
status_label = 'Vencida' if days_overdue > 0 else ('Parcial' if paid > 0 else 'Pendiente')
|
||||
writer.writerow([
|
||||
invoice, vendor,
|
||||
created_at.strftime('%Y-%m-%d') if created_at else '',
|
||||
expected.strftime('%Y-%m-%d') if expected else '',
|
||||
total, paid, balance, days_overdue, status_label
|
||||
])
|
||||
cur.close(); conn.close()
|
||||
csv_data = output.getvalue()
|
||||
output.close()
|
||||
return Response(
|
||||
'\ufeff' + csv_data,
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename=cuentas_por_pagar_{date.today().isoformat()}.csv'}
|
||||
)
|
||||
|
||||
# Receivables: CSV or PDF
|
||||
cur.execute("""
|
||||
SELECT s.id, s.subtotal, s.tax_total, s.total, s.created_at,
|
||||
c.name,
|
||||
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
|
||||
FROM sales s
|
||||
JOIN customers c ON c.id = s.customer_id
|
||||
WHERE s.sale_type = 'credit'
|
||||
AND s.status = 'completed'
|
||||
ORDER BY s.created_at DESC
|
||||
""")
|
||||
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
sale_id = r[0]
|
||||
subtotal = float(r[1]) if r[1] else 0
|
||||
tax = float(r[2]) if r[2] else 0
|
||||
total = float(r[3]) if r[3] else 0
|
||||
created_at = r[4]
|
||||
customer_name = r[5]
|
||||
payments_total = float(r[6]) if r[6] else 0
|
||||
balance = round(total - payments_total, 2)
|
||||
if balance <= 0:
|
||||
continue
|
||||
rows.append({
|
||||
'date': created_at.strftime('%d/%m/%Y') if created_at else '',
|
||||
'customer': customer_name,
|
||||
'subtotal': subtotal,
|
||||
'tax': tax,
|
||||
'total': total,
|
||||
'balance': balance,
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
if fmt == 'pdf':
|
||||
from fpdf import FPDF
|
||||
|
||||
class ReceivablesPDF(FPDF):
|
||||
def header(self):
|
||||
self.set_font('Arial', 'B', 14)
|
||||
self.cell(0, 10, 'Reporte de Ventas por Cobrar', 0, 1, 'C')
|
||||
self.set_font('Arial', '', 10)
|
||||
self.cell(0, 6, f'Generado: {date.today().strftime("%d/%m/%Y")}', 0, 1, 'C')
|
||||
self.ln(4)
|
||||
|
||||
def footer(self):
|
||||
self.set_y(-15)
|
||||
self.set_font('Arial', 'I', 8)
|
||||
self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C')
|
||||
|
||||
pdf = ReceivablesPDF('L', 'mm', 'A4')
|
||||
pdf.add_page()
|
||||
pdf.set_font('Arial', 'B', 10)
|
||||
pdf.set_fill_color(230, 230, 230)
|
||||
|
||||
col_widths = [30, 95, 35, 30, 35, 35]
|
||||
headers = ['FECHA', 'CLIENTE', 'SUBTOTAL', 'IVA', 'TOTAL', 'SALDO']
|
||||
for i, h in enumerate(headers):
|
||||
pdf.cell(col_widths[i], 10, h, 1, 0, 'C', True)
|
||||
pdf.ln()
|
||||
|
||||
pdf.set_font('Arial', '', 9)
|
||||
totals = {'subtotal': 0, 'tax': 0, 'total': 0, 'balance': 0}
|
||||
for row in rows:
|
||||
pdf.cell(col_widths[0], 8, row['date'], 1, 0, 'C')
|
||||
pdf.cell(col_widths[1], 8, row['customer'][:50], 1, 0, 'L')
|
||||
pdf.cell(col_widths[2], 8, f"{row['subtotal']:.2f}", 1, 0, 'R')
|
||||
pdf.cell(col_widths[3], 8, f"{row['tax']:.2f}", 1, 0, 'R')
|
||||
pdf.cell(col_widths[4], 8, f"{row['total']:.2f}", 1, 0, 'R')
|
||||
pdf.cell(col_widths[5], 8, f"{row['balance']:.2f}", 1, 1, 'R')
|
||||
totals['subtotal'] += row['subtotal']
|
||||
totals['tax'] += row['tax']
|
||||
totals['total'] += row['total']
|
||||
totals['balance'] += row['balance']
|
||||
|
||||
pdf.set_font('Arial', 'B', 9)
|
||||
pdf.cell(col_widths[0] + col_widths[1], 8, 'TOTAL', 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[2], 8, f"{totals['subtotal']:.2f}", 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[3], 8, f"{totals['tax']:.2f}", 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[4], 8, f"{totals['total']:.2f}", 1, 0, 'R', True)
|
||||
pdf.cell(col_widths[5], 8, f"{totals['balance']:.2f}", 1, 1, 'R', True)
|
||||
|
||||
pdf_bytes = bytes(pdf.output(dest='S'))
|
||||
return Response(
|
||||
pdf_bytes,
|
||||
mimetype='application/pdf',
|
||||
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.pdf'}
|
||||
)
|
||||
|
||||
# CSV fallback
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Fecha', 'Cliente', 'Subtotal', 'IVA', 'Total', 'Saldo'])
|
||||
for row in rows:
|
||||
writer.writerow([row['date'], row['customer'], row['subtotal'], row['tax'], row['total'], row['balance']])
|
||||
csv_data = output.getvalue()
|
||||
output.close()
|
||||
return Response(
|
||||
'\ufeff' + csv_data,
|
||||
mimetype='text/csv; charset=utf-8',
|
||||
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.csv'}
|
||||
)
|
||||
|
||||
|
||||
# ─── Accounts Payable payments ─────────────────────
|
||||
|
||||
@accounting_bp.route('/supplier-payments', methods=['POST'])
|
||||
@require_auth('accounting.create')
|
||||
def create_supplier_payment():
|
||||
"""Register a payment to a supplier against a purchase order (CxP).
|
||||
|
||||
Body: {po_id: int, amount: float, payment_method: str, reference: str}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('po_id'):
|
||||
return jsonify({'error': 'po_id is required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = record_supplier_payment_entry(conn, data)
|
||||
|
||||
log_action(conn, 'SUPPLIER_PAYMENT', 'purchase_order', result['po_id'],
|
||||
new_value={'amount': float(data.get('amount', 0)),
|
||||
'payment_id': result['payment_id'],
|
||||
'status': result['status']})
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(result), 201
|
||||
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@accounting_bp.route('/supplier-payments', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
def list_supplier_payments():
|
||||
"""List supplier payments. Query params: po_id, supplier_id, page, per_page."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT to_regclass('public.supplier_payments')")
|
||||
if not cur.fetchone()[0]:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': [], 'pagination': {'page': 1, 'per_page': 50, 'total': 0, 'total_pages': 0}})
|
||||
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
|
||||
where_clauses = ["1=1"]
|
||||
params = []
|
||||
if request.args.get('po_id'):
|
||||
where_clauses.append("sp.po_id = %s")
|
||||
params.append(int(request.args.get('po_id')))
|
||||
if request.args.get('supplier_id'):
|
||||
where_clauses.append("sp.supplier_id = %s")
|
||||
params.append(int(request.args.get('supplier_id')))
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"SELECT count(*) FROM supplier_payments sp WHERE {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT sp.id, sp.po_id, sp.supplier_id, sp.amount, sp.payment_method,
|
||||
sp.reference, sp.created_at, s.name as supplier_name
|
||||
FROM supplier_payments sp
|
||||
LEFT JOIN suppliers s ON sp.supplier_id = s.id
|
||||
WHERE {where}
|
||||
ORDER BY sp.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
rows.append({
|
||||
'id': r[0], 'po_id': r[1], 'supplier_id': r[2],
|
||||
'amount': float(r[3]) if r[3] else 0,
|
||||
'payment_method': r[4], 'reference': r[5],
|
||||
'created_at': str(r[6]) if r[6] else None,
|
||||
'supplier_name': r[7],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
total_pages = (total + per_page - 1) // per_page
|
||||
return jsonify({
|
||||
'data': rows,
|
||||
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
||||
})
|
||||
|
||||
|
||||
# ─── Fiscal Periods ────────────────────────────────
|
||||
|
||||
@accounting_bp.route('/periods', methods=['GET'])
|
||||
@@ -1374,7 +744,7 @@ def close_period():
|
||||
|
||||
|
||||
@accounting_bp.route('/stats', methods=['GET'])
|
||||
@require_auth('accounting.view')
|
||||
@require_auth('accounting.read')
|
||||
def api_accounting_stats():
|
||||
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
@@ -5,10 +5,9 @@ import jwt
|
||||
import bcrypt
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from flask import Blueprint, request, jsonify, g, make_response
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from middleware import require_auth
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
||||
|
||||
@@ -60,7 +59,6 @@ def login_pin():
|
||||
# Subdomain-resolved tenant takes priority over body param
|
||||
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
|
||||
pin = data.get('pin', '')
|
||||
employee_id = data.get('employee_id')
|
||||
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
|
||||
# Optional: branch_id from the device for PIN search optimization
|
||||
device_branch_id = data.get('branch_id')
|
||||
@@ -91,26 +89,39 @@ def login_pin():
|
||||
|
||||
matched_employee = None
|
||||
|
||||
if not employee_id:
|
||||
_record_attempt(device_id, False)
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'error': 'Empleado no seleccionado'}), 400
|
||||
|
||||
# Verify the PIN only for the selected employee.
|
||||
if device_branch_id:
|
||||
# Try branch employees first (fast path for known devices)
|
||||
cur.execute("""
|
||||
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
|
||||
FROM employees e
|
||||
WHERE e.id = %s AND e.is_active = true AND e.pin IS NOT NULL
|
||||
""", (employee_id,))
|
||||
emp = cur.fetchone()
|
||||
if emp:
|
||||
WHERE e.is_active = true AND e.pin IS NOT NULL AND e.branch_id = %s
|
||||
""", (device_branch_id,))
|
||||
for emp in cur.fetchall():
|
||||
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
|
||||
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
|
||||
matched_employee = {
|
||||
'id': emp_id, 'name': emp_name, 'role': emp_role,
|
||||
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
|
||||
}
|
||||
break
|
||||
|
||||
if not matched_employee:
|
||||
# Fallback: check ALL active employees (covers owners, admins, roaming staff)
|
||||
cur.execute("""
|
||||
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
|
||||
FROM employees e
|
||||
WHERE e.is_active = true AND e.pin IS NOT NULL
|
||||
""")
|
||||
employees = cur.fetchall()
|
||||
|
||||
for emp in employees:
|
||||
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
|
||||
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
|
||||
matched_employee = {
|
||||
'id': emp_id, 'name': emp_name, 'role': emp_role,
|
||||
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
|
||||
}
|
||||
break
|
||||
|
||||
if not matched_employee:
|
||||
_record_attempt(device_id, False)
|
||||
@@ -146,77 +157,9 @@ def login_pin():
|
||||
}
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||
|
||||
response = make_response(jsonify({
|
||||
'token': token,
|
||||
'employee': matched_employee,
|
||||
'permissions': permissions
|
||||
}))
|
||||
# Cookie used by server-side route guards; JS can also read it for quick checks.
|
||||
response.set_cookie(
|
||||
'pos_role', matched_employee['role'],
|
||||
path='/pos', samesite='Lax', httponly=False
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@auth_bp.route('/refresh', methods=['POST'])
|
||||
@require_auth()
|
||||
def refresh_token():
|
||||
"""Reissue the JWT with the employee's current permissions from the DB.
|
||||
|
||||
This lets permission changes take effect without forcing a full re-login.
|
||||
The original expiration time is preserved.
|
||||
"""
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
try:
|
||||
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 401
|
||||
|
||||
tenant_id = payload.get('tenant_id')
|
||||
employee_id = payload.get('employee_id')
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
|
||||
FROM employees e
|
||||
WHERE e.id = %s AND e.is_active = true
|
||||
""",
|
||||
(employee_id,)
|
||||
)
|
||||
emp = cur.fetchone()
|
||||
if not emp:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Employee not found or inactive'}), 404
|
||||
|
||||
cur.execute(
|
||||
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
|
||||
(emp[0],)
|
||||
)
|
||||
permissions = [r[0] for r in cur.fetchall()]
|
||||
cur.close(); conn.close()
|
||||
|
||||
new_payload = {
|
||||
'tenant_id': tenant_id,
|
||||
'employee_id': emp[0],
|
||||
'name': emp[1],
|
||||
'role': emp[2],
|
||||
'branch_id': emp[3],
|
||||
'max_discount_pct': float(emp[4]) if emp[4] else 0,
|
||||
'permissions': permissions,
|
||||
'device_id': payload.get('device_id', 'unknown'),
|
||||
'type': 'pos_access',
|
||||
'exp': payload.get('exp'),
|
||||
'iat': datetime.now(timezone.utc),
|
||||
}
|
||||
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
|
||||
return jsonify({
|
||||
'token': token,
|
||||
'employee': {
|
||||
'id': emp[0], 'name': emp[1], 'role': emp[2],
|
||||
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
|
||||
},
|
||||
'employee': matched_employee,
|
||||
'permissions': permissions
|
||||
})
|
||||
|
||||
@@ -254,7 +197,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', 'driver': 'Repartidor'}
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador'}
|
||||
employees.append({
|
||||
'id': row[0],
|
||||
'name': name,
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
# /home/Autopartes/pos/blueprints/b2b_bp.py
|
||||
"""Nexus B2B yonke marketplace blueprint.
|
||||
|
||||
Wholesale trading of used parts between yonkes (deshuesaderos): one tenant
|
||||
publishes its used pieces, another buys them. Listings and orders live in the
|
||||
MASTER DB; the pieces stay in each tenant DB.
|
||||
|
||||
All endpoints live under /pos/api/b2b.
|
||||
Permissions: yonke.view for reads, yonke.manage for writes.
|
||||
|
||||
Seller:
|
||||
GET /pos/api/b2b/my-listings my published listings
|
||||
POST /pos/api/b2b/listings publish/refresh a listing
|
||||
PATCH /pos/api/b2b/listings/<id> edit price/status/notes
|
||||
DELETE /pos/api/b2b/listings/<id> soft-delete (status='removed')
|
||||
GET /pos/api/b2b/seller/status {is_seller, listings_count, ...}
|
||||
Buyer:
|
||||
GET /pos/api/b2b/catalog available listings from other tenants
|
||||
GET /pos/api/b2b/listings/<id> listing detail (+ seller_name)
|
||||
Orders (both):
|
||||
POST /pos/api/b2b/orders create an order from listings
|
||||
GET /pos/api/b2b/orders my orders as buyer
|
||||
GET /pos/api/b2b/orders/incoming my orders as seller
|
||||
GET /pos/api/b2b/orders/<id> order detail (participants only)
|
||||
PUT /pos/api/b2b/orders/<id>/status transition the order status
|
||||
"""
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
from services import b2b_service
|
||||
from tenant_db import get_master_conn
|
||||
|
||||
b2b_bp = Blueprint('b2b', __name__, url_prefix='/pos/api/b2b')
|
||||
|
||||
|
||||
def _int_arg(name, default, minimum, maximum):
|
||||
try:
|
||||
val = int(request.args.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return min(max(val, minimum), maximum)
|
||||
|
||||
|
||||
def _error_response(e):
|
||||
"""Map a service exception to (json, status); never leaks a 500."""
|
||||
if isinstance(e, b2b_service.B2BForbidden):
|
||||
return jsonify({'error': str(e)}), 403
|
||||
if isinstance(e, b2b_service.B2BNotFound):
|
||||
return jsonify({'error': str(e)}), 404
|
||||
if isinstance(e, b2b_service.B2BValidationError):
|
||||
return jsonify({'error': str(e)}), 400
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# SELLER
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@b2b_bp.route('/my-listings', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def my_listings():
|
||||
"""Listings published by the current tenant."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
data = b2b_service.list_my_listings(
|
||||
master, g.tenant_id, status=request.args.get('status'))
|
||||
return jsonify({'data': data})
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/listings', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def publish_listing():
|
||||
"""Publish (or refresh) one of my used/rebuild pieces to the B2B catalog."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
master = get_master_conn()
|
||||
try:
|
||||
listing = b2b_service.publish_listing(master, g.tenant_id, data)
|
||||
master.commit()
|
||||
return jsonify(listing), 201
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/listings/<int:listing_id>', methods=['PATCH'])
|
||||
@require_auth('yonke.manage')
|
||||
def update_listing(listing_id):
|
||||
"""Edit price_wholesale / status / notes of one of my listings."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
master = get_master_conn()
|
||||
try:
|
||||
listing = b2b_service.update_listing(master, g.tenant_id, listing_id, data)
|
||||
master.commit()
|
||||
return jsonify(listing)
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/listings/<int:listing_id>', methods=['DELETE'])
|
||||
@require_auth('yonke.manage')
|
||||
def remove_listing(listing_id):
|
||||
"""Soft-delete a listing (status='removed')."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
b2b_service.remove_listing(master, g.tenant_id, listing_id)
|
||||
master.commit()
|
||||
return jsonify({'ok': True, 'id': listing_id, 'status': 'removed'})
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/seller/status', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def seller_status():
|
||||
"""{is_seller, listings_count, incoming_orders} for the current tenant."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
return jsonify(b2b_service.get_seller_status(master, g.tenant_id))
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# BUYER — catalog
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@b2b_bp.route('/catalog', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def catalog():
|
||||
"""Available listings from OTHER tenants, with seller_name and pagination."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
items, pagination = b2b_service.list_catalog(
|
||||
master, g.tenant_id,
|
||||
filters={
|
||||
'q': request.args.get('q'),
|
||||
'make': request.args.get('make'),
|
||||
'model': request.args.get('model'),
|
||||
'year': request.args.get('year'),
|
||||
'grade': request.args.get('grade'),
|
||||
},
|
||||
page=_int_arg('page', 1, 1, 10**9),
|
||||
per_page=_int_arg('per_page', 12, 1, 100),
|
||||
)
|
||||
return jsonify({'data': items, 'pagination': pagination})
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/listings/<int:listing_id>', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def get_listing(listing_id):
|
||||
"""Listing detail with seller_name."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
listing = b2b_service.get_listing(master, listing_id)
|
||||
if not listing:
|
||||
return jsonify({'error': 'Listing no encontrado'}), 404
|
||||
return jsonify(listing)
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@b2b_bp.route('/orders', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def create_order():
|
||||
"""Create a pending order from one or more listings (single seller)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
master = get_master_conn()
|
||||
try:
|
||||
order = b2b_service.create_order(
|
||||
master, g.tenant_id, data.get('items'), notes=data.get('notes'))
|
||||
master.commit()
|
||||
return jsonify(order), 201
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/orders', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def list_orders():
|
||||
"""My orders as buyer (role='buyer')."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
return jsonify({'data': b2b_service.list_orders(master, g.tenant_id, 'buyer')})
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/orders/incoming', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def list_incoming_orders():
|
||||
"""Orders I must fulfil as seller (role='seller')."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
return jsonify({'data': b2b_service.list_orders(master, g.tenant_id, 'seller')})
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/orders/<int:order_id>', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def get_order(order_id):
|
||||
"""Order detail (buyer or seller of that order only)."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
return jsonify(b2b_service.get_order(master, g.tenant_id, order_id))
|
||||
except Exception as e:
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@b2b_bp.route('/orders/<int:order_id>/status', methods=['PUT'])
|
||||
@require_auth('yonke.manage')
|
||||
def update_order_status(order_id):
|
||||
"""Transition an order status (role-checked). 'received' also stocks the buyer."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
master = get_master_conn()
|
||||
try:
|
||||
result = b2b_service.update_order_status(
|
||||
master, g.tenant_id, order_id, data.get('status'),
|
||||
branch_id=g.branch_id)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
master.rollback()
|
||||
return _error_response(e)
|
||||
finally:
|
||||
master.close()
|
||||
@@ -1,54 +1,18 @@
|
||||
# /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 middleware import require_auth
|
||||
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')
|
||||
|
||||
|
||||
# Roles expected to operate a cash register even without the explicit pos.sell permission.
|
||||
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
|
||||
|
||||
|
||||
def _can_operate_register():
|
||||
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
|
||||
|
||||
|
||||
def _int_arg(name, default=None, min_value=None, max_value=None):
|
||||
"""Parse an integer query-string argument safely.
|
||||
|
||||
Bad input (e.g. ?per_page=abc) falls back to the default instead of
|
||||
raising ValueError and returning a 500.
|
||||
"""
|
||||
raw = request.args.get(name)
|
||||
if raw is None or raw == '':
|
||||
value = default
|
||||
else:
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
if value is None:
|
||||
return None
|
||||
if min_value is not None and value < min_value:
|
||||
value = min_value
|
||||
if max_value is not None and value > max_value:
|
||||
value = max_value
|
||||
return value
|
||||
|
||||
|
||||
@cashregister_bp.route('/open', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('pos.sell')
|
||||
def open_register():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Open a cash register session.
|
||||
|
||||
Body: {register_number: int, opening_amount: float}
|
||||
@@ -120,7 +84,7 @@ def open_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/current', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('pos.sell')
|
||||
def current_register():
|
||||
"""Get the current open register for this employee."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -151,10 +115,8 @@ def current_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/movement', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('pos.sell')
|
||||
def cash_movement():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Record a cash in/out movement with mandatory reason.
|
||||
|
||||
Body: {type: 'in'|'out', amount: float, reason: str}
|
||||
@@ -246,35 +208,8 @@ def _compute_register_summary(conn, register_id):
|
||||
total_sales += amount
|
||||
total_sales_count += count
|
||||
|
||||
# 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])
|
||||
# Cash sales specifically (for expected cash calculation)
|
||||
cash_from_sales = sales_by_method.get('efectivo', {}).get('amount', 0)
|
||||
|
||||
# Change given (cash out)
|
||||
cur.execute("""
|
||||
@@ -283,26 +218,15 @@ def _compute_register_summary(conn, register_id):
|
||||
""", (register_id,))
|
||||
change_given = float(cur.fetchone()[0])
|
||||
|
||||
# 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.
|
||||
# Cash movements
|
||||
cur.execute("""
|
||||
SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
|
||||
WHERE register_id = %s
|
||||
AND NOT (type = 'out' AND reason LIKE 'Devolucion #%%')
|
||||
GROUP BY type
|
||||
WHERE register_id = %s 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
|
||||
@@ -312,10 +236,9 @@ 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 - refunds
|
||||
# Expected cash = opening + cash sales - change + cash_in - cash_out
|
||||
expected_cash = round(
|
||||
opening_amount + cash_from_sales - change_given
|
||||
+ movements['in'] - movements['out'] - refunds_out,
|
||||
opening_amount + cash_from_sales - change_given + movements['in'] - movements['out'],
|
||||
2
|
||||
)
|
||||
|
||||
@@ -342,7 +265,6 @@ 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),
|
||||
@@ -351,10 +273,8 @@ def _compute_register_summary(conn, register_id):
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-x', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('pos.sell')
|
||||
def cut_x():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Partial cut (corte X): read-only summary without closing the register."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -381,10 +301,8 @@ def cut_x():
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-z', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('pos.sell')
|
||||
def cut_z():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Final cut (corte Z): close the register.
|
||||
|
||||
Body: {closing_amount: float} (the amount physically counted in the register)
|
||||
@@ -396,7 +314,7 @@ def cut_z():
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, register_number FROM cash_registers
|
||||
SELECT id FROM cash_registers
|
||||
WHERE employee_id = %s AND status = 'open'
|
||||
""", (g.employee_id,))
|
||||
reg = cur.fetchone()
|
||||
@@ -404,7 +322,7 @@ def cut_z():
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'No open register'}), 400
|
||||
|
||||
register_id, register_number = reg
|
||||
register_id = reg[0]
|
||||
summary = _compute_register_summary(conn, register_id)
|
||||
|
||||
expected = summary['expected_cash']
|
||||
@@ -428,22 +346,6 @@ 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:
|
||||
@@ -477,23 +379,17 @@ def cut_z():
|
||||
|
||||
|
||||
@cashregister_bp.route('/history', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('pos.view')
|
||||
def register_history():
|
||||
"""List closed registers with summary.
|
||||
|
||||
Query params: date_from, date_to, employee_id, page, per_page
|
||||
|
||||
Permission rules:
|
||||
- owner/admin and users with pos.view can query any employee.
|
||||
- Cashiers/counters without pos.view can only query their own registers.
|
||||
"""
|
||||
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
page = _int_arg('page', 1, min_value=1)
|
||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
|
||||
where_clauses = ["cr.status = 'closed'"]
|
||||
params = []
|
||||
@@ -504,10 +400,7 @@ def register_history():
|
||||
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
employee_id = _int_arg('employee_id')
|
||||
|
||||
if not can_view_all:
|
||||
employee_id = g.employee_id
|
||||
employee_id = request.args.get('employee_id')
|
||||
|
||||
if date_from:
|
||||
where_clauses.append("cr.closed_at >= %s")
|
||||
@@ -517,7 +410,7 @@ def register_history():
|
||||
params.append(date_to)
|
||||
if employee_id:
|
||||
where_clauses.append("cr.employee_id = %s")
|
||||
params.append(employee_id)
|
||||
params.append(int(employee_id))
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
@@ -683,89 +576,3 @@ def daily_summary():
|
||||
'movements_out': movements['out'],
|
||||
'registers': registers,
|
||||
})
|
||||
|
||||
|
||||
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
|
||||
@require_auth()
|
||||
def register_sales(register_id):
|
||||
"""List the sales associated with a specific cash register (cash cut).
|
||||
|
||||
Returns sales details and a summary by payment method. Accessible to
|
||||
owners/admins, users with pos.view, or the employee who operated the register.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
|
||||
(register_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Register not found'}), 404
|
||||
|
||||
register_employee_id, register_branch_id = row
|
||||
|
||||
can_view = (
|
||||
g.employee_role in ('owner', 'admin') or
|
||||
has_permission('pos.view') or
|
||||
register_employee_id == g.employee_id
|
||||
)
|
||||
if not can_view:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
||||
|
||||
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Register belongs to another branch'}), 403
|
||||
|
||||
where = "s.register_id = %s"
|
||||
params = [register_id]
|
||||
if g.branch_id:
|
||||
where += " AND s.branch_id = %s"
|
||||
params.append(g.branch_id)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
|
||||
s.created_at, c.name as customer_name, e.name as employee_name
|
||||
FROM sales s
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
WHERE {where}
|
||||
ORDER BY s.created_at DESC
|
||||
""", params)
|
||||
|
||||
sales = []
|
||||
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
|
||||
for r in cur.fetchall():
|
||||
sale = {
|
||||
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
|
||||
'subtotal': float(r[3]) if r[3] else 0,
|
||||
'discount_total': float(r[4]) if r[4] else 0,
|
||||
'tax_total': float(r[5]) if r[5] else 0,
|
||||
'total': float(r[6]) if r[6] else 0,
|
||||
'amount_paid': float(r[7]) if r[7] else 0,
|
||||
'change_given': float(r[8]) if r[8] else 0,
|
||||
'status': r[9], 'created_at': str(r[10]),
|
||||
'customer_name': r[11], 'employee_name': r[12]
|
||||
}
|
||||
sales.append(sale)
|
||||
if sale['status'] == 'completed':
|
||||
summary['total'] += sale['total']
|
||||
summary['count'] += 1
|
||||
m = sale['payment_method'] or 'Otro'
|
||||
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
'register_id': register_id,
|
||||
'sales': sales,
|
||||
'summary': {
|
||||
'total': round(summary['total'], 2),
|
||||
'count': summary['count'],
|
||||
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,32 +19,11 @@ 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:
|
||||
@@ -87,9 +66,6 @@ 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:
|
||||
@@ -100,15 +76,6 @@ 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
|
||||
@@ -116,9 +83,6 @@ 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:
|
||||
@@ -523,33 +487,6 @@ 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):
|
||||
@@ -604,9 +541,6 @@ 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:
|
||||
@@ -642,9 +576,6 @@ 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:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# /home/Autopartes/pos/blueprints/config_bp.py
|
||||
"""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
|
||||
@@ -10,194 +8,6 @@ from tenant_db import get_tenant_conn
|
||||
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
||||
|
||||
|
||||
# Default permission set per role. Can be overridden per tenant via role_permissions config.
|
||||
_DEFAULT_ROLE_PERMISSIONS = {
|
||||
'owner': [], # owner bypasses permission checks
|
||||
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||
'yonke.view', 'yonke.manage',
|
||||
'catalog.view', 'catalog.edit',
|
||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||
'invoicing.view', 'invoicing.create',
|
||||
'reports.view', 'reports.financial',
|
||||
'config.view', 'config.edit', 'config.edit_prices',
|
||||
'workshop.view', 'workshop.edit',
|
||||
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
|
||||
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view', 'pos.remission',
|
||||
'catalog.view',
|
||||
'inventory.view', 'inventory.create',
|
||||
'customers.view', 'customers.create',
|
||||
'workshop.view', 'workshop.edit', 'workshop.add_items',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel'],
|
||||
'counter': ['pos.remission', 'pos.view',
|
||||
'catalog.view',
|
||||
'inventory.view', 'inventory.create',
|
||||
'customers.view', 'customers.create'],
|
||||
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
||||
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
||||
'accountant': ['accounting.view', 'accounting.create',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||
'reports.view', 'reports.financial',
|
||||
'customers.view',
|
||||
'fleet.view'],
|
||||
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
|
||||
'mechanic': ['workshop.view'],
|
||||
'driver': ['pos.remission'],
|
||||
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
|
||||
'customers.view', 'customers.create'],
|
||||
}
|
||||
|
||||
|
||||
# Statuses used in the workshop kanban. Must stay in sync with workshop.js COLUMNS.
|
||||
_WORKSHOP_STATUSES = [
|
||||
'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar',
|
||||
'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada',
|
||||
'por_entregar', 'entregado', 'por_enviar', 'enviado',
|
||||
'por_facturar', 'facturada', 'por_recolectar', 'cancelada'
|
||||
]
|
||||
|
||||
# Workshop-specific actions configurable per role.
|
||||
_WORKSHOP_ACTIONS = [
|
||||
{'key': 'create_order', 'label': 'Crear órdenes'},
|
||||
{'key': 'edit_order', 'label': 'Editar órdenes'},
|
||||
{'key': 'delete_order', 'label': 'Eliminar órdenes'},
|
||||
{'key': 'assign_mechanic', 'label': 'Asignar mecánico'},
|
||||
{'key': 'add_items', 'label': 'Agregar artículos'},
|
||||
{'key': 'add_labor', 'label': 'Agregar mano de obra'},
|
||||
{'key': 'change_status', 'label': 'Cambiar estatus'},
|
||||
{'key': 'convert_to_sale', 'label': 'Convertir a venta'},
|
||||
{'key': 'convert_to_remission', 'label': 'Generar nota de remisión'},
|
||||
{'key': 'view_customer_data', 'label': 'Ver datos del cliente/vehículo'},
|
||||
{'key': 'view_prices', 'label': 'Ver precios/costos'},
|
||||
{'key': 'view_notes', 'label': 'Ver bitácora'},
|
||||
]
|
||||
|
||||
# Default workshop permissions per role. Admins see everything; restricted roles only work statuses.
|
||||
_DEFAULT_WORKSHOP_PERMISSIONS = {
|
||||
'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
|
||||
'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
|
||||
'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
|
||||
'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
|
||||
'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] not in ('delete_order', 'convert_to_sale')]},
|
||||
'workshop': {
|
||||
'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')],
|
||||
'actions': ['change_status', 'add_labor', 'view_notes']
|
||||
},
|
||||
'mechanic': {
|
||||
'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'],
|
||||
'actions': ['change_status', 'add_labor', 'view_notes']
|
||||
},
|
||||
}
|
||||
|
||||
_AVAILABLE_PERMISSIONS = [
|
||||
{'module': 'Dashboard', 'permissions': [
|
||||
{'key': 'dashboard.view', 'label': 'Ver Dashboard'},
|
||||
]},
|
||||
{'module': 'Punto de Venta', 'permissions': [
|
||||
{'key': 'pos.sell', 'label': 'Realizar ventas'},
|
||||
{'key': 'pos.view', 'label': 'Ver ventas/cotizaciones'},
|
||||
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
|
||||
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
|
||||
{'key': 'pos.remission', 'label': 'Notas de remisión'},
|
||||
{'key': 'pos.view_cost', 'label': 'Ver costos en POS'},
|
||||
]},
|
||||
{'module': 'Inventario', 'permissions': [
|
||||
{'key': 'inventory.view', 'label': 'Ver inventario'},
|
||||
{'key': 'inventory.create', 'label': 'Crear artículos'},
|
||||
{'key': 'inventory.edit', 'label': 'Editar artículos'},
|
||||
{'key': 'inventory.delete', 'label': 'Eliminar artículos'},
|
||||
{'key': 'inventory.adjust', 'label': 'Ajustar stock'},
|
||||
{'key': 'inventory.transfer', 'label': 'Transferir entre sucursales'},
|
||||
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
|
||||
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
|
||||
]},
|
||||
{'module': 'Yonke', 'permissions': [
|
||||
{'key': 'yonke.view', 'label': 'Ver Yonke (partes usadas)'},
|
||||
{'key': 'yonke.manage', 'label': 'Gestionar Yonke (piezas y consignaciones)'},
|
||||
]},
|
||||
{'module': 'Catálogo', 'permissions': [
|
||||
{'key': 'catalog.view', 'label': 'Ver catálogo'},
|
||||
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
|
||||
]},
|
||||
{'module': 'Clientes', 'permissions': [
|
||||
{'key': 'customers.view', 'label': 'Ver clientes'},
|
||||
{'key': 'customers.create', 'label': 'Crear clientes'},
|
||||
{'key': 'customers.edit', 'label': 'Editar clientes'},
|
||||
{'key': 'customers.delete', 'label': 'Eliminar clientes'},
|
||||
{'key': 'customers.edit_credit', 'label': 'Editar límite de crédito'},
|
||||
]},
|
||||
{'module': 'Taller', 'permissions': [
|
||||
{'key': 'workshop.view', 'label': 'Ver módulo Taller'},
|
||||
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes (deprecado, usar matriz Taller)'},
|
||||
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra (deprecado, usar matriz Taller)'},
|
||||
]},
|
||||
{'module': 'Facturación', 'permissions': [
|
||||
{'key': 'invoicing.view', 'label': 'Ver facturas'},
|
||||
{'key': 'invoicing.create', 'label': 'Crear facturas'},
|
||||
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
|
||||
]},
|
||||
{'module': 'Configuración', 'permissions': [
|
||||
{'key': 'config.view', 'label': 'Ver configuración'},
|
||||
{'key': 'config.edit', 'label': 'Editar configuración'},
|
||||
{'key': 'config.edit_prices', 'label': 'Modificar precios globales'},
|
||||
]},
|
||||
{'module': 'Contabilidad', 'permissions': [
|
||||
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
|
||||
{'key': 'accounting.create', 'label': 'Crear movimientos contables'},
|
||||
]},
|
||||
{'module': 'Reportes', 'permissions': [
|
||||
{'key': 'reports.view', 'label': 'Ver reportes'},
|
||||
{'key': 'reports.financial', 'label': 'Reportes financieros'},
|
||||
]},
|
||||
{'module': 'Flotillas', 'permissions': [
|
||||
{'key': 'fleet.view', 'label': 'Ver flotillas'},
|
||||
{'key': 'fleet.create', 'label': 'Crear flotillas'},
|
||||
{'key': 'fleet.edit', 'label': 'Editar flotillas'},
|
||||
{'key': 'fleet.delete', 'label': 'Eliminar flotillas'},
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
def _get_role_permissions(conn, role):
|
||||
"""Return configured permissions for a role, falling back to defaults."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'role_permissions'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if row and row[0]:
|
||||
try:
|
||||
configured = json.loads(row[0])
|
||||
if isinstance(configured, dict) and role in configured:
|
||||
return list(configured.get(role, []))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
|
||||
def _get_workshop_permissions(conn, role):
|
||||
"""Return configured workshop permissions for a role, falling back to defaults."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'workshop_permissions'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if row and row[0]:
|
||||
try:
|
||||
configured = json.loads(row[0])
|
||||
if isinstance(configured, dict) and role in configured:
|
||||
return configured[role]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return _DEFAULT_WORKSHOP_PERMISSIONS.get(role, {'statuses': [], 'actions': []})
|
||||
|
||||
|
||||
def _get_all_workshop_permissions(conn):
|
||||
"""Return effective workshop permissions for every known role."""
|
||||
result = {}
|
||||
for role in _DEFAULT_WORKSHOP_PERMISSIONS:
|
||||
result[role] = _get_workshop_permissions(conn, role)
|
||||
return result
|
||||
|
||||
|
||||
@config_bp.route('/branches', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_branches():
|
||||
@@ -340,58 +150,8 @@ def update_branch(branch_id):
|
||||
return jsonify({'ok': True, 'message': 'Branch updated'})
|
||||
|
||||
|
||||
@config_bp.route('/branches/<int:branch_id>', methods=['DELETE'])
|
||||
@require_auth('config.edit')
|
||||
def delete_branch(branch_id):
|
||||
"""Hard-delete a branch. Only owner/admin can delete; main branch cannot be deleted.
|
||||
|
||||
Related records keep their data but lose the branch reference; stock and count
|
||||
rows tied exclusively to the branch are removed.
|
||||
"""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
return jsonify({'error': 'Solo administradores pueden eliminar sucursales'}), 403
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT is_main FROM branches WHERE id = %s", (branch_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Branch not found'}), 404
|
||||
|
||||
if row[0]:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'No se puede eliminar la sucursal principal'}), 403
|
||||
|
||||
# Remove branch-specific stock and count rows first.
|
||||
cur.execute("DELETE FROM inventory_stock WHERE branch_id = %s", (branch_id,))
|
||||
cur.execute("DELETE FROM inventory_stock_summary WHERE branch_id = %s", (branch_id,))
|
||||
cur.execute("DELETE FROM physical_counts WHERE branch_id = %s", (branch_id,))
|
||||
|
||||
# Nullify every other FK reference back to branches.
|
||||
cur.execute("""
|
||||
SELECT c.relname::text AS tbl, a.attname::text AS col
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class c ON c.oid = con.conrelid
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
|
||||
WHERE con.confrelid = 'public.branches'::regclass
|
||||
AND con.contype = 'f'
|
||||
""")
|
||||
for tbl, col in cur.fetchall():
|
||||
if tbl in ('inventory_stock', 'inventory_stock_summary', 'physical_counts'):
|
||||
continue
|
||||
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (branch_id,))
|
||||
|
||||
cur.execute("DELETE FROM branches WHERE id = %s", (branch_id,))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'message': 'Sucursal eliminada'})
|
||||
|
||||
|
||||
@config_bp.route('/employees', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('config.view')
|
||||
def list_employees():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -440,7 +200,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', 'driver']
|
||||
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
|
||||
if data['role'] not in valid_roles:
|
||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||
|
||||
@@ -455,21 +215,31 @@ def create_employee():
|
||||
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
||||
emp_id = cur.fetchone()[0]
|
||||
|
||||
# Set default permissions by role (configurable per tenant)
|
||||
for perm in _get_role_permissions(conn, data['role']):
|
||||
# Set default permissions by role
|
||||
role_permissions = {
|
||||
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||
'catalog.view', 'catalog.edit',
|
||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||
'invoicing.view', 'invoicing.create',
|
||||
'reports.view', 'reports.financial',
|
||||
'config.view', 'config.edit', 'config.edit_prices'],
|
||||
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
|
||||
'catalog.view', 'customers.view', 'customers.create'],
|
||||
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
||||
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
||||
'accountant': ['accounting.view', 'accounting.create',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||
'reports.view', 'reports.financial',
|
||||
'customers.view'],
|
||||
}
|
||||
|
||||
for perm in role_permissions.get(data['role'], []):
|
||||
cur.execute(
|
||||
"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("""
|
||||
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']})
|
||||
@@ -480,105 +250,6 @@ def create_employee():
|
||||
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
||||
|
||||
|
||||
@config_bp.route('/role-permissions', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_role_permissions_config():
|
||||
"""Return the configured permissions for each role and the available permission list."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
configured = {}
|
||||
for role in _DEFAULT_ROLE_PERMISSIONS:
|
||||
configured[role] = _get_role_permissions(conn, role)
|
||||
return jsonify({
|
||||
'roles': configured,
|
||||
'available': _AVAILABLE_PERMISSIONS,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@config_bp.route('/role-permissions', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def save_role_permissions_config():
|
||||
"""Save the permission mapping per role. Only owner/admin can edit."""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
|
||||
data = request.get_json() or {}
|
||||
if 'roles' not in data:
|
||||
return jsonify({'error': 'roles object required'}), 400
|
||||
for role, perms in data['roles'].items():
|
||||
if role not in _DEFAULT_ROLE_PERMISSIONS:
|
||||
return jsonify({'error': f'Invalid role: {role}'}), 400
|
||||
if not isinstance(perms, list):
|
||||
return jsonify({'error': f'permissions for {role} must be a list'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('role_permissions', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (json.dumps(data['roles']),))
|
||||
|
||||
# Apply the new permissions to existing employees of each affected role
|
||||
for role, perms in data['roles'].items():
|
||||
cur.execute("DELETE FROM employee_permissions WHERE employee_id IN (SELECT id FROM employees WHERE role = %s)", (role,))
|
||||
cur.execute("SELECT id FROM employees WHERE role = %s", (role,))
|
||||
emp_ids = [r[0] for r in cur.fetchall()]
|
||||
for emp_id in emp_ids:
|
||||
for perm in perms:
|
||||
cur.execute(
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||
(emp_id, perm)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
|
||||
|
||||
|
||||
@config_bp.route('/role-permissions/workshop', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def get_workshop_permissions_config():
|
||||
"""Return the configured workshop permissions (statuses + actions) per role."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
return jsonify({
|
||||
'roles': _get_all_workshop_permissions(conn),
|
||||
'statuses': [{'key': s, 'label': s.replace('_', ' ').title()} for s in _WORKSHOP_STATUSES],
|
||||
'actions': _WORKSHOP_ACTIONS,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@config_bp.route('/role-permissions/workshop', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def save_workshop_permissions_config():
|
||||
"""Save the workshop permission mapping per role."""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
|
||||
data = request.get_json() or {}
|
||||
if 'roles' not in data:
|
||||
return jsonify({'error': 'roles object required'}), 400
|
||||
|
||||
for role, cfg in data['roles'].items():
|
||||
if role not in _DEFAULT_WORKSHOP_PERMISSIONS:
|
||||
return jsonify({'error': f'Invalid role: {role}'}), 400
|
||||
if not isinstance(cfg, dict):
|
||||
return jsonify({'error': f'config for {role} must be an object'}), 400
|
||||
if not isinstance(cfg.get('statuses', []), list) or not isinstance(cfg.get('actions', []), list):
|
||||
return jsonify({'error': f'statuses/actions for {role} must be lists'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (json.dumps(data['roles']),))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
|
||||
|
||||
|
||||
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_employee(emp_id):
|
||||
@@ -624,24 +295,6 @@ def update_employee(emp_id):
|
||||
params.append(emp_id)
|
||||
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
|
||||
|
||||
# If the role changed, re-sync permissions to match the new role defaults/config.
|
||||
if 'role' in data:
|
||||
cur.execute("DELETE FROM employee_permissions WHERE employee_id = %s", (emp_id,))
|
||||
for perm in _get_role_permissions(conn, data['role']):
|
||||
cur.execute(
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||
(emp_id, perm)
|
||||
)
|
||||
# Drivers also get a courier record so they can be assigned to deliveries
|
||||
if data['role'] == 'driver':
|
||||
cur.execute("SELECT name FROM employees WHERE id = %s", (emp_id,))
|
||||
emp_name = cur.fetchone()[0]
|
||||
cur.execute("""
|
||||
INSERT INTO couriers (tenant_id, name, code, is_active)
|
||||
VALUES (%s, %s, %s, true)
|
||||
ON CONFLICT (tenant_id, code) DO NOTHING
|
||||
""", (g.tenant_id, emp_name, f'emp_{emp_id}'))
|
||||
|
||||
from services.audit import log_action
|
||||
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
|
||||
new_value={k: v for k, v in data.items() if k != 'pin'})
|
||||
@@ -652,53 +305,6 @@ def update_employee(emp_id):
|
||||
return jsonify({'ok': True, 'message': 'Employee updated'})
|
||||
|
||||
|
||||
@config_bp.route('/employees/<int:emp_id>', methods=['DELETE'])
|
||||
@require_auth('config.edit')
|
||||
def delete_employee(emp_id):
|
||||
"""Hard-delete an employee. Foreign-key references are nulled and
|
||||
dependent rows (permissions/sessions) are removed. Owners cannot be
|
||||
deleted via UI to prevent locking out the tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT role FROM employees WHERE id = %s", (emp_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Employee not found'}), 404
|
||||
|
||||
if row[0] == 'owner':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'No se puede eliminar una cuenta de dueno'}), 403
|
||||
|
||||
# Resolve every foreign-key column that points back to employees.
|
||||
cur.execute("""
|
||||
SELECT c.relname::text AS tbl, a.attname::text AS col
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class c ON c.oid = con.conrelid
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
|
||||
WHERE con.confrelid = 'public.employees'::regclass
|
||||
AND con.contype = 'f'
|
||||
""")
|
||||
refs = cur.fetchall()
|
||||
|
||||
for tbl, col in refs:
|
||||
if tbl in ('employee_permissions', 'employee_sessions', 'notification_preferences'):
|
||||
cur.execute(f"DELETE FROM \"{tbl}\" WHERE \"{col}\" = %s", (emp_id,))
|
||||
else:
|
||||
cur.execute(f"UPDATE \"{tbl}\" SET \"{col}\" = NULL WHERE \"{col}\" = %s", (emp_id,))
|
||||
|
||||
cur.execute("DELETE FROM employees WHERE id = %s", (emp_id,))
|
||||
|
||||
from services.audit import log_action
|
||||
log_action(conn, 'EMPLOYEE_DELETE', 'employee', emp_id)
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'message': 'Empleado eliminado'})
|
||||
|
||||
|
||||
@config_bp.route('/currency', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_currency():
|
||||
@@ -783,14 +389,7 @@ def get_business():
|
||||
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
|
||||
'rfc': cfg.get('tenant_rfc', ''),
|
||||
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
|
||||
'cp': cfg.get('tenant_cp', ''),
|
||||
'direccion': cfg.get('tenant_direccion', ''),
|
||||
'numero_exterior': cfg.get('tenant_numero_exterior', ''),
|
||||
'numero_interior': cfg.get('tenant_numero_interior', ''),
|
||||
'colonia': cfg.get('tenant_colonia', ''),
|
||||
'ciudad': cfg.get('tenant_ciudad', ''),
|
||||
'municipio': cfg.get('tenant_municipio', ''),
|
||||
'estado': cfg.get('tenant_estado', ''),
|
||||
'telefono': cfg.get('tenant_telefono', ''),
|
||||
'email': cfg.get('tenant_email', ''),
|
||||
})
|
||||
@@ -806,22 +405,14 @@ def update_business():
|
||||
'nombre': 'tenant_nombre',
|
||||
'rfc': 'tenant_rfc',
|
||||
'regimen_fiscal': 'tenant_regimen_fiscal',
|
||||
'cp': 'tenant_cp',
|
||||
'direccion': 'tenant_direccion',
|
||||
'numero_exterior': 'tenant_numero_exterior',
|
||||
'numero_interior': 'tenant_numero_interior',
|
||||
'colonia': 'tenant_colonia',
|
||||
'ciudad': 'tenant_ciudad',
|
||||
'municipio': 'tenant_municipio',
|
||||
'estado': 'tenant_estado',
|
||||
'telefono': 'tenant_telefono',
|
||||
'email': 'tenant_email',
|
||||
# Tax params (also keep cfdi_* aliases in sync)
|
||||
# Tax params
|
||||
'tax_iva': 'tax_iva',
|
||||
'tax_ieps': 'tax_ieps',
|
||||
'invoice_serie': 'invoice_serie',
|
||||
'invoice_folio': 'invoice_folio',
|
||||
'cfdi_serie': 'cfdi_serie',
|
||||
'default_currency': 'default_currency',
|
||||
'default_payment_method': 'default_payment_method',
|
||||
}
|
||||
@@ -900,15 +491,16 @@ def get_billing():
|
||||
@config_bp.route('/billing/upgrade', methods=['POST'])
|
||||
@require_auth('config.edit')
|
||||
def upgrade_billing():
|
||||
"""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
|
||||
"""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)
|
||||
|
||||
|
||||
# ─── Vehicle Compatibility Source ────────────────────
|
||||
@@ -1023,49 +615,6 @@ 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():
|
||||
@@ -1082,14 +631,6 @@ 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', ''),
|
||||
})
|
||||
|
||||
|
||||
@@ -1106,18 +647,7 @@ 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("""
|
||||
@@ -1132,151 +662,25 @@ 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()
|
||||
@require_auth('config.view')
|
||||
def get_modules():
|
||||
"""Get enabled modules for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%' OR key LIKE '%enabled'")
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
|
||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
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'
|
||||
def _bool(key):
|
||||
return rows.get(key, 'true').lower() == 'true'
|
||||
|
||||
return jsonify({
|
||||
'whatsapp': _bool('whatsapp'),
|
||||
'marketplace': _bool('marketplace'),
|
||||
'meli': _bool('meli'),
|
||||
'catalog': _bool('catalog'),
|
||||
'workshop': _bool('workshop'),
|
||||
'yonke': _bool('yonke'),
|
||||
'whatsapp': _bool('module_whatsapp'),
|
||||
'marketplace': _bool('module_marketplace'),
|
||||
'meli': _bool('module_meli'),
|
||||
'catalog': _bool('module_catalog'),
|
||||
})
|
||||
|
||||
|
||||
@@ -1288,14 +692,12 @@ def update_modules():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 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
|
||||
settings = {
|
||||
'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
|
||||
'module_marketplace': 'true' if data.get('marketplace') else 'false',
|
||||
'module_meli': 'true' if data.get('meli') else 'false',
|
||||
'module_catalog': 'true' if data.get('catalog') else 'false',
|
||||
}
|
||||
|
||||
for key, value in settings.items():
|
||||
cur.execute("""
|
||||
@@ -1311,78 +713,9 @@ def update_modules():
|
||||
'whatsapp': data.get('whatsapp'),
|
||||
'marketplace': data.get('marketplace'),
|
||||
'meli': data.get('meli'),
|
||||
'catalog': data.get('catalog'),
|
||||
'workshop': data.get('workshop'),
|
||||
}})
|
||||
|
||||
|
||||
@config_bp.route('/counter-remission', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_counter_remission_config():
|
||||
"""Get counter remission note feature flag for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'counter_remission_enabled'")
|
||||
row = cur.fetchone()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'enabled': str(row[0]).lower() == 'true' if row else False})
|
||||
|
||||
|
||||
@config_bp.route('/counter-remission', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_counter_remission_config():
|
||||
"""Enable/disable counter remission notes for this tenant."""
|
||||
data = request.get_json() or {}
|
||||
enabled = 'true' if data.get('enabled') else 'false'
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", ('counter_remission_enabled', enabled))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'enabled': enabled == 'true'})
|
||||
|
||||
|
||||
@config_bp.route('/sales-settings', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_sales_settings():
|
||||
"""Get sales-related settings (zero-price sales, negative stock)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('allow_zero_price_sales', 'allow_negative_stock')")
|
||||
rows = {k: v for k, v in cur.fetchall()}
|
||||
cur.close(); conn.close()
|
||||
# Default allow_zero_price_sales to true to avoid breaking existing tenants.
|
||||
allow = str(rows.get('allow_zero_price_sales', 'true')).lower() in ('true', '1', 'yes')
|
||||
# Default allow_negative_stock to false (safer).
|
||||
neg = str(rows.get('allow_negative_stock', 'false')).lower() in ('true', '1', 'yes')
|
||||
return jsonify({'allow_zero_price_sales': allow, 'allow_negative_stock': neg})
|
||||
|
||||
|
||||
@config_bp.route('/sales-settings', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_sales_settings():
|
||||
"""Update sales-related settings."""
|
||||
data = request.get_json() or {}
|
||||
allow = 'true' if data.get('allow_zero_price_sales') else 'false'
|
||||
neg = 'true' if data.get('allow_negative_stock') else 'false'
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", ('allow_zero_price_sales', allow))
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", ('allow_negative_stock', neg))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'allow_zero_price_sales': allow == 'true', 'allow_negative_stock': neg == 'true'})
|
||||
|
||||
|
||||
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_onboarding_status():
|
||||
@@ -1412,104 +745,3 @@ def set_onboarding_status():
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'completed': completed == 'true'})
|
||||
|
||||
|
||||
# ─── Receipt / Ticket Customization ────────────────────────────────────────
|
||||
|
||||
RECEIPT_CONFIG_KEYS = [
|
||||
'receipt_logo',
|
||||
'receipt_store_name',
|
||||
'receipt_tagline',
|
||||
'receipt_rfc',
|
||||
'receipt_address',
|
||||
'receipt_phone',
|
||||
'receipt_footer',
|
||||
'receipt_thanks_message',
|
||||
'receipt_show_logo',
|
||||
'receipt_show_rfc',
|
||||
'receipt_show_address',
|
||||
'receipt_show_phone',
|
||||
'receipt_show_iva_breakdown',
|
||||
'receipt_show_payment_details',
|
||||
'receipt_show_employee',
|
||||
'receipt_paper_width',
|
||||
'receipt_auto_print',
|
||||
]
|
||||
|
||||
|
||||
@config_bp.route('/receipt', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_receipt_config():
|
||||
"""Get receipt customization settings."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
|
||||
(RECEIPT_CONFIG_KEYS,)
|
||||
)
|
||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def _bool(key, default=False):
|
||||
v = rows.get(key, 'true' if default else 'false')
|
||||
return str(v).lower() == 'true'
|
||||
|
||||
return jsonify({
|
||||
'logo': rows.get('receipt_logo', ''),
|
||||
'store_name': rows.get('receipt_store_name', ''),
|
||||
'tagline': rows.get('receipt_tagline', ''),
|
||||
'rfc': rows.get('receipt_rfc', ''),
|
||||
'address': rows.get('receipt_address', ''),
|
||||
'phone': rows.get('receipt_phone', ''),
|
||||
'footer': rows.get('receipt_footer', ''),
|
||||
'thanks_message': rows.get('receipt_thanks_message', 'Gracias por su compra!'),
|
||||
'show_logo': _bool('receipt_show_logo', True),
|
||||
'show_rfc': _bool('receipt_show_rfc', True),
|
||||
'show_address': _bool('receipt_show_address', False),
|
||||
'show_phone': _bool('receipt_show_phone', False),
|
||||
'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True),
|
||||
'show_payment_details': _bool('receipt_show_payment_details', True),
|
||||
'auto_print': _bool('receipt_auto_print', False),
|
||||
'show_employee': _bool('receipt_show_employee', False),
|
||||
'paper_width': rows.get('receipt_paper_width', '80') or '80',
|
||||
})
|
||||
|
||||
|
||||
@config_bp.route('/receipt', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_receipt_config():
|
||||
"""Update receipt customization settings."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
settings = {
|
||||
'receipt_logo': data.get('logo', ''),
|
||||
'receipt_store_name': data.get('store_name', ''),
|
||||
'receipt_tagline': data.get('tagline', ''),
|
||||
'receipt_rfc': data.get('rfc', ''),
|
||||
'receipt_address': data.get('address', ''),
|
||||
'receipt_phone': data.get('phone', ''),
|
||||
'receipt_footer': data.get('footer', ''),
|
||||
'receipt_thanks_message': data.get('thanks_message', 'Gracias por su compra!'),
|
||||
'receipt_show_logo': 'true' if data.get('show_logo') else 'false',
|
||||
'receipt_show_rfc': 'true' if data.get('show_rfc') else 'false',
|
||||
'receipt_show_address': 'true' if data.get('show_address') else 'false',
|
||||
'receipt_show_phone': 'true' if data.get('show_phone') else 'false',
|
||||
'receipt_show_iva_breakdown': 'true' if data.get('show_iva_breakdown') else 'false',
|
||||
'receipt_show_payment_details': 'true' if data.get('show_payment_details') else 'false',
|
||||
'receipt_show_employee': 'true' if data.get('show_employee') else 'false',
|
||||
'receipt_paper_width': str(data.get('paper_width', '80') or '80'),
|
||||
}
|
||||
|
||||
for key, value in settings.items():
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (key, value))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Receipt configuration updated'})
|
||||
|
||||
@@ -2,31 +2,18 @@
|
||||
"""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')
|
||||
|
||||
|
||||
def _can_view_customers():
|
||||
"""Cashiers, counter and workshop employees need customer access for POS/service flows."""
|
||||
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
|
||||
|
||||
|
||||
def _can_create_customer():
|
||||
"""Cashiers, counter and workshop employees can create customers on the fly."""
|
||||
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
|
||||
|
||||
|
||||
# ─── Customer CRUD ─────────────────────────────
|
||||
|
||||
@customers_bp.route('', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('customers.view')
|
||||
def list_customers():
|
||||
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
||||
|
||||
@@ -36,8 +23,6 @@ def list_customers():
|
||||
per_page: items per page (default 50, max 200)
|
||||
branch_id: filter by branch (default: current user's branch)
|
||||
"""
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -45,24 +30,10 @@ def list_customers():
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
search = request.args.get('q', '').strip()
|
||||
branch_id = request.args.get('branch_id')
|
||||
price_tier = request.args.get('price_tier', '').strip()
|
||||
status = request.args.get('status', '').strip().lower()
|
||||
|
||||
where_clauses = []
|
||||
where_clauses = ["c.is_active = true"]
|
||||
params = []
|
||||
|
||||
if status == 'inactive':
|
||||
where_clauses.append("c.is_active = false")
|
||||
elif status == 'overdue':
|
||||
where_clauses.append(
|
||||
"c.is_active = true AND c.credit_limit > 0 AND c.credit_balance > c.credit_limit"
|
||||
)
|
||||
elif status == 'all':
|
||||
pass # no is_active filter
|
||||
else:
|
||||
# Default to active customers for backwards compatibility
|
||||
where_clauses.append("c.is_active = true")
|
||||
|
||||
if branch_id:
|
||||
where_clauses.append("c.branch_id = %s")
|
||||
params.append(int(branch_id))
|
||||
@@ -71,20 +42,8 @@ def list_customers():
|
||||
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
|
||||
)
|
||||
params.extend([f'%{search}%'] * 4)
|
||||
if price_tier:
|
||||
# Support numeric tier or Spanish labels
|
||||
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
|
||||
tier_val = tier_map.get(price_tier.lower())
|
||||
if tier_val is None:
|
||||
try:
|
||||
tier_val = int(price_tier)
|
||||
except ValueError:
|
||||
tier_val = None
|
||||
if tier_val in (1, 2, 3):
|
||||
where_clauses.append("c.price_tier = %s")
|
||||
params.append(tier_val)
|
||||
|
||||
where = " AND ".join(where_clauses) if where_clauses else "true"
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
# Count
|
||||
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
|
||||
@@ -95,8 +54,7 @@ def list_customers():
|
||||
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
||||
c.address, c.cp,
|
||||
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
||||
c.branch_id, c.is_active, c.created_at,
|
||||
(SELECT MAX(s.created_at) FROM sales s WHERE s.customer_id = c.id) AS last_purchase
|
||||
c.branch_id
|
||||
FROM customers c
|
||||
WHERE {where}
|
||||
ORDER BY c.name
|
||||
@@ -113,9 +71,6 @@ def list_customers():
|
||||
'credit_balance': float(r[10]) if r[10] else 0,
|
||||
'vehicle_info': r[11],
|
||||
'branch_id': r[12],
|
||||
'is_active': r[13],
|
||||
'created_at': str(r[14]) if r[14] else None,
|
||||
'last_purchase': str(r[15]) if r[15] else None,
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -129,10 +84,8 @@ def list_customers():
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('customers.view')
|
||||
def get_customer(customer_id):
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
"""Get customer details with credit info, vehicle history, and recent purchases."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -158,14 +111,6 @@ def get_customer(customer_id):
|
||||
|
||||
customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None
|
||||
|
||||
# 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,
|
||||
@@ -190,93 +135,19 @@ def get_customer(customer_id):
|
||||
float(customer['credit_limit']) - float(customer['credit_balance']), 2
|
||||
)
|
||||
|
||||
# Fleet vehicles assigned to this customer
|
||||
cur.execute("""
|
||||
SELECT id, plate, vin, make, model, year, current_mileage, color, owner_name, is_active, created_at
|
||||
FROM fleet_vehicles
|
||||
WHERE customer_id = %s
|
||||
ORDER BY is_active DESC, created_at DESC
|
||||
""", (customer_id,))
|
||||
customer['fleet_vehicles'] = []
|
||||
for r in cur.fetchall():
|
||||
customer['fleet_vehicles'].append({
|
||||
'id': r[0], 'plate': r[1], 'vin': r[2], 'make': r[3], 'model': r[4],
|
||||
'year': r[5], 'current_mileage': r[6], 'color': r[7], 'owner_name': r[8],
|
||||
'is_active': r[9], 'created_at': str(r[10]) if r[10] else None,
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify(customer)
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>/credit-score', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_customer_credit_score(customer_id):
|
||||
"""Credit risk score for a customer (A9).
|
||||
|
||||
Returns {score, tier, recommended_limit, reasons, data_points, ...}.
|
||||
"""
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
from services.credit_scoring import score_customer
|
||||
return jsonify(score_customer(conn, customer_id))
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 404
|
||||
except Exception as e:
|
||||
logger.error('credit-score for customer %s failed: %s', customer_id, e)
|
||||
return jsonify({'error': 'Could not compute credit score'}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>/purchases', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_customer_purchases(customer_id):
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
"""Return full purchase history for a customer."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
|
||||
e.name as employee_name
|
||||
FROM sales s
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
WHERE s.customer_id = %s
|
||||
ORDER BY s.created_at DESC
|
||||
""", (customer_id,))
|
||||
|
||||
purchases = []
|
||||
for r in cur.fetchall():
|
||||
purchases.append({
|
||||
'id': r[0],
|
||||
'total': float(r[1]) if r[1] else 0,
|
||||
'payment_method': r[2],
|
||||
'sale_type': r[3],
|
||||
'status': r[4],
|
||||
'created_at': str(r[5]),
|
||||
'employee_name': r[6],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'data': purchases})
|
||||
|
||||
|
||||
@customers_bp.route('', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_auth('customers.create')
|
||||
def create_customer():
|
||||
"""Create a new customer.
|
||||
|
||||
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
||||
phone, address, price_tier, credit_limit, vehicle_info}
|
||||
"""
|
||||
if not _can_create_customer():
|
||||
return jsonify({'error': 'Missing permissions: customers.create'}), 403
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name'):
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
@@ -369,57 +240,9 @@ def update_customer(customer_id):
|
||||
return jsonify({'message': 'Customer updated'})
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>', methods=['DELETE'])
|
||||
@require_auth('customers.delete')
|
||||
def delete_customer(customer_id):
|
||||
"""Hard-delete a customer.
|
||||
|
||||
Related records (sales, service orders, vehicles, etc.) keep their data but
|
||||
lose the customer reference. Layaways for this customer are removed because
|
||||
they require a customer_id.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT id FROM customers WHERE id = %s", (customer_id,))
|
||||
if not cur.fetchone():
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Customer not found'}), 404
|
||||
|
||||
# Remove layaway dependencies first, then the layaways themselves
|
||||
cur.execute("DELETE FROM layaway_items WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
|
||||
cur.execute("DELETE FROM layaway_payments WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
|
||||
cur.execute("DELETE FROM layaways WHERE customer_id = %s", (customer_id,))
|
||||
|
||||
# Nullify every other FK reference back to customers.
|
||||
cur.execute("""
|
||||
SELECT c.relname::text AS tbl, a.attname::text AS col
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class c ON c.oid = con.conrelid
|
||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
|
||||
WHERE con.confrelid = 'public.customers'::regclass
|
||||
AND con.contype = 'f'
|
||||
""")
|
||||
refs = cur.fetchall()
|
||||
for tbl, col in refs:
|
||||
# Cascade-delete tables and already-handled layaways are skipped.
|
||||
if tbl in ('customer_activities', 'customer_tag_assignments', 'loyalty_points', 'loyalty_redemptions', 'layaways'):
|
||||
continue
|
||||
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (customer_id,))
|
||||
|
||||
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
|
||||
log_action(conn, 'CUSTOMER_DELETE', 'customer', customer_id)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'message': 'Cliente eliminado'})
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('customers.view')
|
||||
def customer_statement(customer_id):
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
"""Account statement: sales (invoices), payments, running balance.
|
||||
|
||||
Query params:
|
||||
@@ -518,10 +341,8 @@ def customer_statement(customer_id):
|
||||
|
||||
|
||||
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth('customers.view')
|
||||
def customer_vehicles(customer_id):
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
"""Get customer's vehicle list with last purchases per vehicle.
|
||||
|
||||
Vehicle info is stored as JSONB in customers.vehicle_info:
|
||||
@@ -618,32 +439,6 @@ def record_customer_payment(customer_id):
|
||||
UPDATE customers SET credit_balance = %s WHERE id = %s
|
||||
""", (new_balance, customer_id))
|
||||
|
||||
# Allocate the customer payment to oldest unpaid credit sales so the
|
||||
# aging report and per-sale balances reflect the remaining debt.
|
||||
remaining = amount
|
||||
cur.execute("""
|
||||
SELECT s.id, s.total - COALESCE(SUM(sp.amount), 0) as balance
|
||||
FROM sales s
|
||||
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
|
||||
WHERE s.customer_id = %s
|
||||
AND s.sale_type = 'credit'
|
||||
AND s.status = 'completed'
|
||||
GROUP BY s.id, s.total, s.created_at
|
||||
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
|
||||
ORDER BY s.created_at
|
||||
""", (customer_id,))
|
||||
for sale_id, balance in cur.fetchall():
|
||||
if remaining <= 0:
|
||||
break
|
||||
pay = round(min(remaining, float(balance)), 2)
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""", (sale_id, register_id, payment_method, pay,
|
||||
f'Abono cliente #{customer_id}'))
|
||||
remaining = round(remaining - pay, 2)
|
||||
|
||||
# Record cash movement on register if cash payment
|
||||
if register_id and payment_method == 'efectivo':
|
||||
cur.execute("""
|
||||
@@ -657,24 +452,6 @@ 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({
|
||||
|
||||
@@ -118,70 +118,3 @@ def get_employee_stats():
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@dashboard_stats_bp.route('/credit-alerts', methods=['GET'])
|
||||
@require_auth()
|
||||
def credit_alerts():
|
||||
"""Credit sales that are overdue or due within the next 7 days."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT s.id,
|
||||
c.name as customer_name,
|
||||
s.total,
|
||||
s.created_at,
|
||||
s.created_at + INTERVAL '30 days' as due_date,
|
||||
COALESCE(SUM(sp.amount), 0) as paid
|
||||
FROM sales s
|
||||
JOIN customers c ON c.id = s.customer_id
|
||||
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
|
||||
WHERE s.sale_type = 'credit'
|
||||
AND s.status = 'completed'
|
||||
GROUP BY s.id, c.name, s.total, s.created_at
|
||||
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
|
||||
AND s.created_at + INTERVAL '30 days' <= NOW() + INTERVAL '30 days'
|
||||
ORDER BY due_date
|
||||
LIMIT 50
|
||||
""")
|
||||
|
||||
rows = []
|
||||
now = datetime.utcnow()
|
||||
for r in cur.fetchall():
|
||||
sale_id = r[0]
|
||||
customer_name = r[1]
|
||||
total = float(r[2]) if r[2] else 0
|
||||
paid = float(r[5]) if r[5] else 0
|
||||
balance = round(total - paid, 2)
|
||||
created_at = r[3]
|
||||
due_date = r[4]
|
||||
days_until_due = (due_date.replace(tzinfo=None) - now).days if due_date else None
|
||||
|
||||
if days_until_due is None:
|
||||
continue
|
||||
status = 'overdue' if days_until_due < 0 else ('due_soon' if days_until_due <= 7 else 'current')
|
||||
label = 'Vencida' if status == 'overdue' else ('Por vencer' if status == 'due_soon' else 'Al corriente')
|
||||
|
||||
rows.append({
|
||||
'sale_id': sale_id,
|
||||
'folio': f'VTA-{sale_id}',
|
||||
'customer_name': customer_name,
|
||||
'issue_date': created_at.isoformat() if created_at else None,
|
||||
'due_date': due_date.isoformat() if due_date else None,
|
||||
'days_until_due': days_until_due,
|
||||
'total': total,
|
||||
'paid': paid,
|
||||
'balance': balance,
|
||||
'status': status,
|
||||
'status_label': label,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'data': rows,
|
||||
'overdue_count': sum(1 for r in rows if r['status'] == 'overdue'),
|
||||
'due_soon_count': sum(1 for r in rows if r['status'] == 'due_soon'),
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@@ -13,7 +13,7 @@ fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet')
|
||||
# ─── Vehicles CRUD ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/vehicles', methods=['GET'])
|
||||
@require_auth('fleet.view')
|
||||
@require_auth()
|
||||
def list_vehicles():
|
||||
"""List fleet vehicles with pagination and search.
|
||||
|
||||
@@ -83,7 +83,7 @@ def list_vehicles():
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
|
||||
@require_auth('fleet.view')
|
||||
@require_auth()
|
||||
def get_vehicle(vehicle_id):
|
||||
"""Vehicle detail with maintenance schedules and recent logs."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -155,128 +155,16 @@ def get_vehicle(vehicle_id):
|
||||
return jsonify(vehicle)
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/schedules', methods=['GET'])
|
||||
@require_auth('fleet.view')
|
||||
def list_all_schedules():
|
||||
"""Return all active maintenance schedules joined with vehicle data.
|
||||
|
||||
Replaces the N+1 pattern of fetching schedules per vehicle.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
where_clauses = ["v.is_active = true", "s.is_active = true"]
|
||||
params = []
|
||||
if g.branch_id:
|
||||
where_clauses.append("v.branch_id = %s")
|
||||
params.append(g.branch_id)
|
||||
|
||||
vehicle_ids = request.args.get('vehicle_ids', '').strip()
|
||||
if vehicle_ids:
|
||||
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
|
||||
if ids:
|
||||
where_clauses.append("v.id = ANY(%s)")
|
||||
params.append(ids)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT s.id, s.vehicle_id, s.maintenance_type, s.interval_km,
|
||||
s.interval_months, s.last_done_at, s.last_done_km,
|
||||
s.next_due_at, s.next_due_km, s.notes,
|
||||
v.plate, v.make, v.model, v.current_mileage, v.color
|
||||
FROM fleet_maintenance_schedules s
|
||||
JOIN fleet_vehicles v ON v.id = s.vehicle_id
|
||||
WHERE {where}
|
||||
ORDER BY v.plate, s.next_due_at NULLS LAST, s.next_due_km NULLS LAST
|
||||
""", params)
|
||||
|
||||
schedules = []
|
||||
for r in cur.fetchall():
|
||||
schedules.append({
|
||||
'id': r[0], 'vehicle_id': r[1], 'maintenance_type': r[2],
|
||||
'interval_km': r[3], 'interval_months': r[4],
|
||||
'last_done_at': str(r[5]) if r[5] else None, 'last_done_km': r[6],
|
||||
'next_due_at': str(r[7]) if r[7] else None, 'next_due_km': r[8],
|
||||
'notes': r[9],
|
||||
'vehicle': {
|
||||
'plate': r[10], 'make': r[11], 'model': r[12],
|
||||
'current_mileage': r[13], 'color': r[14]
|
||||
}
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': schedules})
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/history', methods=['GET'])
|
||||
@require_auth('fleet.view')
|
||||
def list_all_history():
|
||||
"""Return recent maintenance logs for all vehicles joined with vehicle data.
|
||||
|
||||
Replaces the N+1 pattern of fetching logs per vehicle.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
limit = min(int(request.args.get('limit', 200)), 500)
|
||||
|
||||
where_clauses = ["v.is_active = true"]
|
||||
params = [limit]
|
||||
if g.branch_id:
|
||||
where_clauses.append("v.branch_id = %s")
|
||||
params.append(g.branch_id)
|
||||
|
||||
vehicle_ids = request.args.get('vehicle_ids', '').strip()
|
||||
if vehicle_ids:
|
||||
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
|
||||
if ids:
|
||||
where_clauses.append("v.id = ANY(%s)")
|
||||
params.append(ids)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT l.id, l.vehicle_id, l.schedule_id, l.maintenance_type,
|
||||
l.mileage_at, l.cost, l.parts_used, l.notes, l.created_at,
|
||||
e.name as employee_name,
|
||||
v.plate, v.make, v.model, v.color
|
||||
FROM fleet_maintenance_logs l
|
||||
JOIN fleet_vehicles v ON v.id = l.vehicle_id
|
||||
LEFT JOIN employees e ON l.employee_id = e.id
|
||||
WHERE {where}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT %s
|
||||
""", params)
|
||||
|
||||
logs = []
|
||||
for r in cur.fetchall():
|
||||
logs.append({
|
||||
'id': r[0], 'vehicle_id': r[1], 'schedule_id': r[2],
|
||||
'maintenance_type': r[3], 'mileage_at': r[4],
|
||||
'cost': float(r[5]) if r[5] else 0, 'parts_used': r[6],
|
||||
'notes': r[7], 'created_at': str(r[8]) if r[8] else None,
|
||||
'employee_name': r[9],
|
||||
'vehicle': {'plate': r[10], 'make': r[11], 'model': r[12], 'color': r[13]}
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': logs})
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles', methods=['POST'])
|
||||
@require_auth('fleet.create')
|
||||
@require_auth()
|
||||
def create_vehicle():
|
||||
"""Create a fleet vehicle.
|
||||
|
||||
Body: {customer_id, plate, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
|
||||
Brand and model are required; plate is optional; VIN is not used.
|
||||
Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('make') or not data.get('model'):
|
||||
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
|
||||
if not data.get('customer_id'):
|
||||
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
|
||||
if not data.get('plate') and not data.get('vin'):
|
||||
return jsonify({'error': 'plate or vin is required'}), 400
|
||||
|
||||
branch_id = data.get('branch_id', g.branch_id)
|
||||
|
||||
@@ -284,23 +172,17 @@ def create_vehicle():
|
||||
cur = conn.cursor()
|
||||
|
||||
try:
|
||||
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
|
||||
cust = cur.fetchone()
|
||||
if not cust:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Cliente no encontrado'}), 404
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO fleet_vehicles
|
||||
(branch_id, customer_id, plate, make, model, year,
|
||||
(branch_id, plate, vin, make, model, year,
|
||||
current_mileage, fuel_type, color, owner_name, notes)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id
|
||||
""", (
|
||||
branch_id, data['customer_id'], data.get('plate'),
|
||||
branch_id, data.get('plate'), data.get('vin'),
|
||||
data.get('make'), data.get('model'), data.get('year'),
|
||||
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
||||
data.get('color'), data.get('owner_name') or cust[0], data.get('notes'),
|
||||
data.get('color'), data.get('owner_name'), data.get('notes'),
|
||||
))
|
||||
vehicle_id = cur.fetchone()[0]
|
||||
|
||||
@@ -319,7 +201,7 @@ def create_vehicle():
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
|
||||
@require_auth('fleet.edit')
|
||||
@require_auth()
|
||||
def update_vehicle(vehicle_id):
|
||||
"""Update vehicle fields including mileage.
|
||||
|
||||
@@ -363,7 +245,7 @@ def update_vehicle(vehicle_id):
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
|
||||
@require_auth('fleet.delete')
|
||||
@require_auth()
|
||||
def deactivate_vehicle(vehicle_id):
|
||||
"""Soft-delete: set is_active = false."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -384,7 +266,7 @@ def deactivate_vehicle(vehicle_id):
|
||||
# ─── Maintenance Schedules ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
|
||||
@require_auth('fleet.view')
|
||||
@require_auth()
|
||||
def list_schedules(vehicle_id):
|
||||
"""Maintenance schedules for a vehicle."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -417,7 +299,7 @@ def list_schedules(vehicle_id):
|
||||
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
|
||||
@require_auth('fleet.create')
|
||||
@require_auth()
|
||||
def create_schedule(vehicle_id):
|
||||
"""Create maintenance schedule for a vehicle.
|
||||
|
||||
@@ -462,7 +344,7 @@ def create_schedule(vehicle_id):
|
||||
# ─── Maintenance Logs ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
|
||||
@require_auth('fleet.create')
|
||||
@require_auth()
|
||||
def record_maintenance(vehicle_id):
|
||||
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
|
||||
|
||||
@@ -545,7 +427,7 @@ def record_maintenance(vehicle_id):
|
||||
# ─── Alerts ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/alerts', methods=['GET'])
|
||||
@require_auth('fleet.view')
|
||||
@require_auth()
|
||||
def fleet_alerts():
|
||||
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -590,7 +472,7 @@ def fleet_alerts():
|
||||
# ─── Stats ─────────────────────────────
|
||||
|
||||
@fleet_bp.route('/stats', methods=['GET'])
|
||||
@require_auth('fleet.view')
|
||||
@require_auth()
|
||||
def fleet_stats():
|
||||
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
@@ -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, get_tenant_conn_by_dbname
|
||||
from tenant_db import get_master_conn, get_tenant_conn
|
||||
|
||||
internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal')
|
||||
|
||||
|
||||
@@ -6,13 +6,9 @@ import json
|
||||
import os
|
||||
import csv
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from 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,
|
||||
@@ -69,275 +65,14 @@ 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', 'POST'])
|
||||
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
|
||||
@require_auth('inventory.create')
|
||||
def classify_part_endpoint(part_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.
|
||||
"""
|
||||
"""Ask AI to identify a part by its OEM number."""
|
||||
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)
|
||||
|
||||
|
||||
@@ -368,13 +103,8 @@ def list_items():
|
||||
# branch_id no longer filters inventory rows (shared catalog).
|
||||
# It is used only to show per-branch stock.
|
||||
if search:
|
||||
# Search also matches alternate / alias SKUs.
|
||||
where_clauses.append(
|
||||
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s "
|
||||
"OR EXISTS (SELECT 1 FROM inventory_sku_aliases a "
|
||||
"WHERE a.inventory_id = i.id AND a.is_active = true AND a.sku ILIKE %s))"
|
||||
)
|
||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
|
||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||
if category:
|
||||
where_clauses.append("i.category_id = %s")
|
||||
params.append(int(category))
|
||||
@@ -679,119 +409,6 @@ 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():
|
||||
@@ -800,15 +417,11 @@ 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: '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.
|
||||
X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN;
|
||||
'skip' ignores missing compat; 'reject' requires all compat.
|
||||
Expected CSV columns (case-insensitive):
|
||||
sku/part_number, name, brand, price, stock, cost,
|
||||
sku_secondary, description, category, make, model, year, engine, engine_code
|
||||
location, description, category, make, model, year, engine, engine_code
|
||||
Optional compat columns: make, model, year, engine, engine_code
|
||||
"""
|
||||
from services.qwen_fitment import get_vehicle_fitment
|
||||
@@ -816,8 +429,7 @@ 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', 'skip').lower()
|
||||
stock_mode = request.headers.get('X-Import-Stock', 'add').lower()
|
||||
strategy = request.headers.get('X-Import-Strategy', 'qwen').lower()
|
||||
errors = []
|
||||
warnings = []
|
||||
created_ids = []
|
||||
@@ -833,17 +445,8 @@ def bulk_import_items():
|
||||
try:
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext == '.csv':
|
||||
raw = file.stream.read()
|
||||
decoded = None
|
||||
for enc in ('utf-8-sig', 'cp1252', 'latin-1'):
|
||||
try:
|
||||
decoded = raw.decode(enc)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if decoded is None:
|
||||
return jsonify({'error': 'Unable to detect CSV encoding. Please save the file as UTF-8.'}), 400
|
||||
reader = csv.DictReader(io.StringIO(decoded))
|
||||
stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
|
||||
reader = csv.DictReader(stream)
|
||||
rows = list(reader)
|
||||
elif ext in ('.xls', '.xlsx', '.xlsm'):
|
||||
try:
|
||||
@@ -887,7 +490,6 @@ def bulk_import_items():
|
||||
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
||||
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
||||
'ubicacion': 'location', 'categoria': 'category',
|
||||
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
|
||||
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
||||
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
||||
'motor': 'engine', 'codigo_motor': 'engine_code',
|
||||
@@ -918,10 +520,6 @@ def bulk_import_items():
|
||||
db_name = db_name_row[0] if db_name_row else None
|
||||
mcur.close(); mconn.close()
|
||||
|
||||
# Pre-fetch category name -> id mapping
|
||||
cur.execute("SELECT id, name FROM categories")
|
||||
category_map = {str(r[1]).strip().lower(): r[0] for r in cur.fetchall()}
|
||||
|
||||
for row_num, row in enumerate(rows, start=1):
|
||||
part_number = str(row.get('part_number', '')).strip()
|
||||
name = str(row.get('name', '')).strip()
|
||||
@@ -950,10 +548,7 @@ def bulk_import_items():
|
||||
cost = _to_decimal(row.get('cost'), 0)
|
||||
location = str(row.get('location', '')).strip()
|
||||
description = str(row.get('description', '')).strip()
|
||||
category_name = str(row.get('category', '')).strip()
|
||||
category_id = category_map.get(category_name.lower()) if category_name else None
|
||||
if category_name and category_id is None:
|
||||
warnings.append(f'Row {row_num}: categoria "{category_name}" no encontrada')
|
||||
category = str(row.get('category', '')).strip()
|
||||
|
||||
# Check if item already exists (catalog is shared across branches)
|
||||
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
|
||||
@@ -971,14 +566,14 @@ def bulk_import_items():
|
||||
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
|
||||
location = COALESCE(NULLIF(%s,''), location),
|
||||
description = COALESCE(NULLIF(%s,''), description),
|
||||
category_id = COALESCE(%s, category_id)
|
||||
category = COALESCE(NULLIF(%s,''), category)
|
||||
WHERE id = %s
|
||||
""",
|
||||
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id)
|
||||
(name, brand, cost, cost, price_1, price_1, location, description, category, item_id)
|
||||
)
|
||||
was_inserted = False
|
||||
# Record stock adjustment for existing item if stock > 0 (unless skipped)
|
||||
if stock > 0 and stock_mode != 'skip':
|
||||
# Record stock adjustment for existing item if stock > 0
|
||||
if stock > 0:
|
||||
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
||||
else:
|
||||
# Generate barcode for new item
|
||||
@@ -987,11 +582,11 @@ def bulk_import_items():
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory
|
||||
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, unit)
|
||||
(part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, 'PZA')
|
||||
(part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
|
||||
)
|
||||
item_id = cur.fetchone()[0]
|
||||
was_inserted = True
|
||||
@@ -1004,20 +599,7 @@ def bulk_import_items():
|
||||
created_ids.append(item_id)
|
||||
created += 1
|
||||
|
||||
# ---------- 2. Secondary SKU alias ----------
|
||||
sku_secondary = str(row.get('sku_secondary', '')).strip()
|
||||
if sku_secondary:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(item_id, sku_secondary, 'Secundario')
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# ---------- 3. Vehicle compatibility ----------
|
||||
# ---------- 2. Vehicle compatibility ----------
|
||||
make = str(row.get('make', '')).strip()
|
||||
model = str(row.get('model', '')).strip()
|
||||
year_str = str(row.get('year', '')).strip()
|
||||
@@ -1031,9 +613,24 @@ def bulk_import_items():
|
||||
year = _to_int(year_str, None)
|
||||
mye_id = None
|
||||
if make and model and year:
|
||||
# Resolver contra la BD maestra (no la del tenant).
|
||||
mye_id = _resolve_mye_master(make, model, year)
|
||||
if mye_id is None:
|
||||
# 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:
|
||||
warnings.append(
|
||||
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
|
||||
'saving as text-only compatibility.'
|
||||
@@ -1446,251 +1043,6 @@ 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():
|
||||
@@ -2919,22 +2271,6 @@ def list_inventory_subcategories(category_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/categories/all', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_all_inventory_categories():
|
||||
"""Return all active categories (flat, with parent_id) for selectors."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, name, parent_id FROM categories WHERE is_active = true ORDER BY name"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'categories': [{'id': r[0], 'name': r[1], 'parent_id': r[2]} for r in rows]})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Global Tier Discounts ───────────────────────
|
||||
|
||||
@inventory_bp.route('/tier-discounts', methods=['GET'])
|
||||
|
||||
@@ -12,7 +12,6 @@ from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
from services import facturapi_service
|
||||
from services.audit import log_action
|
||||
from services.facturapi_service import FacturapiError
|
||||
from services.cfdi_facturapi_builder import (
|
||||
build_egreso_payload,
|
||||
build_ingreso_payload,
|
||||
@@ -20,7 +19,6 @@ 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,
|
||||
@@ -45,16 +43,9 @@ def _get_issuer_config(cur, branch_id=None):
|
||||
result = {
|
||||
"rfc": config.get("tenant_rfc", ""),
|
||||
"razon_social": config.get("tenant_razon_social", ""),
|
||||
"regimen_fiscal": config.get("cfdi_regimen_fiscal") or config.get("tenant_regimen_fiscal", "601"),
|
||||
"regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
|
||||
"cp": config.get("tenant_cp", "00000"),
|
||||
"direccion": config.get("tenant_direccion", ""),
|
||||
"exterior": config.get("tenant_numero_exterior", ""),
|
||||
"interior": config.get("tenant_numero_interior", ""),
|
||||
"colonia": config.get("tenant_colonia", ""),
|
||||
"ciudad": config.get("tenant_ciudad", ""),
|
||||
"municipio": config.get("tenant_municipio", ""),
|
||||
"estado": config.get("tenant_estado", ""),
|
||||
"serie": config.get("cfdi_serie") or config.get("invoice_serie", "A"),
|
||||
"serie": config.get("cfdi_serie", "A"),
|
||||
"facturapi_key": config.get("cfdi_facturapi_key", ""),
|
||||
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
|
||||
}
|
||||
@@ -63,7 +54,7 @@ def _get_issuer_config(cur, branch_id=None):
|
||||
if branch_id:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT rfc, razon_social, regimen_fiscal, cp, serie_cfdi
|
||||
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
|
||||
FROM branches WHERE id = %s
|
||||
""",
|
||||
(branch_id,),
|
||||
@@ -85,8 +76,7 @@ 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,
|
||||
currency, exchange_rate
|
||||
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||
FROM sales WHERE id = %s
|
||||
""",
|
||||
(sale_id,),
|
||||
@@ -110,8 +100,6 @@ 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(
|
||||
@@ -370,44 +358,6 @@ 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 ────────────────────────────────────
|
||||
|
||||
|
||||
@@ -535,7 +485,7 @@ def get_sale_pdf(sale_id):
|
||||
|
||||
|
||||
@invoicing_bp.route("/stats", methods=["GET"])
|
||||
@require_auth("invoicing.view")
|
||||
@require_auth("invoicing.read")
|
||||
def api_invoicing_stats():
|
||||
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -717,7 +667,6 @@ def facturapi_setup():
|
||||
(result["org_id"],),
|
||||
)
|
||||
|
||||
if result.get("api_key"):
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_config (key, value)
|
||||
@@ -733,30 +682,18 @@ def facturapi_setup():
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
status_conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
status_cur = status_conn.cursor()
|
||||
status = facturapi_service.get_org_status(_get_issuer_config(status_cur))
|
||||
status["org_id"] = result["org_id"]
|
||||
if not result.get("legal_updated"):
|
||||
status["error"] = status.get("error") or "Datos fiscales no pudieron configurarse automáticamente. Configúralos en el dashboard de Facturapi."
|
||||
status_cur.close()
|
||||
status_conn.close()
|
||||
except Exception:
|
||||
status_conn.close()
|
||||
raise
|
||||
return jsonify(status)
|
||||
return jsonify(
|
||||
{
|
||||
"org_id": result["org_id"],
|
||||
"message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
|
||||
}
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except FacturapiError as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close()
|
||||
|
||||
@@ -10,19 +10,11 @@ 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>
|
||||
@@ -32,8 +24,6 @@ Routes:
|
||||
POST /pos/api/marketplace-ext/webhook/meli
|
||||
"""
|
||||
|
||||
import urllib.parse
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
@@ -70,84 +60,10 @@ 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():
|
||||
@@ -160,55 +76,11 @@ 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()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/connect/init", methods=["POST"])
|
||||
@require_auth()
|
||||
def init_meli_connect():
|
||||
"""Store client credentials server-side and return the MercadoLibre auth URL.
|
||||
|
||||
The frontend no longer keeps the client_secret in localStorage.
|
||||
"""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
data = request.get_json() or {}
|
||||
client_id = data.get("client_id", "").strip()
|
||||
client_secret = data.get("client_secret", "").strip()
|
||||
category = data.get("category", "").strip()
|
||||
shipping = data.get("shipping", "").strip()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
return jsonify({"error": "client_id and client_secret required"}), 400
|
||||
|
||||
base = _get_public_base_url().rstrip("/")
|
||||
redirect_uri = f"{base}/pos/marketplace-external/callback"
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
meli_svc.save_meli_config(conn, {
|
||||
"meli_client_id": client_id,
|
||||
"meli_client_secret": client_secret,
|
||||
"meli_default_category_id": category,
|
||||
"meli_shipping_mode": shipping,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
auth_url = (
|
||||
"https://auth.mercadolibre.com.mx/authorization?response_type=code"
|
||||
f"&client_id={urllib.parse.quote(client_id)}"
|
||||
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
|
||||
"&scope=read+write+offline_access"
|
||||
)
|
||||
return jsonify({"auth_url": auth_url, "redirect_uri": redirect_uri})
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/connect", methods=["POST"])
|
||||
@require_auth()
|
||||
def connect_meli():
|
||||
@@ -218,21 +90,12 @@ def connect_meli():
|
||||
|
||||
data = request.get_json() or {}
|
||||
code = data.get("code")
|
||||
client_id = data.get("client_id")
|
||||
client_secret = data.get("client_secret")
|
||||
redirect_uri = data.get("redirect_uri", "")
|
||||
|
||||
if not code:
|
||||
return jsonify({"error": "code required"}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cfg = meli_svc.get_meli_config(conn)
|
||||
client_id = data.get("client_id") or cfg.get("meli_client_id")
|
||||
client_secret = data.get("client_secret") or cfg.get("meli_client_secret")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
return jsonify({"error": "ML credentials not configured"}), 400
|
||||
if not code or not client_id or not client_secret:
|
||||
return jsonify({"error": "code, client_id and client_secret required"}), 400
|
||||
|
||||
try:
|
||||
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
||||
@@ -261,17 +124,12 @@ def connect_meli():
|
||||
"meli_client_id": client_id,
|
||||
"meli_client_secret": client_secret,
|
||||
})
|
||||
resp = {
|
||||
return jsonify({
|
||||
"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()
|
||||
|
||||
@@ -302,13 +160,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, conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
result = svc.search_categories(site_id, q)
|
||||
return jsonify({"categories": result})
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -322,16 +180,16 @@ def search_categories():
|
||||
@marketplace_ext_bp.route("/listings", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_listings():
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
|
||||
return jsonify(result)
|
||||
except MeliAuthError as e:
|
||||
return _meli_auth_error(e)
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -367,7 +225,6 @@ 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:
|
||||
@@ -398,36 +255,6 @@ 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():
|
||||
@@ -457,8 +284,7 @@ def sync_stock_to_meli():
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
# Manual run from the UI: bypass the meli_auto_sync_stock_price gate.
|
||||
result = meli_svc.process_meli_sync_queue(conn, force=True)
|
||||
result = meli_svc.process_meli_sync_queue(conn)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
@@ -475,41 +301,15 @@ 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, conn)
|
||||
svc = meli_svc._get_meli_service(cfg)
|
||||
if not svc:
|
||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
attrs = svc.get_category_attributes(category_id)
|
||||
# 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 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 MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -546,7 +346,6 @@ 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:
|
||||
@@ -587,8 +386,6 @@ 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:
|
||||
@@ -601,7 +398,7 @@ def get_async_listing_status(task_id):
|
||||
"""Get status of an async ML publishing task."""
|
||||
try:
|
||||
from celery.result import AsyncResult
|
||||
from celery_app import celery as celery_app
|
||||
from app import celery as celery_app
|
||||
result = AsyncResult(task_id, app=celery_app)
|
||||
if result.ready():
|
||||
return jsonify({"status": "done", "result": result.result or {}})
|
||||
@@ -765,34 +562,6 @@ 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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -820,8 +589,8 @@ def sync_orders():
|
||||
@marketplace_ext_bp.route("/orders", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_orders():
|
||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
status = request.args.get("status")
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -850,15 +619,11 @@ 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,
|
||||
generate_cfdi=generate_cfdi,
|
||||
conn, order_id, employee_id=g.employee_id, register_id=register_id
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
@@ -897,35 +662,38 @@ def update_order_status_route(order_id):
|
||||
def meli_webhook():
|
||||
"""Receive MercadoLibre notifications.
|
||||
|
||||
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.
|
||||
ML sends a lightweight payload with topic + resource URL.
|
||||
We ack immediately and enqueue Celery for async processing.
|
||||
"""
|
||||
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")
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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:
|
||||
if tenant_id and topic:
|
||||
try:
|
||||
from tasks import process_meli_webhook_task
|
||||
process_meli_webhook_task.delay(tenant_id, topic, resource)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -123,10 +123,7 @@ def public_catalog_search():
|
||||
from services.catalog_service import smart_search
|
||||
master = get_master_conn()
|
||||
try:
|
||||
# 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')
|
||||
results = smart_search(master, q, conn, branch_id=None, limit=limit)
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@@ -3,21 +3,16 @@
|
||||
Prefix: /pos/api/service-orders
|
||||
"""
|
||||
|
||||
import json
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
from services.service_order_engine import (
|
||||
add_item,
|
||||
add_labor,
|
||||
assign_mechanic,
|
||||
convert_to_remission,
|
||||
convert_to_sale,
|
||||
create_service_catalog_item,
|
||||
create_service_order,
|
||||
delete_service_catalog_item,
|
||||
delete_service_order,
|
||||
get_kanban_summary,
|
||||
get_service_order,
|
||||
list_service_catalog,
|
||||
@@ -33,136 +28,17 @@ from services.service_order_engine import (
|
||||
update_status,
|
||||
)
|
||||
from tenant_db import get_tenant_conn
|
||||
from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PERMISSIONS
|
||||
|
||||
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
||||
|
||||
|
||||
def _tenant_allows_zero_price(conn):
|
||||
"""Return True if the tenant allows selling items at $0. Defaults to True."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
|
||||
|
||||
|
||||
def _validate_zero_price(conn, items):
|
||||
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
|
||||
if _tenant_allows_zero_price(conn):
|
||||
return
|
||||
for item in items:
|
||||
unit_price = float(item.get('unit_price', 0) or 0)
|
||||
quantity = float(item.get('quantity', 1) or 1)
|
||||
discount_pct = float(item.get('discount_pct', 0) or 0)
|
||||
line_total = unit_price * quantity * (1 - discount_pct / 100)
|
||||
if line_total <= 0:
|
||||
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
|
||||
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
|
||||
|
||||
|
||||
# Roles allowed to access the workshop module at all.
|
||||
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
|
||||
|
||||
|
||||
def _can_view_workshop():
|
||||
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
|
||||
|
||||
|
||||
def _get_ws_perms():
|
||||
"""Return effective workshop permissions for the current user."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
return _get_workshop_permissions(conn, g.employee_role)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _visible_statuses():
|
||||
if g.employee_role in ('owner', 'admin'):
|
||||
return list(_DEFAULT_WORKSHOP_PERMISSIONS['admin']['statuses'])
|
||||
return _get_ws_perms().get('statuses', [])
|
||||
|
||||
|
||||
def _can_do_action(action):
|
||||
if g.employee_role in ('owner', 'admin'):
|
||||
return True
|
||||
return action in _get_ws_perms().get('actions', [])
|
||||
|
||||
|
||||
def _redact_order_for_restricted(order):
|
||||
"""Remove customer/commercial data when the role cannot view sensitive data."""
|
||||
if g.employee_role in ('owner', 'admin') or _can_do_action('view_customer_data'):
|
||||
return order
|
||||
sensitive = {
|
||||
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
|
||||
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
|
||||
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
|
||||
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
|
||||
'final_cost', 'total_parts', 'total_labor', 'total',
|
||||
}
|
||||
|
||||
def redact_value(v):
|
||||
if isinstance(v, str):
|
||||
return '—'
|
||||
if isinstance(v, (int, float)):
|
||||
return 0
|
||||
return None
|
||||
|
||||
redacted = {}
|
||||
for k, v in order.items():
|
||||
if k in sensitive:
|
||||
redacted[k] = redact_value(v)
|
||||
elif k == 'items' and isinstance(v, list):
|
||||
redacted[k] = [_redact_item_for_restricted(it) for it in v]
|
||||
else:
|
||||
redacted[k] = v
|
||||
return redacted
|
||||
|
||||
|
||||
def _redact_item_for_restricted(item):
|
||||
"""Hide sensitive item rows for restricted viewers."""
|
||||
item = dict(item)
|
||||
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
|
||||
if k in item:
|
||||
item[k] = None if not isinstance(item[k], (int, float)) else 0
|
||||
return item
|
||||
|
||||
|
||||
def require_workshop_view(f):
|
||||
@wraps(f)
|
||||
@require_auth()
|
||||
def decorated(*args, **kwargs):
|
||||
if not _can_view_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def require_workshop_action(action):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
@require_auth()
|
||||
def decorated(*args, **kwargs):
|
||||
if not _can_view_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
||||
if not _can_do_action(action):
|
||||
return jsonify({'error': f'Missing permissions: workshop.{action}'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['GET'])
|
||||
@require_workshop_view
|
||||
@require_auth()
|
||||
def list_orders():
|
||||
status = request.args.get('status')
|
||||
priority = request.args.get('priority')
|
||||
customer_id = request.args.get('customer_id', type=int)
|
||||
employee_id = request.args.get('employee_id', type=int)
|
||||
delivery_method = request.args.get('delivery_method')
|
||||
is_direct = request.args.get('is_direct', type=lambda v: v.lower() == 'true') if 'is_direct' in request.args else None
|
||||
q = request.args.get('q')
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
|
||||
@@ -171,65 +47,15 @@ def list_orders():
|
||||
result = list_service_orders(
|
||||
conn, status=status, branch_id=g.branch_id,
|
||||
customer_id=customer_id, priority=priority,
|
||||
employee_id=employee_id, delivery_method=delivery_method,
|
||||
is_direct=is_direct, q=q, page=page, per_page=per_page
|
||||
employee_id=employee_id, page=page, per_page=per_page
|
||||
)
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
visible = _visible_statuses()
|
||||
result['data'] = [
|
||||
_redact_order_for_restricted(o) for o in result.get('data', [])
|
||||
if o.get('status') in visible
|
||||
]
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/from-pos', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def create_order_from_pos():
|
||||
"""Create a service order directly from the POS cart (quotation)."""
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', [])
|
||||
estimated_cost = sum(
|
||||
(it.get('unit_price') or 0) * (it.get('quantity') or 1)
|
||||
for it in items
|
||||
)
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
_validate_zero_price(conn, items)
|
||||
result = create_service_order(conn, {
|
||||
'tenant_id': g.tenant_id,
|
||||
'branch_id': data.get('branch_id', g.branch_id),
|
||||
'customer_id': data.get('customer_id'),
|
||||
'vehicle_id': data.get('vehicle_id'),
|
||||
'priority': data.get('priority', 'normal'),
|
||||
'reception_notes': data.get('reception_notes'),
|
||||
'estimated_cost': estimated_cost or None,
|
||||
'estimated_completion': data.get('estimated_completion'),
|
||||
'employee_id': data.get('employee_id'),
|
||||
'mileage_in': data.get('mileage_in'),
|
||||
'created_by': getattr(g, 'employee_id', None),
|
||||
'delivery_method': data.get('delivery_method'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
'is_direct': data.get('is_direct', False),
|
||||
'requires_invoice': data.get('requires_invoice', False),
|
||||
'workshop_name': data.get('workshop_name'),
|
||||
'customer_address': data.get('customer_address'),
|
||||
'customer_phone': data.get('customer_phone'),
|
||||
'vehicle_description': data.get('vehicle_description'),
|
||||
})
|
||||
so_id = result['service_order_id']
|
||||
for item in items:
|
||||
add_item(conn, so_id, item)
|
||||
order = get_service_order(conn, so_id)
|
||||
return jsonify(order), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['POST'])
|
||||
@require_workshop_action('create_order')
|
||||
@require_auth()
|
||||
def create_order():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -247,14 +73,6 @@ def create_order():
|
||||
'mileage_in': data.get('mileage_in'),
|
||||
'fuel_level': data.get('fuel_level'),
|
||||
'created_by': getattr(g, 'employee_id', None),
|
||||
'delivery_method': data.get('delivery_method'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
'is_direct': data.get('is_direct', False),
|
||||
'requires_invoice': data.get('requires_invoice', False),
|
||||
'workshop_name': data.get('workshop_name'),
|
||||
'customer_address': data.get('customer_address'),
|
||||
'customer_phone': data.get('customer_phone'),
|
||||
'vehicle_description': data.get('vehicle_description'),
|
||||
})
|
||||
return jsonify(result), 201
|
||||
finally:
|
||||
@@ -262,29 +80,22 @@ def create_order():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||
@require_workshop_view
|
||||
@require_auth()
|
||||
def get_order(so_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
order = get_service_order(conn, so_id)
|
||||
if not order:
|
||||
return jsonify({'error': 'Service order not found'}), 404
|
||||
if order.get('status') not in _visible_statuses():
|
||||
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
||||
return jsonify(_redact_order_for_restricted(order))
|
||||
return jsonify(order)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||
@require_workshop_action('edit_order')
|
||||
@require_auth()
|
||||
def update_order(so_id):
|
||||
data = request.get_json() or {}
|
||||
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
|
||||
if g.employee_role == 'mechanic':
|
||||
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
|
||||
if not data or any(k not in allowed for k in data.keys()):
|
||||
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = update_service_order(conn, so_id, data)
|
||||
@@ -295,32 +106,13 @@ def update_order(so_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
|
||||
@require_workshop_action('delete_order')
|
||||
def delete_order(so_id):
|
||||
"""Soft-delete a service order. Restricted to owner/admin."""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
return jsonify({'error': 'Solo administradores pueden eliminar órdenes'}), 403
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
delete_service_order(conn, so_id)
|
||||
return jsonify({'message': 'Orden eliminada'})
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 404
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||
@require_workshop_action('change_status')
|
||||
@require_auth()
|
||||
def change_status(so_id):
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get('status')
|
||||
if not new_status:
|
||||
return jsonify({'error': 'status is required'}), 400
|
||||
if new_status not in _visible_statuses():
|
||||
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = update_status(
|
||||
@@ -338,7 +130,7 @@ def change_status(so_id):
|
||||
# ─── Items (Parts) ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||
@require_workshop_action('add_items')
|
||||
@require_auth()
|
||||
def add_order_item(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -350,7 +142,7 @@ def add_order_item(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@require_workshop_action('add_items')
|
||||
@require_auth()
|
||||
def update_order_item(item_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -364,7 +156,7 @@ def update_order_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_workshop_action('add_items')
|
||||
@require_auth()
|
||||
def delete_order_item(item_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -377,7 +169,7 @@ def delete_order_item(item_id):
|
||||
# ─── Labor ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||
@require_workshop_action('add_labor')
|
||||
@require_auth()
|
||||
def add_order_labor(so_id):
|
||||
data = request.get_json() or {}
|
||||
if not data.get('description'):
|
||||
@@ -391,7 +183,7 @@ def add_order_labor(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||
@require_workshop_action('add_labor')
|
||||
@require_auth()
|
||||
def update_order_labor(labor_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -405,7 +197,7 @@ def update_order_labor(labor_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||
@require_workshop_action('add_labor')
|
||||
@require_auth()
|
||||
def delete_order_labor(labor_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -418,7 +210,7 @@ def delete_order_labor(labor_id):
|
||||
# ─── Kanban Summary ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||
@require_workshop_view
|
||||
@require_auth()
|
||||
def kanban_summary():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -432,7 +224,7 @@ def kanban_summary():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||
@require_workshop_action('add_items')
|
||||
@require_auth()
|
||||
def reserve_order_item(so_id, item_id):
|
||||
"""Reserve inventory for a service order item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -446,7 +238,7 @@ def reserve_order_item(so_id, item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||
@require_workshop_action('add_items')
|
||||
@require_auth()
|
||||
def release_order_item(so_id, item_id):
|
||||
"""Release a previous inventory reservation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -464,7 +256,6 @@ def release_order_item(so_id, item_id):
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_workshop_action('convert_to_sale')
|
||||
def convert_order_to_sale(so_id):
|
||||
"""Convert a service order into a POS sale.
|
||||
|
||||
@@ -499,43 +290,11 @@ def convert_order_to_sale(so_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Convert to remission note ────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
|
||||
@require_auth('pos.remission')
|
||||
@require_workshop_action('convert_to_remission')
|
||||
def convert_order_to_remission(so_id):
|
||||
"""Convert a service order into a counter remission note (pending payment).
|
||||
|
||||
Body: {
|
||||
register_id: int (optional),
|
||||
notes: str (optional)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
sale_payload = {
|
||||
'register_id': data.get('register_id'),
|
||||
'notes': data.get('notes'),
|
||||
}
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = convert_to_remission(
|
||||
conn, so_id, sale_payload, employee_id=g.employee_id
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Mechanic assignment ──────────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||
@require_workshop_action('assign_mechanic')
|
||||
@require_auth()
|
||||
def assign_mechanic_endpoint(so_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
data = request.get_json() or {}
|
||||
@@ -553,144 +312,11 @@ def assign_mechanic_endpoint(so_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/customers', methods=['POST'])
|
||||
@require_workshop_action('edit_order')
|
||||
def create_customer_for_workshop():
|
||||
"""Create a customer directly from the workshop flow."""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name'):
|
||||
return jsonify({'error': 'El nombre es obligatorio'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO customers
|
||||
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
||||
cp, email, phone, address, price_tier, credit_limit, max_discount_pct, vehicle_info)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.get('branch_id', g.branch_id), data['name'], data.get('rfc'),
|
||||
data.get('razon_social'), data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
|
||||
data.get('cp'), data.get('email'), data.get('phone'), data.get('address'),
|
||||
data.get('price_tier', 1), data.get('credit_limit', 0), data.get('max_discount_pct', 0),
|
||||
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
|
||||
))
|
||||
customer_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'id': customer_id, 'message': 'Cliente creado'}), 201
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@service_order_bp.route('/vehicles', methods=['POST'])
|
||||
@require_workshop_action('edit_order')
|
||||
def create_vehicle_for_workshop():
|
||||
"""Create a fleet vehicle directly from the workshop flow.
|
||||
|
||||
Vehicles must be assigned to a customer. Brand and model are required;
|
||||
plate is optional and VIN is not used.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
if not data.get('make') or not data.get('model'):
|
||||
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
|
||||
if not data.get('customer_id'):
|
||||
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
|
||||
cust = cur.fetchone()
|
||||
if not cust:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Cliente no encontrado'}), 404
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO fleet_vehicles
|
||||
(branch_id, customer_id, plate, make, model, year,
|
||||
current_mileage, fuel_type, color, owner_name, notes)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.get('branch_id', g.branch_id), data['customer_id'], data.get('plate'),
|
||||
data.get('make'), data.get('model'), data.get('year'),
|
||||
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
||||
data.get('color'), cust[0], data.get('notes'),
|
||||
))
|
||||
vehicle_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'id': vehicle_id, 'message': 'Vehiculo creado'}), 201
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@service_order_bp.route('/inventory-search', methods=['GET'])
|
||||
@require_workshop_view
|
||||
def inventory_search():
|
||||
"""Search active inventory for attaching parts to a service order.
|
||||
|
||||
Does not require inventory.view so workshop users can pick parts.
|
||||
"""
|
||||
q = request.args.get('q', '').strip()
|
||||
if not q:
|
||||
return jsonify({'data': []})
|
||||
|
||||
branch_id = request.args.get('branch_id', g.branch_id)
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
like = f'%{q}%'
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
|
||||
COALESCE((SELECT stock FROM inventory_stock
|
||||
WHERE inventory_id = i.id AND branch_id = %s), 0) AS stock,
|
||||
i.cost, i.price_1
|
||||
FROM inventory i
|
||||
WHERE i.is_active = true
|
||||
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
|
||||
ORDER BY i.name
|
||||
LIMIT 20
|
||||
""", (branch_id, like, like, like))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
|
||||
COALESCE((SELECT stock FROM inventory_stock_summary
|
||||
WHERE inventory_id = i.id), 0) AS stock,
|
||||
i.cost, i.price_1
|
||||
FROM inventory i
|
||||
WHERE i.is_active = true
|
||||
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
|
||||
ORDER BY i.name
|
||||
LIMIT 20
|
||||
""", (like, like, like))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'unit': r[4],
|
||||
'stock': float(r[5]) if r[5] else 0,
|
||||
'cost': float(r[6]) if r[6] else 0,
|
||||
'price_1': float(r[7]) if r[7] else 0,
|
||||
})
|
||||
cur.close()
|
||||
return jsonify({'data': items})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Service catalog (reusable labor) ─────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['GET'])
|
||||
@require_workshop_view
|
||||
@require_auth()
|
||||
def list_catalog():
|
||||
"""List reusable labor/service concepts."""
|
||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||
@@ -703,7 +329,7 @@ def list_catalog():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||
@require_workshop_action('edit_order')
|
||||
@require_auth()
|
||||
def create_catalog_item():
|
||||
"""Create a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -719,7 +345,7 @@ def create_catalog_item():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||
@require_workshop_action('edit_order')
|
||||
@require_auth()
|
||||
def update_catalog_item(item_id):
|
||||
"""Update a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -734,7 +360,7 @@ def update_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||
@require_workshop_action('edit_order')
|
||||
@require_auth()
|
||||
def delete_catalog_item(item_id):
|
||||
"""Soft-delete a reusable labor concept."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -749,7 +375,7 @@ def delete_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||
@require_workshop_action('edit_order')
|
||||
@require_auth()
|
||||
def print_service_order_ticket(so_id):
|
||||
"""Generate a printable ticket for a service order.
|
||||
|
||||
@@ -777,9 +403,7 @@ def print_service_order_ticket(so_id):
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
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')"""
|
||||
"SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')"
|
||||
)
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
# /home/Autopartes/pos/blueprints/storefront_bp.py
|
||||
"""Storefront público por tenant: vitrina web con carrito → pedido al POS.
|
||||
|
||||
Endpoints públicos (sin auth) protegidos por rate-limit por IP:
|
||||
GET /pos/api/store/info — nombre del negocio
|
||||
GET /pos/api/store/search — búsqueda de inventario con stock y precio público
|
||||
POST /pos/api/store/order — crea pedido (venta pendiente de pago) con reserva de stock
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
|
||||
from middleware_tenant import resolve_tenant
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services.pos_engine import process_sale
|
||||
|
||||
storefront_bp = Blueprint('storefront', __name__, url_prefix='/pos/api')
|
||||
|
||||
_RATE = defaultdict(deque)
|
||||
RATE_LIMIT = 60 # requests por minuto por IP
|
||||
RATE_WINDOW = 60
|
||||
|
||||
|
||||
def _rate_ok():
|
||||
ip = (request.headers.get('X-Real-IP') or request.remote_addr or 'x')
|
||||
now = time.time()
|
||||
dq = _RATE[ip]
|
||||
while dq and dq[0] < now - RATE_WINDOW:
|
||||
dq.popleft()
|
||||
if len(dq) >= RATE_LIMIT:
|
||||
return False
|
||||
dq.append(now)
|
||||
return True
|
||||
|
||||
|
||||
def _require_tenant():
|
||||
if not getattr(g, 'tenant_id', None):
|
||||
return None
|
||||
return g.tenant_id
|
||||
|
||||
|
||||
def _business_name(tenant_id):
|
||||
"""Nombre del negocio desde la DB maestra (cache 5 min por proceso)."""
|
||||
cache = getattr(_business_name, '_cache', None)
|
||||
if cache is None:
|
||||
cache = _business_name._cache = {}
|
||||
now = time.time()
|
||||
hit = cache.get(tenant_id)
|
||||
if hit and hit[1] > now:
|
||||
return hit[0]
|
||||
try:
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM tenants WHERE id = %s", (tenant_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
name = row[0] if row else 'Refaccionaria'
|
||||
except Exception:
|
||||
name = 'Refaccionaria'
|
||||
cache[tenant_id] = (name, now + 300)
|
||||
return name
|
||||
|
||||
|
||||
@storefront_bp.route('/store/info', methods=['GET'])
|
||||
def store_info():
|
||||
tid = _require_tenant()
|
||||
if not tid:
|
||||
return jsonify({'error': 'tenant'}), 404
|
||||
return jsonify({'name': _business_name(tid)})
|
||||
|
||||
|
||||
@storefront_bp.route('/store/search', methods=['GET'])
|
||||
def store_search():
|
||||
tid = _require_tenant()
|
||||
if not tid:
|
||||
return jsonify({'error': 'tenant'}), 404
|
||||
if not _rate_ok():
|
||||
return jsonify({'error': 'Demasiadas búsquedas. Intenta en un minuto.'}), 429
|
||||
|
||||
q = (request.args.get('q') or '').strip()
|
||||
def _int_arg(name, default, lo, hi):
|
||||
try:
|
||||
return max(lo, min(hi, int(request.args.get(name, default) or default)))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
page = _int_arg('page', 1, 1, 10000)
|
||||
per_page = _int_arg('per_page', 20, 1, 30)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
conn = get_tenant_conn(tid)
|
||||
cur = conn.cursor()
|
||||
params = []
|
||||
where = "i.is_active = true"
|
||||
if q:
|
||||
where += """ AND (i.name ILIKE %s OR i.part_number ILIKE %s
|
||||
OR i.id IN (SELECT a.inventory_id FROM inventory_sku_aliases a
|
||||
WHERE a.sku ILIKE %s))"""
|
||||
like = f"%{q}%"
|
||||
params += [like, like, like]
|
||||
cur.execute(f"SELECT count(*) FROM inventory i WHERE {where}", params)
|
||||
total = cur.fetchone()[0]
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.image_url, i.description,
|
||||
COALESCE(s.stock, 0) AS stock
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE {where}
|
||||
ORDER BY (COALESCE(s.stock, 0) > 0) DESC, i.name
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, offset])
|
||||
items = [{
|
||||
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3] or '',
|
||||
'price': float(r[4] or 0), 'image_url': r[5] or '',
|
||||
'description': (r[6] or '')[:200], 'stock': int(r[7] or 0),
|
||||
} for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'items': items, 'page': page, 'total': total,
|
||||
'total_pages': max(1, (total + per_page - 1) // per_page)})
|
||||
|
||||
|
||||
@storefront_bp.route('/store/order', methods=['POST'])
|
||||
def store_order():
|
||||
tid = _require_tenant()
|
||||
if not tid:
|
||||
return jsonify({'error': 'tenant'}), 404
|
||||
if not _rate_ok():
|
||||
return jsonify({'error': 'Demasiadas solicitudes. Intenta en un minuto.'}), 429
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = (data.get('customer_name') or '').strip()
|
||||
phone = ''.join(ch for ch in (data.get('customer_phone') or '') if ch.isdigit())
|
||||
items = data.get('items') or []
|
||||
notes = (data.get('notes') or '').strip()
|
||||
delivery = bool(data.get('delivery'))
|
||||
|
||||
if not name or not phone or len(phone) < 10:
|
||||
return jsonify({'error': 'Nombre y teléfono (10 dígitos) son requeridos'}), 400
|
||||
if not items:
|
||||
return jsonify({'error': 'El carrito está vacío'}), 400
|
||||
|
||||
conn = get_tenant_conn(tid)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Sucursal principal
|
||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Tienda no disponible'}), 503
|
||||
branch_id = row[0]
|
||||
|
||||
# Cliente: buscar por teléfono, si no existe crearlo
|
||||
cur.execute("SELECT id FROM customers WHERE phone = %s ORDER BY id LIMIT 1", (phone,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
customer_id = row[0]
|
||||
else:
|
||||
cur.execute("""
|
||||
INSERT INTO customers (name, phone, price_tier, is_active)
|
||||
VALUES (%s, %s, 1, true) RETURNING id
|
||||
""", (name, phone))
|
||||
customer_id = cur.fetchone()[0]
|
||||
|
||||
# Validar stock y precios (precio SIEMPRE del inventario, no del cliente)
|
||||
resolved = []
|
||||
for it in items:
|
||||
inv_id = it.get('inventory_id')
|
||||
qty = int(it.get('quantity', 0) or 0)
|
||||
if not inv_id or qty <= 0:
|
||||
continue
|
||||
cur.execute("""
|
||||
SELECT i.id, i.name, i.part_number, i.price_1, i.tax_rate,
|
||||
COALESCE(s.stock, 0) AS stock
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE i.id = %s AND i.is_active
|
||||
""", (inv_id,))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Producto no disponible (#{inv_id})'}), 400
|
||||
if (r[5] or 0) < qty:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Sin stock suficiente: {r[2]}. Disponible: {int(r[5] or 0)}'}), 400
|
||||
resolved.append({
|
||||
'inventory_id': r[0], 'quantity': qty,
|
||||
'unit_price': float(r[3] or 0), 'tax_rate': float(r[4] or 0),
|
||||
'discount_pct': 0,
|
||||
})
|
||||
if not resolved:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'El carrito está vacío'}), 400
|
||||
|
||||
delivery_note = ' | Envío a domicilio solicitado' if delivery else ''
|
||||
sale_data = {
|
||||
'items': resolved,
|
||||
'customer_id': customer_id,
|
||||
'payment_method': 'pendiente',
|
||||
'sale_type': 'cash',
|
||||
'branch_id': branch_id,
|
||||
'amount_paid': 0,
|
||||
'notes': f'Pedido web de {name}{delivery_note}' + (f' | {notes}' if notes else ''),
|
||||
}
|
||||
try:
|
||||
sale = process_sale(conn, sale_data)
|
||||
conn.commit()
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'No se pudo registrar el pedido'}), 500
|
||||
cur.close()
|
||||
|
||||
# Notificar al dueño (best effort)
|
||||
try:
|
||||
from services.notification_engine import notify_new_sale
|
||||
notify_new_sale(conn, tid, sale['id'], sale['total'], 'web')
|
||||
except Exception:
|
||||
pass
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
'ok': True,
|
||||
'order_id': sale['id'],
|
||||
'total': sale['total'],
|
||||
'message': 'Pedido registrado. Te contactaremos para confirmar pago y entrega.',
|
||||
})
|
||||
@@ -28,7 +28,7 @@ def enqueue_report():
|
||||
|
||||
|
||||
@tasks_bp.route('/<task_id>/status', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_auth
|
||||
def task_status(task_id):
|
||||
"""Get status of a background task."""
|
||||
from celery_app import celery
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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
|
||||
@@ -12,7 +11,7 @@ Endpoints (all under /pos/api):
|
||||
PUT /warranty-claims/<id>/close
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g, render_template_string
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.warranty_engine import (
|
||||
@@ -44,10 +43,7 @@ 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 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')
|
||||
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id', 'warranty_months']
|
||||
missing = [f for f in required if f not in data]
|
||||
if missing:
|
||||
return jsonify({'error': f'Missing fields: {missing}'}), 400
|
||||
@@ -60,12 +56,11 @@ def post_warranty():
|
||||
sale_item_id=data['sale_item_id'],
|
||||
inventory_id=data['inventory_id'],
|
||||
customer_id=data['customer_id'],
|
||||
warranty_months=int(data.get('warranty_months') or 0),
|
||||
warranty_months=int(data['warranty_months']),
|
||||
supplier_id=data.get('supplier_id'),
|
||||
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()
|
||||
@@ -92,49 +87,6 @@ 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):
|
||||
@@ -254,68 +206,3 @@ def put_close_claim(claim_id):
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ── Printable warranty receipt ─────────────────────────────────────────────
|
||||
|
||||
WARRANTY_PRINT_TEMPLATE = """<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Garantia #{{ w.id }}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
|
||||
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.08);overflow:hidden}
|
||||
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:24px;text-align:center}
|
||||
.header h1{font-size:20px;font-weight:700;margin-bottom:4px}
|
||||
.header p{font-size:13px;opacity:.85}
|
||||
.body{padding:24px}
|
||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
|
||||
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
|
||||
.meta strong{color:#111;display:block;font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
|
||||
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
|
||||
.badge-active{background:#d1fae5;color:#065f46}
|
||||
.badge-expired,.badge-claimed,.badge-void{background:#fee2e2;color:#991b1b}
|
||||
.terms{font-size:12px;color:#6b7280;border-top:1px solid #e5e7eb;padding-top:14px}
|
||||
.footer{text-align:center;padding:14px;font-size:12px;color:#9ca3af}
|
||||
.actions{padding:0 24px 20px;text-align:center}
|
||||
.btn{display:inline-block;padding:12px 22px;border-radius:10px;border:none;background:#1f2937;color:#fff;font-size:14px;font-weight:700;cursor:pointer}
|
||||
@media print{body{background:#fff;padding:0}.card{box-shadow:none}.actions{display:none}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>Comprobante de Garantia</h1>
|
||||
<p>{{ business.name }}{% if business.rfc %} · RFC {{ business.rfc }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<div><strong>Folio</strong>G-{{ w.id }}</div>
|
||||
<div><strong>Estado</strong><span class="badge badge-{{ w.status }}">{{ w.status }}</span></div>
|
||||
<div><strong>Cliente</strong>{{ w.customer_name or 'Publico general' }}</div>
|
||||
<div><strong>Venta</strong>#{{ w.sale_id or '—' }}</div>
|
||||
<div><strong>Pieza</strong>{{ w.name or '—' }}</div>
|
||||
<div><strong>No. parte</strong>{{ w.part_number or '—' }}</div>
|
||||
<div><strong>Inicio</strong>{{ w.start_date }}</div>
|
||||
<div><strong>Vence</strong>{{ w.end_date }}</div>
|
||||
<div><strong>Cobertura</strong>{{ coverage }}</div>
|
||||
</div>
|
||||
<div class="terms">
|
||||
<p>Esta garantia ampara defectos de funcionamiento de la pieza por {{ coverage }} a partir de
|
||||
la fecha de compra. Presente este comprobante junto con la pieza para cualquier reclamacion.</p>
|
||||
{% if w.notes %}<p>Notas: {{ w.notes }}</p>{% endif %}
|
||||
{% if business.address %}<p>{{ business.address }}</p>{% endif %}
|
||||
{% if business.phone %}<p>Tel: {{ business.phone }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" onclick="window.print()">Imprimir</button>
|
||||
</div>
|
||||
<div class="footer">{{ business.name }} — Comprobante generado el {{ w.created_at[:10] }}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -34,7 +34,6 @@ 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
|
||||
@@ -44,17 +43,7 @@ def _get_whatsapp_config(conn):
|
||||
# No explicit tenant setting: auto-enable if a bridge URL is configured
|
||||
enabled = bool(bridge_url)
|
||||
|
||||
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', ''),
|
||||
|
||||
return {
|
||||
'bridge_url': bridge_url,
|
||||
'bridge_key': bridge_key,
|
||||
'enabled': enabled,
|
||||
@@ -370,11 +359,6 @@ 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})
|
||||
|
||||
@@ -409,7 +393,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
||||
|
||||
|
||||
# Resolve tenant
|
||||
tenant_id = tenant_id_override or request.args.get('tenant_id', type=int)
|
||||
tenant_id = request.args.get('tenant_id', type=int)
|
||||
if not tenant_id:
|
||||
try:
|
||||
mconn = get_master_conn()
|
||||
@@ -510,7 +494,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
||||
from services.part_kits import should_send_followup
|
||||
followup = should_send_followup(phone, tenant_conn)
|
||||
if followup:
|
||||
whatsapp_service.wa_send_text(wa_config, reply_to, followup)
|
||||
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
||||
cur_fu = tenant_conn.cursor()
|
||||
cur_fu.execute(
|
||||
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
||||
@@ -560,7 +544,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
||||
|
||||
# 7. Send reply
|
||||
if reply:
|
||||
result = whatsapp_service.wa_send_text(wa_config, reply_to, reply)
|
||||
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||||
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
||||
|
||||
# Log outgoing
|
||||
@@ -584,7 +568,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
||||
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
|
||||
f"Por favor llámanos directamente al {phone_branch}."
|
||||
)
|
||||
whatsapp_service.wa_send_text(wa_config, reply_to, fallback)
|
||||
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -599,221 +583,10 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
||||
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():
|
||||
|
||||
@@ -1,911 +0,0 @@
|
||||
# /home/Autopartes/pos/blueprints/yonke_bp.py
|
||||
"""Nexus Yonke blueprint: used/rebuild parts, donor vehicles and consignments.
|
||||
|
||||
All endpoints live under /pos/api/yonke.
|
||||
Permissions: yonke.view for reads, yonke.manage for writes.
|
||||
|
||||
AI vision grading (A4):
|
||||
POST /pos/api/yonke/pieces/<id>/grade-suggest (multipart `file` | JSON image_base64)
|
||||
POST /pos/api/yonke/grade-suggest (no piece required)
|
||||
Both return the suggestion contract documented in the "AI vision grading"
|
||||
section below; the front (yonke.js) can POST the photo and pre-fill the grade.
|
||||
|
||||
A5 additions (donor intake, reports, statements, labels, ML, bulk, AI):
|
||||
POST /pos/api/yonke/donors/full — donor + N pieces (atomic)
|
||||
POST /pos/api/yonke/donors/<vin>/allocate-cost — spread acquisition cost
|
||||
GET /pos/api/yonke/reports/aging?days=90 — pieces aging in the yard
|
||||
GET /pos/api/yonke/reports/summary — donors/margin/capital/grades
|
||||
GET /pos/api/yonke/consignments/<id>/statement — consignor statement (JSON)
|
||||
GET /pos/api/yonke/consignments/<id>/statement/print — consignor statement (HTML)
|
||||
POST /pos/api/yonke/pieces/<id>/publish-meli — publish one piece to ML
|
||||
POST /pos/api/yonke/pieces/bulk — bulk CSV import
|
||||
GET/POST /pos/api/yonke/pieces/<id>/compat — "sirve para" fitment
|
||||
GET /pos/api/yonke/pieces/<id>/label — printable barcode label
|
||||
POST /pos/api/yonke/pieces/<id>/identify — vision identify (503 on AI down)
|
||||
POST /pos/api/yonke/pieces/<id>/price-suggest — rule-based price suggestion
|
||||
GET /pos/api/yonke/duplicates?threshold=0.85 — semantic duplicate groups
|
||||
POST /pos/api/yonke/pieces/<id>/validate-photo — vision photo check
|
||||
"""
|
||||
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
from flask import Blueprint, request, jsonify, g, Response
|
||||
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
from services import yonke_service
|
||||
from services.yonke_service import DuplicatePartNumber
|
||||
from services.image_service import DATA_DIR, STATIC_DIR
|
||||
from services.vin_decoder import decode_vin
|
||||
|
||||
yonke_bp = Blueprint('yonke', __name__, url_prefix='/pos/api/yonke')
|
||||
|
||||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'webp'}
|
||||
MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
|
||||
def _int_arg(name, default, minimum, maximum):
|
||||
try:
|
||||
val = int(request.args.get(name, default))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return min(max(val, minimum), maximum)
|
||||
|
||||
|
||||
def _branch_id(data=None):
|
||||
"""Resolve the branch: explicit body/query value first, token fallback."""
|
||||
if isinstance(data, dict) and data.get('branch_id'):
|
||||
try:
|
||||
return int(data['branch_id'])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
arg = request.args.get('branch_id')
|
||||
if arg:
|
||||
try:
|
||||
return int(arg)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return g.branch_id
|
||||
|
||||
|
||||
# ─── Pieces ────────────────────────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/pieces', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def list_pieces():
|
||||
"""List used/rebuild pieces with filters and pagination."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
items, pagination = yonke_service.list_pieces(
|
||||
conn,
|
||||
filters={
|
||||
'q': request.args.get('q'),
|
||||
'donor_make': request.args.get('donor_make'),
|
||||
'donor_model': request.args.get('donor_model'),
|
||||
'donor_year': request.args.get('donor_year'),
|
||||
'vin': request.args.get('vin'),
|
||||
'grade': request.args.get('grade'),
|
||||
'condition': request.args.get('condition'),
|
||||
'ownership': request.args.get('ownership'),
|
||||
},
|
||||
page=_int_arg('page', 1, 1, 10**9),
|
||||
per_page=_int_arg('per_page', 25, 1, 200),
|
||||
branch_id=_branch_id(),
|
||||
)
|
||||
return jsonify({'data': items, 'pagination': pagination})
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/pieces', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def create_piece():
|
||||
"""Create a used/rebuild piece (auto-SKU when part_number is omitted)."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
item = yonke_service.create_piece(conn, data, branch_id=_branch_id(data))
|
||||
conn.commit()
|
||||
return jsonify(item), 201
|
||||
except DuplicatePartNumber:
|
||||
conn.rollback()
|
||||
return jsonify({'error': 'part_number already exists'}), 409
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def get_piece(piece_id):
|
||||
"""Get a single piece (extra helper for the detail/edit view)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
item = yonke_service.get_piece(conn, piece_id, branch_id=_branch_id())
|
||||
if not item:
|
||||
return jsonify({'error': 'Piece not found'}), 404
|
||||
return jsonify(item)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>', methods=['PUT'])
|
||||
@require_auth('yonke.manage')
|
||||
def update_piece(piece_id):
|
||||
"""Edit the Yonke fields of a piece."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
item = yonke_service.update_piece(conn, piece_id, data, branch_id=_branch_id(data))
|
||||
if not item:
|
||||
conn.rollback()
|
||||
return jsonify({'error': 'Piece not found'}), 404
|
||||
conn.commit()
|
||||
return jsonify(item)
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Images ────────────────────────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/images', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def upload_piece_images(piece_id):
|
||||
"""Upload one or more photos for a piece (multipart, repeated `file` field)."""
|
||||
files = [f for f in request.files.getlist('file') if f and f.filename]
|
||||
if not files:
|
||||
return jsonify({'error': 'No file provided'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
if not yonke_service.get_piece(conn, piece_id):
|
||||
return jsonify({'error': 'Piece not found'}), 404
|
||||
|
||||
valid = []
|
||||
for f in files:
|
||||
ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else ''
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
return jsonify({
|
||||
'error': f'File type not allowed. Use: {", ".join(sorted(ALLOWED_EXTENSIONS))}'
|
||||
}), 400
|
||||
raw = f.read()
|
||||
if len(raw) > MAX_IMAGE_BYTES:
|
||||
return jsonify({'error': 'File too large (max 5 MB)'}), 400
|
||||
valid.append(BytesIO(raw))
|
||||
|
||||
images = yonke_service.add_piece_images(conn, g.tenant_id, piece_id, valid)
|
||||
conn.commit()
|
||||
return jsonify({'images': images})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/images/<int:image_id>', methods=['DELETE'])
|
||||
@require_auth('yonke.manage')
|
||||
def delete_piece_image(piece_id, image_id):
|
||||
"""Delete one photo (row + files); promotes another when it was primary."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
deleted = yonke_service.delete_piece_image(conn, g.tenant_id, piece_id, image_id)
|
||||
if not deleted:
|
||||
conn.rollback()
|
||||
return jsonify({'error': 'Image not found'}), 404
|
||||
conn.commit()
|
||||
return jsonify({'ok': True})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── AI vision grading (A4) ────────────────────────────────────────────────
|
||||
#
|
||||
# Front-end contract (yonke.js):
|
||||
# POST /pos/api/yonke/pieces/<id>/grade-suggest
|
||||
# body: multipart `file` (jpg/png/webp, <=5 MB) — or JSON {"image_base64": "..."}
|
||||
# optional metadata overrides: name / category / condition
|
||||
# 200 -> {condition_grade_suggested:"A|B|C|D", condition_suggested, defects[],
|
||||
# confidence, notes, source, piece_id}
|
||||
# 503 -> same shape but grade=null + `error` (AI backend unavailable)
|
||||
# POST /pos/api/yonke/grade-suggest
|
||||
# same body, no piece required (returns the suggestion only, never persists).
|
||||
|
||||
def _piece_primary_image_url(piece):
|
||||
"""Primary photo URL for a piece, falling back to inventory.image_url."""
|
||||
for img in (piece or {}).get('images') or []:
|
||||
if img.get('is_primary') and img.get('url'):
|
||||
return img['url']
|
||||
for img in (piece or {}).get('images') or []:
|
||||
if img.get('url'):
|
||||
return img['url']
|
||||
return (piece or {}).get('image_url') or None
|
||||
|
||||
|
||||
def _read_local_image(url):
|
||||
"""Read the bytes of an inventory_images/inventory URL stored on disk."""
|
||||
if not url:
|
||||
return None
|
||||
idx = url.find('/images/parts/')
|
||||
if idx == -1:
|
||||
return None
|
||||
rel = url[idx + len('/images/parts/'):]
|
||||
for base in (DATA_DIR, STATIC_DIR):
|
||||
try:
|
||||
with open(os.path.join(base, rel), 'rb') as fh:
|
||||
return fh.read()
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _read_image_request():
|
||||
"""Extract the image from the request.
|
||||
|
||||
Returns (image_bytes, image_base64, error_response). Exactly one of
|
||||
image_bytes / image_base64 is set when error_response is None.
|
||||
"""
|
||||
upload = request.files.get('file')
|
||||
if upload and upload.filename:
|
||||
raw = upload.read()
|
||||
if not raw:
|
||||
return None, None, (jsonify({'error': 'Empty file'}), 400)
|
||||
if len(raw) > MAX_IMAGE_BYTES:
|
||||
return None, None, (jsonify({'error': 'File too large (max 5 MB)'}), 400)
|
||||
return raw, None, None
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
b64 = (data.get('image_base64') or data.get('image') or '').strip()
|
||||
if b64:
|
||||
return None, b64, None
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _meta_from_request(piece=None):
|
||||
"""Metadata for the prompt: request overrides piece values."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
form = request.form or {}
|
||||
|
||||
def pick(field):
|
||||
return (form.get(field) or data.get(field) or '').strip() or None
|
||||
|
||||
piece = piece or {}
|
||||
return {
|
||||
'name': pick('name') or piece.get('name'),
|
||||
'category': pick('category'),
|
||||
'condition': pick('condition') or piece.get('condition') or 'usado',
|
||||
}
|
||||
|
||||
|
||||
def _piece_category_name(conn, piece_id):
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT c.name
|
||||
FROM inventory i
|
||||
LEFT JOIN part_categories c ON c.id = i.category_id
|
||||
WHERE i.id = %s
|
||||
""", (piece_id,))
|
||||
row = cur.fetchone()
|
||||
return (row[0] if row else None) or None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def _grade_response(result, piece_id=None):
|
||||
"""Normalize the service result into the HTTP response + status code."""
|
||||
payload = dict(result)
|
||||
if piece_id is not None:
|
||||
payload['piece_id'] = piece_id
|
||||
payload['available'] = result.get('condition_grade_suggested') is not None
|
||||
status = 200 if payload['available'] else 503
|
||||
return jsonify(payload), status
|
||||
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/grade-suggest', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def grade_suggest_piece(piece_id):
|
||||
"""Suggest a condition grade from a piece photo (upload/b64/primary photo)."""
|
||||
image_bytes, image_b64, err = _read_image_request()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
piece = yonke_service.get_piece(conn, piece_id)
|
||||
if not piece:
|
||||
return jsonify({'error': 'Piece not found'}), 404
|
||||
|
||||
if not image_bytes and not image_b64:
|
||||
image_bytes = _read_local_image(_piece_primary_image_url(piece))
|
||||
if not image_bytes:
|
||||
return jsonify({
|
||||
'error': 'No image provided and the piece has no stored photo'
|
||||
}), 400
|
||||
|
||||
meta = _meta_from_request(piece)
|
||||
if not meta.get('category'):
|
||||
meta['category'] = _piece_category_name(conn, piece_id)
|
||||
|
||||
from services import part_vision
|
||||
result = part_vision.suggest_grade(
|
||||
image_bytes=image_bytes, image_base64=image_b64,
|
||||
name=meta['name'], category=meta['category'], condition=meta['condition'],
|
||||
)
|
||||
return _grade_response(result, piece_id=piece_id)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/grade-suggest', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def grade_suggest_adhoc():
|
||||
"""Suggest a condition grade from a photo without requiring a piece."""
|
||||
image_bytes, image_b64, err = _read_image_request()
|
||||
if err:
|
||||
return err
|
||||
if not image_bytes and not image_b64:
|
||||
return jsonify({'error': 'No image provided'}), 400
|
||||
|
||||
meta = _meta_from_request()
|
||||
try:
|
||||
from services import part_vision
|
||||
result = part_vision.suggest_grade(
|
||||
image_bytes=image_bytes, image_base64=image_b64,
|
||||
name=meta['name'], category=meta['category'], condition=meta['condition'],
|
||||
)
|
||||
return _grade_response(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ─── VIN decoder ───────────────────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/vin/<vin>', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def decode_vin_endpoint(vin):
|
||||
"""Decode a VIN with the shared NHTSA decoder."""
|
||||
result = decode_vin(vin)
|
||||
return jsonify({
|
||||
'vin': result.get('vin', (vin or '').strip().upper()),
|
||||
'make': result.get('make', '') or '',
|
||||
'model': result.get('model', '') or '',
|
||||
'year': result.get('year'),
|
||||
'engine': result.get('engine', '') or '',
|
||||
'body_type': result.get('body_type', '') or '',
|
||||
'fuel_type': result.get('fuel_type', '') or '',
|
||||
'drive_type': result.get('drive_type', '') or '',
|
||||
'trim': result.get('trim', '') or '',
|
||||
'error': result.get('error', '') or '',
|
||||
})
|
||||
|
||||
|
||||
# ─── Donors ────────────────────────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/donors', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def list_donors():
|
||||
"""List donor vehicles grouped by VIN."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
data, pagination = yonke_service.list_donors(
|
||||
conn,
|
||||
q=request.args.get('q'),
|
||||
page=_int_arg('page', 1, 1, 10**9),
|
||||
per_page=_int_arg('per_page', 25, 1, 200),
|
||||
)
|
||||
return jsonify({'data': data, 'pagination': pagination})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Consignments ──────────────────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/consignments', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def list_consignments():
|
||||
"""List consignments with piece counts and amounts."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
data, pagination = yonke_service.list_consignments(
|
||||
conn,
|
||||
status=request.args.get('status'),
|
||||
page=_int_arg('page', 1, 1, 10**9),
|
||||
per_page=_int_arg('per_page', 50, 1, 200),
|
||||
)
|
||||
return jsonify({'data': data, 'pagination': pagination})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/consignments', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def create_consignment():
|
||||
"""Create a consignment."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cons = yonke_service.create_consignment(conn, data)
|
||||
conn.commit()
|
||||
return jsonify(cons), 201
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/consignments/<int:consignment_id>', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def get_consignment(consignment_id):
|
||||
"""Consignment detail with its items."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cons = yonke_service.get_consignment(conn, consignment_id)
|
||||
if not cons:
|
||||
return jsonify({'error': 'Consignment not found'}), 404
|
||||
return jsonify(cons)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/consignments/<int:consignment_id>/items', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def add_consignment_item(consignment_id):
|
||||
"""Attach a piece to a consignment (sets inventory.ownership='consigna')."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
item = yonke_service.add_consignment_item(conn, consignment_id, data)
|
||||
conn.commit()
|
||||
return jsonify(item), 201
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/consignments/<int:consignment_id>/settle', methods=['PUT'])
|
||||
@require_auth('yonke.manage')
|
||||
def settle_consignment(consignment_id):
|
||||
"""Settle consignment items and return the payout summary."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
summary = yonke_service.settle_consignment(
|
||||
conn, consignment_id, item_ids=data.get('item_ids')
|
||||
)
|
||||
if not summary:
|
||||
conn.rollback()
|
||||
return jsonify({'error': 'Consignment not found'}), 404
|
||||
conn.commit()
|
||||
return jsonify(summary)
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# A5 — Donor intake, reports, statements, labels, ML publish, bulk, AI
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _public_base_url():
|
||||
"""Tenant public base URL from the request (reverse-proxy aware)."""
|
||||
proto = request.headers.get('X-Forwarded-Proto', request.scheme)
|
||||
host = request.headers.get('X-Forwarded-Host', request.host)
|
||||
cf = request.headers.get('CF-Visitor')
|
||||
if cf and '"scheme":"https"' in cf:
|
||||
proto = 'https'
|
||||
if proto == 'http' and ('nexusautoparts.com.mx' in host
|
||||
or request.headers.get('X-Forwarded-Ssl') == 'on'):
|
||||
proto = 'https'
|
||||
return f"{proto}://{host}/"
|
||||
|
||||
|
||||
def _tenant_display_name():
|
||||
"""Best-effort business name (from the master DB). Never raises."""
|
||||
try:
|
||||
from tenant_db import get_master_conn
|
||||
conn = get_master_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT name FROM tenants WHERE id = %s", (g.tenant_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return (row[0] if row else None) or None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _piece_or_404(conn, piece_id):
|
||||
piece = yonke_service.get_piece(conn, piece_id, branch_id=_branch_id())
|
||||
if not piece:
|
||||
return None, (jsonify({'error': 'Piece not found'}), 404)
|
||||
return piece, None
|
||||
|
||||
|
||||
def _read_any_image(piece):
|
||||
"""Image bytes for AI endpoints: upload first, then the piece's stored photo."""
|
||||
image_bytes, image_b64, err = _read_image_request()
|
||||
if err:
|
||||
return None, None, err
|
||||
if not image_bytes and not image_b64:
|
||||
image_bytes = _read_local_image(_piece_primary_image_url(piece))
|
||||
if not image_bytes:
|
||||
return None, None, (jsonify({
|
||||
'error': 'No image provided and the piece has no stored photo'
|
||||
}), 400)
|
||||
return image_bytes, image_b64, None
|
||||
|
||||
|
||||
# ─── 1. Full donor intake ──────────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/donors/full', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def create_donor_full():
|
||||
"""Register a donor vehicle and all its pieces in one atomic call."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = yonke_service.create_donor_full(
|
||||
conn, data, branch_id=_branch_id(data))
|
||||
conn.commit()
|
||||
return jsonify(result), 201
|
||||
except DuplicatePartNumber:
|
||||
conn.rollback()
|
||||
return jsonify({'error': 'part_number already exists'}), 409
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 2. Cost allocation across a donor's pieces ────────────────────────────
|
||||
|
||||
@yonke_bp.route('/donors/<vin>/allocate-cost', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def allocate_donor_cost(vin):
|
||||
"""Spread a donor's acquisition cost across its pieces."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = yonke_service.allocate_donor_cost(
|
||||
conn, vin, data.get('total_cost'), method=data.get('method'))
|
||||
if result is None:
|
||||
conn.rollback()
|
||||
return jsonify({'error': 'Donor not found or has no pieces'}), 404
|
||||
conn.commit()
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 3. Aging report ───────────────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/reports/aging', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def aging_report():
|
||||
"""Pieces in the yard for at least `days` days (default 90)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = yonke_service.aging_report(conn, days=_int_arg('days', 90, 0, 100000))
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 4. Summary report ─────────────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/reports/summary', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def summary_report():
|
||||
"""Donor breakdown, margins, invested capital and grade mix."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
return jsonify(yonke_service.summary_report(conn))
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 5-6. Consignment statement (+ print) ──────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/consignments/<int:consignment_id>/statement', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def consignment_statement(consignment_id):
|
||||
"""Consignor account statement for an optional [from, to] period."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
statement = yonke_service.consignment_statement(
|
||||
conn, consignment_id,
|
||||
date_from=request.args.get('from'), date_to=request.args.get('to'))
|
||||
if not statement:
|
||||
return jsonify({'error': 'Consignment not found'}), 404
|
||||
return jsonify(statement)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/consignments/<int:consignment_id>/statement/print', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def consignment_statement_print(consignment_id):
|
||||
"""Printable HTML for the consignor statement."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
statement = yonke_service.consignment_statement(
|
||||
conn, consignment_id,
|
||||
date_from=request.args.get('from'), date_to=request.args.get('to'))
|
||||
if not statement:
|
||||
return Response('<h1>404</h1>', status=404, mimetype='text/html')
|
||||
html = yonke_service.render_statement_html(statement, _tenant_display_name())
|
||||
return Response(html, mimetype='text/html')
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 7. Publish one piece to MercadoLibre ──────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/publish-meli', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def publish_piece_meli(piece_id):
|
||||
"""Publish this piece to MercadoLibre (degrades if ML is not configured)."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = yonke_service.publish_piece_to_meli(
|
||||
conn, piece_id,
|
||||
category_id=data.get('category_id'),
|
||||
price=data.get('price'),
|
||||
stock=data.get('stock'),
|
||||
branch_id=_branch_id(data),
|
||||
listing_type=data.get('listing_type'),
|
||||
shipping_mode=data.get('shipping_mode'),
|
||||
base_url=_public_base_url(),
|
||||
)
|
||||
if result.get('not_found'):
|
||||
conn.rollback()
|
||||
return jsonify({'ok': False, 'error': result.get('error')}), 404
|
||||
conn.commit()
|
||||
return jsonify(result), (200 if result.get('ok') else 400)
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'ok': False, 'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 8. Bulk CSV import ────────────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/pieces/bulk', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def bulk_create_pieces():
|
||||
"""Create pieces in bulk from a CSV (multipart `file` or raw text body)."""
|
||||
upload = request.files.get('file') or request.files.get('csv')
|
||||
if upload and upload.filename:
|
||||
raw = upload.read()
|
||||
text = raw.decode('utf-8-sig', 'replace') if isinstance(raw, bytes) else str(raw)
|
||||
elif request.is_json:
|
||||
data = request.get_json(silent=True) or {}
|
||||
text = (data.get('csv') or '').strip()
|
||||
if not text:
|
||||
return jsonify({'error': 'CSV file required'}), 400
|
||||
else:
|
||||
raw = request.get_data() or b''
|
||||
if not raw:
|
||||
return jsonify({'error': 'CSV file required'}), 400
|
||||
text = raw.decode('utf-8-sig', 'replace')
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = yonke_service.bulk_create_pieces(
|
||||
conn, text, branch_id=_branch_id())
|
||||
conn.commit()
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 9. Vehicle compatibility ("sirve para") ───────────────────────────────
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/compat', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def list_piece_compat(piece_id):
|
||||
"""List the vehicles this piece fits."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
_, err = _piece_or_404(conn, piece_id)
|
||||
if err:
|
||||
return err
|
||||
return jsonify({'data': yonke_service.list_piece_compat(conn, piece_id)})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/compat', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def add_piece_compat(piece_id):
|
||||
"""Add manual vehicles ({make, model, year}) to a piece."""
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items')
|
||||
if not isinstance(items, list) or not items:
|
||||
return jsonify({'error': 'items required (list of {make, model, year})'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
_, err = _piece_or_404(conn, piece_id)
|
||||
if err:
|
||||
return err
|
||||
added = yonke_service.add_piece_compat(conn, piece_id, items)
|
||||
conn.commit()
|
||||
return jsonify({'added': added, 'count': len(added)}), 201
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 10. Printable piece label (barcode) ───────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/label', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def piece_label(piece_id):
|
||||
"""Printable HTML label with a Code39 barcode of the auto-SKU."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
piece, err = _piece_or_404(conn, piece_id)
|
||||
if err:
|
||||
return err
|
||||
html = yonke_service.render_label_html(conn, piece, _tenant_display_name())
|
||||
return Response(html, mimetype='text/html')
|
||||
except Exception as e:
|
||||
return Response(f'<h1>Error</h1><p>{e}</p>', status=500, mimetype='text/html')
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── 11. Non-conversational AI ─────────────────────────────────────────────
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/identify', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def identify_piece(piece_id):
|
||||
"""Vision identify: {name, category, confidence} (503 when AI is down)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
piece, err = _piece_or_404(conn, piece_id)
|
||||
if err:
|
||||
return err
|
||||
image_bytes, image_b64, err = _read_any_image(piece)
|
||||
if err:
|
||||
return err
|
||||
|
||||
from services import yonke_ai
|
||||
result = yonke_ai.identify_piece(
|
||||
image_bytes=image_bytes, image_base64=image_b64,
|
||||
hint_name=piece.get('name'))
|
||||
status = 200 if result.get('available') else 503
|
||||
return jsonify(result), status
|
||||
except Exception as e:
|
||||
return jsonify({'name': None, 'category': None, 'confidence': 0.0,
|
||||
'available': False, 'error': str(e)}), 503
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/price-suggest', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def price_suggest(piece_id):
|
||||
"""Rule-based price suggestion using cost/grade plus local comparables."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
piece, err = _piece_or_404(conn, piece_id)
|
||||
if err:
|
||||
return err
|
||||
from services import yonke_ai
|
||||
return jsonify(yonke_ai.suggest_price(conn, piece))
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/duplicates', methods=['GET'])
|
||||
@require_auth('yonke.view')
|
||||
def duplicates():
|
||||
"""Groups of near-duplicate used pieces (semantic similarity)."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
from services import yonke_ai
|
||||
threshold = request.args.get('threshold', 0.85)
|
||||
return jsonify(yonke_ai.find_duplicate_groups(conn, threshold=threshold))
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@yonke_bp.route('/pieces/<int:piece_id>/validate-photo', methods=['POST'])
|
||||
@require_auth('yonke.manage')
|
||||
def validate_photo(piece_id):
|
||||
"""Vision check: is it an auto part with usable photo quality?"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
piece, err = _piece_or_404(conn, piece_id)
|
||||
if err:
|
||||
return err
|
||||
image_bytes, image_b64, err = _read_any_image(piece)
|
||||
if err:
|
||||
return err
|
||||
from services import yonke_ai
|
||||
return jsonify(yonke_ai.validate_photo(
|
||||
image_bytes=image_bytes, image_base64=image_b64))
|
||||
except Exception as e:
|
||||
return jsonify({'ok': True, 'issues': [], 'available': False,
|
||||
'error': str(e)})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -90,26 +90,6 @@ 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', '')
|
||||
|
||||
@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
|
||||
# Check permissions
|
||||
if required_permissions:
|
||||
missing = set(required_permissions) - g.permissions
|
||||
# owner/admin roles bypass all permission checks
|
||||
if g.employee_role not in ('owner', 'admin') and missing:
|
||||
# owner role bypasses all permission checks
|
||||
if g.employee_role != 'owner' and missing:
|
||||
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
|
||||
|
||||
def has_permission(permission):
|
||||
"""Check if current user has a specific permission. Use inside a route."""
|
||||
return g.employee_role in ('owner', 'admin') or permission in g.permissions
|
||||
return g.employee_role == 'owner' or permission in g.permissions
|
||||
|
||||
@@ -89,10 +89,8 @@ def resolve_tenant():
|
||||
g.tenant_name = None
|
||||
g.tenant_subdomain = None
|
||||
|
||||
# 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'):
|
||||
# Skip for static files and health check
|
||||
if request.path.startswith('/pos/static/') or request.path == '/pos/health':
|
||||
return
|
||||
|
||||
subdomain = _extract_subdomain()
|
||||
|
||||
@@ -52,29 +52,6 @@ MIGRATIONS = {
|
||||
"v4.3": "v4.3_facturapi.sql",
|
||||
"v4.4": "v4.4_workshop.sql",
|
||||
"v4.5": "v4.5_customer_max_discount.sql",
|
||||
"v4.6": "v4.6_inventory_support.sql",
|
||||
"v4.7": "v4.7_workshop_business.sql",
|
||||
"v4.8": "v4.8_workshop_permissions.sql",
|
||||
"v4.9": "v4.9_workshop_customers_view.sql",
|
||||
"v4.10": "v4.10_fleet_permissions.sql",
|
||||
"v4.11": "v4.11_clean_workshop_mechanic_permissions.sql",
|
||||
"v4.12": "v4.12_service_order_soft_delete.sql",
|
||||
"v4.13": "v4.13_fleet_vehicle_customer.sql",
|
||||
"v4.14": "v4.14_service_order_delivery_cleanup.sql",
|
||||
"v4.15": "v4.15_counter_remission.sql",
|
||||
"v4.16": "v4.16_remission_courier.sql",
|
||||
"v4.17": "v4.17_service_order_invoice.sql",
|
||||
"v4.18": "v4.18_rached_workshop.sql",
|
||||
"v4.19": "v4.19_counter_inventory_create.sql",
|
||||
"v4.20": "v4.20_cashier_sell_permissions.sql",
|
||||
"v4.21": "v4.21_cashier_workshop_permissions.sql",
|
||||
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
|
||||
"v4.23": "v4.23_service_order_mechanic_name.sql",
|
||||
"v4.24": "v4.24_trgm_search_indexes.sql",
|
||||
"v4.25": "v4.25_transfer_documents.sql",
|
||||
"v4.26": "v4.26_sales_client_uuid.sql",
|
||||
"v4.27": "v4.27_yonke_m1.sql",
|
||||
"v4.28": "v4.28_meli_commerce.sql",
|
||||
}
|
||||
|
||||
|
||||
@@ -130,10 +107,7 @@ def apply_migration(db_name, version):
|
||||
def run_migrations():
|
||||
"""Apply pending migrations to all tenants."""
|
||||
tenants = get_all_tenants()
|
||||
def _version_key(v):
|
||||
return tuple(int(x) for x in v.lstrip('v').split('.'))
|
||||
|
||||
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
|
||||
sorted_versions = sorted(MIGRATIONS.keys())
|
||||
|
||||
print(f"Found {len(tenants)} active tenants")
|
||||
print(f"Available migrations: {sorted_versions}")
|
||||
@@ -141,9 +115,8 @@ def run_migrations():
|
||||
for tenant_id, db_name, name, current_version in tenants:
|
||||
print(f"\n[{name}] (db={db_name}, current={current_version})")
|
||||
|
||||
current_key = _version_key(current_version)
|
||||
for version in sorted_versions:
|
||||
if _version_key(version) <= current_key:
|
||||
if version <= current_version:
|
||||
continue
|
||||
|
||||
print(f" Applying {version}...", end=" ")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
-- : SKIP
|
||||
-- v1.2: Add subdomain column to tenants table (nexus_master)
|
||||
-- This migration runs against the MASTER database, not tenant databases.
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
-- : SKIP
|
||||
-- Marketplace orders (cross-tenant)
|
||||
-- These tables go in nexus_autoparts (master DB), not tenant DBs
|
||||
|
||||
|
||||
@@ -2,20 +2,15 @@
|
||||
-- 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.
|
||||
|
||||
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 $$;
|
||||
-- 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;
|
||||
|
||||
-- ─── TENANT DB (run on each tenant) ────────────
|
||||
|
||||
|
||||
@@ -10,12 +10,9 @@
|
||||
-- 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
|
||||
@@ -44,12 +41,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 $fn$
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$fn$ LANGUAGE plpgsql;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
|
||||
CREATE TRIGGER trg_wa_link_updated
|
||||
@@ -118,4 +115,4 @@ BEGIN
|
||||
FROM wa_customer_links wcl
|
||||
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
|
||||
END IF;
|
||||
END $do$;
|
||||
END $$;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
-- v4.10: add fleet RBAC permissions for existing employees.
|
||||
-- Owner and admin get full fleet access; accountant and workshop get read access.
|
||||
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('fleet.view'),
|
||||
('fleet.create'),
|
||||
('fleet.edit'),
|
||||
('fleet.delete')
|
||||
) AS p(perm)
|
||||
WHERE e.role IN ('owner', 'admin')
|
||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
||||
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, 'fleet.view'
|
||||
FROM employees e
|
||||
WHERE e.role IN ('accountant', 'workshop')
|
||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
||||
@@ -1,24 +0,0 @@
|
||||
-- v4.11_clean_workshop_mechanic_permissions.sql
|
||||
-- Restringe los permisos de los empleados con rol 'workshop' y 'mechanic'
|
||||
-- para que solo tengan acceso a la seccion de Taller y no vean precios.
|
||||
-- Ejecutar en cada base de datos de tenant.
|
||||
|
||||
-- 1. Eliminar cualquier permiso que no sea de taller para estos roles
|
||||
DELETE FROM employee_permissions
|
||||
WHERE employee_id IN (
|
||||
SELECT id FROM employees WHERE role IN ('workshop', 'mechanic')
|
||||
)
|
||||
AND permission NOT IN ('workshop.view', 'workshop.edit', 'workshop.add_items');
|
||||
|
||||
-- 2. Asegurar que tengan los permisos base de taller
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.permission
|
||||
FROM employees e
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('workshop.view'),
|
||||
('workshop.edit'),
|
||||
('workshop.add_items')
|
||||
) AS p(permission)
|
||||
WHERE e.role IN ('workshop', 'mechanic')
|
||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- v4.12_service_order_soft_delete.sql
|
||||
-- Soft-delete support for service orders.
|
||||
|
||||
ALTER TABLE service_orders
|
||||
ADD COLUMN IF NOT EXISTS is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_is_deleted
|
||||
ON service_orders (is_deleted);
|
||||
|
||||
-- Exclude deleted orders from common queries by default via partial index helpers
|
||||
-- (application filters explicitly).
|
||||
@@ -1,20 +0,0 @@
|
||||
-- v4.13_fleet_vehicle_customer.sql
|
||||
-- Link fleet vehicles to a customer.
|
||||
|
||||
ALTER TABLE fleet_vehicles
|
||||
ADD COLUMN IF NOT EXISTS customer_id INTEGER;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'fleet_vehicles_customer_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE fleet_vehicles
|
||||
ADD CONSTRAINT fleet_vehicles_customer_id_fkey
|
||||
FOREIGN KEY (customer_id) REFERENCES customers(id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_customer_id
|
||||
ON fleet_vehicles (customer_id);
|
||||
@@ -1,10 +0,0 @@
|
||||
-- Normalize service order delivery options to only "pickup" (mostrador) and "delivery" (a domicilio).
|
||||
-- Convert legacy "courier" records to pickup and clear courier_id when delivery is not "delivery".
|
||||
|
||||
UPDATE service_orders
|
||||
SET delivery_method = 'pickup'
|
||||
WHERE delivery_method = 'courier';
|
||||
|
||||
UPDATE service_orders
|
||||
SET courier_id = NULL
|
||||
WHERE delivery_method IS NULL OR delivery_method != 'delivery';
|
||||
@@ -1,18 +0,0 @@
|
||||
-- v4.15: Counter remission note support
|
||||
-- Ensures employees with role 'counter' have the base permissions needed to
|
||||
-- generate remission notes from the POS.
|
||||
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.permission
|
||||
FROM employees e
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('pos.remission'),
|
||||
('pos.view'),
|
||||
('catalog.view'),
|
||||
('inventory.view'),
|
||||
('customers.view'),
|
||||
('customers.create')
|
||||
) AS p(permission)
|
||||
WHERE e.role = 'counter'
|
||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- v4.16: Add courier assignment to counter remission notes.
|
||||
|
||||
ALTER TABLE sales
|
||||
ADD COLUMN IF NOT EXISTS courier_id INTEGER;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'sales_courier_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE sales
|
||||
ADD CONSTRAINT sales_courier_id_fkey
|
||||
FOREIGN KEY (courier_id) REFERENCES couriers(id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_courier_id ON sales(courier_id);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Service order invoice requirement flag
|
||||
ALTER TABLE service_orders
|
||||
ADD COLUMN IF NOT EXISTS requires_invoice BOOLEAN DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_requires_invoice
|
||||
ON service_orders(requires_invoice);
|
||||
@@ -1,32 +0,0 @@
|
||||
-- v4.18 Rached workshop fields
|
||||
-- Extends service orders and items to match the Rached legacy workshop flow.
|
||||
-- Applied to all tenants.
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 1. SERVICE_ORDERS: capture free-text customer/vehicle/workshop data
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE service_orders
|
||||
ADD COLUMN IF NOT EXISTS workshop_name VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS customer_address TEXT,
|
||||
ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(300);
|
||||
|
||||
COMMENT ON COLUMN service_orders.workshop_name IS 'Free-text workshop/customer alias (Rached "Taller")';
|
||||
COMMENT ON COLUMN service_orders.customer_address IS 'Address captured or imported for the service order';
|
||||
COMMENT ON COLUMN service_orders.customer_phone IS 'Phone captured or imported for the service order';
|
||||
COMMENT ON COLUMN service_orders.vehicle_description IS 'Free-text vehicle description (alternative to fleet_vehicles)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_workshop_name ON service_orders(workshop_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_description ON service_orders(vehicle_description);
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 2. SERVICE_ORDER_ITEMS: mechanic per item + explicit observations
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE service_order_items
|
||||
ADD COLUMN IF NOT EXISTS mechanic_id INTEGER REFERENCES employees(id),
|
||||
ADD COLUMN IF NOT EXISTS observations TEXT;
|
||||
|
||||
COMMENT ON COLUMN service_order_items.mechanic_id IS 'Mechanic assigned to this specific line item';
|
||||
COMMENT ON COLUMN service_order_items.observations IS 'Line-item observations (Rached detail notes)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_order_items_mechanic_id ON service_order_items(mechanic_id);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- v4.19 — Grant inventory.create permission to existing counter employees
|
||||
-- so they can create items and record purchase entries.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, 'inventory.create'
|
||||
FROM employees e
|
||||
WHERE e.role = 'counter'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = 'inventory.create'
|
||||
);
|
||||
@@ -1,18 +0,0 @@
|
||||
-- v4.20 — Ensure existing cashier employees can sell and view quotations.
|
||||
-- The cashier default set already includes these permissions, but employees
|
||||
-- created before the fix may be missing them.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('pos.sell'),
|
||||
('pos.discount'),
|
||||
('pos.cancel'),
|
||||
('pos.view')
|
||||
) AS p(perm)
|
||||
WHERE e.role = 'cashier'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- v4.21 — Allow existing cashier employees to create and edit service orders.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('workshop.view'),
|
||||
('workshop.edit'),
|
||||
('workshop.add_items')
|
||||
) AS p(perm)
|
||||
WHERE e.role = 'cashier'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- v4.22 -- Enable invoicing for existing cashier employees.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('invoicing.view'),
|
||||
('invoicing.create'),
|
||||
('invoicing.cancel')
|
||||
) AS p(perm)
|
||||
WHERE e.role = 'cashier'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||
);
|
||||
@@ -1,2 +0,0 @@
|
||||
-- v4.23 -- Add free-text mechanic name for service orders.
|
||||
ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS mechanic_name TEXT;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- v4.24: pg_trgm search indexes for fast fuzzy inventory search.
|
||||
-- The POS list endpoint uses ILIKE '%q%' on part_number/name/barcode/aliases;
|
||||
-- btree indexes cannot serve those. Trigram GIN indexes make them fast even
|
||||
-- with 150k+ SKU catalogs.
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_part_trgm
|
||||
ON inventory USING gin (part_number gin_trgm_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_name_trgm
|
||||
ON inventory USING gin (name gin_trgm_ops);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_alias_trgm
|
||||
ON inventory_sku_aliases USING gin (sku gin_trgm_ops);
|
||||
|
||||
ANALYZE inventory;
|
||||
ANALYZE inventory_sku_aliases;
|
||||
@@ -1,28 +0,0 @@
|
||||
-- v4.25: transfer documents with in-transit state.
|
||||
-- Transfers become documents: draft -> sent -> received (or cancelled).
|
||||
-- Actual stock movement happens on 'receive' (origin -, destination +),
|
||||
-- so goods in transit are tracked and discrepancies are explicit.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_transfers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
from_branch_id INTEGER NOT NULL REFERENCES branches(id),
|
||||
to_branch_id INTEGER NOT NULL REFERENCES branches(id),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft | sent | received | cancelled
|
||||
notes TEXT,
|
||||
employee_id INTEGER,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
sent_at TIMESTAMPTZ,
|
||||
received_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_transfer_lines (
|
||||
id SERIAL PRIMARY KEY,
|
||||
transfer_id INTEGER NOT NULL REFERENCES inventory_transfers(id) ON DELETE CASCADE,
|
||||
inventory_id INTEGER NOT NULL REFERENCES inventory(id),
|
||||
quantity INTEGER NOT NULL,
|
||||
qty_received INTEGER,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transfers_status ON inventory_transfers(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_transfer_lines_transfer ON inventory_transfer_lines(transfer_id);
|
||||
@@ -1,4 +0,0 @@
|
||||
-- v4.26: idempotencia para venta offline (client_uuid generado por el POS).
|
||||
ALTER TABLE sales ADD COLUMN IF NOT EXISTS client_uuid VARCHAR(64);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_sales_client_uuid
|
||||
ON sales (client_uuid) WHERE client_uuid IS NOT NULL;
|
||||
@@ -1,113 +0,0 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- v4.27 — Yonke M1: used-parts (parts-usadas) data model
|
||||
-- Additive + idempotent. Runs against tenant DBs.
|
||||
--
|
||||
-- Enables businesses that sell ONLY used parts (yonkes/deshuesaderos):
|
||||
-- * condition (nuevo/usado/reconstruido) + grade (A/B/C/D)
|
||||
-- * unique 1-of-1 pieces (is_unique_piece) with a donor vehicle
|
||||
-- * multiple photos per item (inventory_images)
|
||||
-- * ownership (propio/consigna) + consignment tables
|
||||
-- * warranty expressed in days (for used parts)
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ─── 1. inventory: condition / donor / ownership / unique-piece columns ─────
|
||||
ALTER TABLE inventory
|
||||
ADD COLUMN IF NOT EXISTS condition VARCHAR(20) DEFAULT 'nuevo',
|
||||
ADD COLUMN IF NOT EXISTS condition_grade VARCHAR(2),
|
||||
ADD COLUMN IF NOT EXISTS is_unique_piece BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS serial_number VARCHAR(100),
|
||||
ADD COLUMN IF NOT EXISTS donor_vin VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS donor_make VARCHAR(60),
|
||||
ADD COLUMN IF NOT EXISTS donor_model VARCHAR(60),
|
||||
ADD COLUMN IF NOT EXISTS donor_year INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS donor_mileage_km INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS ownership VARCHAR(20) DEFAULT 'propio',
|
||||
ADD COLUMN IF NOT EXISTS acquisition_cost NUMERIC(12,2);
|
||||
|
||||
-- CHECK constraints (guarded so re-runs are safe)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'inventory_condition_chk') THEN
|
||||
ALTER TABLE inventory ADD CONSTRAINT inventory_condition_chk
|
||||
CHECK (condition IS NULL OR condition IN ('nuevo','usado','reconstruido'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'inventory_ownership_chk') THEN
|
||||
ALTER TABLE inventory ADD CONSTRAINT inventory_ownership_chk
|
||||
CHECK (ownership IS NULL OR ownership IN ('propio','consigna'));
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'inventory_grade_chk') THEN
|
||||
ALTER TABLE inventory ADD CONSTRAINT inventory_grade_chk
|
||||
CHECK (condition_grade IS NULL OR condition_grade IN ('A','B','C','D'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_condition
|
||||
ON inventory(condition);
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_donor_vin
|
||||
ON inventory(donor_vin) WHERE donor_vin IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_donor_vehicle
|
||||
ON inventory(donor_make, donor_model, donor_year) WHERE is_unique_piece;
|
||||
|
||||
-- ─── 2. Relax part_number NOT NULL so codeless unique pieces can exist ──────
|
||||
-- A standard UNIQUE index in PostgreSQL already treats NULLs as distinct, so
|
||||
-- the existing full unique index (idx_inventory_part_unique) keeps enforcing
|
||||
-- uniqueness on non-null codes while allowing many NULLs. We deliberately do
|
||||
-- NOT turn it into a partial index: that would break `ON CONFLICT (part_number)`
|
||||
-- used by the importers.
|
||||
ALTER TABLE inventory ALTER COLUMN part_number DROP NOT NULL;
|
||||
|
||||
-- ─── 3. inventory_images: multiple photos per item ─────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS inventory_images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_images_inv
|
||||
ON inventory_images(inventory_id, sort_order);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_inventory_images_primary
|
||||
ON inventory_images(inventory_id) WHERE is_primary;
|
||||
|
||||
-- Migrate the existing single image_url into the new table (first photo).
|
||||
INSERT INTO inventory_images (inventory_id, url, sort_order, is_primary)
|
||||
SELECT i.id, i.image_url, 0, TRUE
|
||||
FROM inventory i
|
||||
WHERE i.image_url IS NOT NULL AND i.image_url <> ''
|
||||
AND NOT EXISTS (SELECT 1 FROM inventory_images ii WHERE ii.inventory_id = i.id);
|
||||
|
||||
-- ─── 4. warranties: express warranty in days (used parts) ──────────────────
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('public.warranties') IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE warranties ADD COLUMN IF NOT EXISTS warranty_days INTEGER';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ─── 5. Consignments (third-party parts held for sale) ─────────────────────
|
||||
CREATE TABLE IF NOT EXISTS consignments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
consignor_name VARCHAR(200) NOT NULL,
|
||||
consignor_rfc VARCHAR(13),
|
||||
consignor_phone VARCHAR(30),
|
||||
commission_pct NUMERIC(5,2) DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open', -- open | closed | settled
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS consignment_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
consignment_id INTEGER NOT NULL REFERENCES consignments(id) ON DELETE CASCADE,
|
||||
inventory_id INTEGER REFERENCES inventory(id) ON DELETE SET NULL,
|
||||
agreed_price NUMERIC(12,2),
|
||||
commission_amount NUMERIC(12,2),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'in_stock', -- in_stock | sold | returned | settled
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_items_cons
|
||||
ON consignment_items(consignment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_consignment_items_inv
|
||||
ON consignment_items(inventory_id);
|
||||
@@ -1,56 +0,0 @@
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- v4.28 — MeLi commerce: listing economics, inventory-match metadata
|
||||
-- Additive + idempotent. Runs against tenant DBs.
|
||||
--
|
||||
-- Goal: make MercadoLibre listings sellable end-to-end by persisting, per
|
||||
-- listing, the data that today is only computed transiently:
|
||||
-- * how the ML item was linked to local inventory (backfill audit)
|
||||
-- * the ML commission and the resulting net price / margin
|
||||
-- * the cost at the moment of publishing (so margin stays comparable)
|
||||
-- * the listing type and the description actually published
|
||||
--
|
||||
-- Also normalizes marketplace_questions.status to lowercase: ML returns
|
||||
-- "UNANSWERED"/"ANSWERED" and the auto-answer/SLA queries filter lowercase.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ─── 1. marketplace_listings: match metadata + economics ────────────────────
|
||||
ALTER TABLE marketplace_listings
|
||||
ADD COLUMN IF NOT EXISTS listing_type_id VARCHAR(30),
|
||||
ADD COLUMN IF NOT EXISTS inventory_match_source VARCHAR(40),
|
||||
ADD COLUMN IF NOT EXISTS inventory_matched_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS meli_fee NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS meli_fee_pct NUMERIC(6,2),
|
||||
ADD COLUMN IF NOT EXISTS meli_net_price NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS meli_fee_source VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS cost_at_publish NUMERIC(12,2),
|
||||
ADD COLUMN IF NOT EXISTS margin_pct NUMERIC(8,2),
|
||||
ADD COLUMN IF NOT EXISTS meli_description TEXT,
|
||||
ADD COLUMN IF NOT EXISTS economics_updated_at TIMESTAMPTZ;
|
||||
|
||||
-- Fast lookup of the listings that still need an inventory match (backfill).
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_unmapped
|
||||
ON marketplace_listings(channel)
|
||||
WHERE inventory_id IS NULL;
|
||||
|
||||
-- ─── 2. marketplace_questions: status normalization ─────────────────────────
|
||||
-- ML's API returns "UNANSWERED" / "ANSWERED"; the service compares lowercase.
|
||||
-- Normalize historical rows so auto-answer and the SLA job see them.
|
||||
UPDATE marketplace_questions
|
||||
SET status = LOWER(status), updated_at = NOW()
|
||||
WHERE status IS NOT NULL AND status <> LOWER(status);
|
||||
|
||||
-- Index for the SLA sweep: oldest unanswered questions first.
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_status_date
|
||||
ON marketplace_questions(status, question_date);
|
||||
|
||||
-- ─── 3. Allow several active ML listings per local product ──────────────────
|
||||
-- The original v3.4 unique index assumed one ML listing per inventory item.
|
||||
-- Real MeLi catalogs routinely publish several listings (different fitments /
|
||||
-- titles) that share the same internal part number; the inventory backfill
|
||||
-- must be able to link them all, and order conversion maps each order item by
|
||||
-- external_item_id. Replace the uniqueness with a plain lookup index — the
|
||||
-- publish path now upserts by (inventory_id, channel) explicitly.
|
||||
DROP INDEX IF EXISTS idx_marketplace_listings_unique;
|
||||
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_inv_channel
|
||||
ON marketplace_listings(inventory_id, channel) WHERE is_active = true;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
-- Tenant DB schema v4.6 — support tables for inventory UI
|
||||
|
||||
-- Product categories (hierarchical, optional)
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_active ON categories(is_active);
|
||||
|
||||
-- Tier-based discount configuration for price tiers 2 and 3
|
||||
CREATE TABLE IF NOT EXISTS tier_discounts (
|
||||
tier_id INTEGER PRIMARY KEY,
|
||||
tier_name VARCHAR(50) NOT NULL,
|
||||
discount_pct NUMERIC(5,2) DEFAULT 0
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
-- v4.6 Workshop business fields
|
||||
-- Adds delivery method, courier assignment and direct-order flag to service orders.
|
||||
|
||||
ALTER TABLE service_orders
|
||||
ADD COLUMN IF NOT EXISTS delivery_method VARCHAR(30),
|
||||
ADD COLUMN IF NOT EXISTS courier_id INTEGER REFERENCES couriers(id),
|
||||
ADD COLUMN IF NOT EXISTS is_direct BOOLEAN DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_delivery_method ON service_orders(delivery_method);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_courier_id ON service_orders(courier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_is_direct ON service_orders(is_direct);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- v4.8 Workshop permissions seed
|
||||
-- Grants workshop permissions to existing admin employees so they keep access
|
||||
-- after the new role-based restrictions are enforced.
|
||||
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT id, 'workshop.view'
|
||||
FROM employees
|
||||
WHERE role = 'admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT id, 'workshop.edit'
|
||||
FROM employees
|
||||
WHERE role = 'admin'
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- v4.9: Grant customers.view permission to existing workshop employees
|
||||
-- so they can load the customer list when creating service orders.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, 'customers.view'
|
||||
FROM employees e
|
||||
WHERE e.role = 'workshop'
|
||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
||||
@@ -29,8 +29,6 @@ INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_activ
|
||||
VALUES ('220', 'IVA Trasladado', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '220', true, true);
|
||||
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||
VALUES ('230', 'ISR por Pagar', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '230', true, true);
|
||||
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||
VALUES ('240', 'Anticipos de Clientes', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '240', true, true);
|
||||
|
||||
-- ============================================================
|
||||
-- CAPITAL (300)
|
||||
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
@@ -21,13 +20,10 @@ 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."""
|
||||
@@ -50,64 +46,18 @@ def _get_account_id(cur, code):
|
||||
|
||||
def _get_account_ids(cur, codes):
|
||||
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
||||
cur.execute(
|
||||
"SELECT code, id FROM accounts WHERE code = ANY(%s) AND is_active = true",
|
||||
(list(codes),)
|
||||
)
|
||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||
missing = set(codes) - set(rows)
|
||||
if missing:
|
||||
raise ValueError(f"Account(s) with code {sorted(missing)} not found")
|
||||
return rows
|
||||
|
||||
|
||||
def _ensure_account(cur, code, name, parent_code, acc_type):
|
||||
"""Return the account ID for a code, creating it if it is missing.
|
||||
|
||||
Tenants seeded before an auxiliary account (e.g. customer advances) was
|
||||
introduced do not have it in their chart of accounts, so it is created
|
||||
on first use. Creation is idempotent (accounts.code is unique).
|
||||
|
||||
Args:
|
||||
cur: psycopg2 cursor
|
||||
code: account code to look up / create
|
||||
name: account name used when creating
|
||||
parent_code: code of the parent account (None for roots)
|
||||
acc_type: 'activo' | 'pasivo' | 'capital' | 'ingreso' | 'costo' | 'gasto'
|
||||
|
||||
Returns:
|
||||
int: account ID
|
||||
"""
|
||||
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
|
||||
parent_id = None
|
||||
if parent_code:
|
||||
cur.execute("SELECT id FROM accounts WHERE code = %s", (parent_code,))
|
||||
parent = cur.fetchone()
|
||||
parent_id = parent[0] if parent else None
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||
VALUES (%s, %s, %s, %s, %s, false, true)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
RETURNING id
|
||||
""", (code, name, parent_id, acc_type, code))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row[0]
|
||||
|
||||
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
|
||||
return cur.fetchone()[0]
|
||||
result = {}
|
||||
for code in codes:
|
||||
result[code] = _get_account_id(cur, code)
|
||||
return result
|
||||
|
||||
|
||||
def get_next_entry_number(conn):
|
||||
"""Get the next sequential journal entry number.
|
||||
|
||||
Uses a transaction-level advisory lock to prevent duplicate numbers
|
||||
when multiple journal entries are created concurrently.
|
||||
Uses a simple MAX+1 approach. For high-concurrency environments this
|
||||
could be replaced with a sequence, but for single-tenant refaccionarias
|
||||
the transaction-level lock from the INSERT is sufficient.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection to tenant DB
|
||||
@@ -116,7 +66,6 @@ def get_next_entry_number(conn):
|
||||
int: next entry number (starts at 1)
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT pg_advisory_xact_lock(hashtext('journal_entry_number'))")
|
||||
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
|
||||
number = cur.fetchone()[0]
|
||||
cur.close()
|
||||
@@ -622,434 +571,6 @@ def record_cancellation_entry(conn, sale):
|
||||
return entry_id
|
||||
|
||||
|
||||
def record_return_entry(conn, ret):
|
||||
"""Generate the journal entry for a customer return (devolucion).
|
||||
|
||||
Reverses the revenue side of the original sale and puts the returned
|
||||
goods back into inventory:
|
||||
Debit 420 Devoluciones sobre Ventas = net (after discount, before tax)
|
||||
Debit 220 IVA Trasladado = tax
|
||||
Credit 110/111/120 (refund account) = net + tax
|
||||
Debit 130 Inventarios = cost
|
||||
Credit 510 Costo de Mercancia Vendida = cost
|
||||
|
||||
The net/tax/cost split is read from return_items joined with sale_items,
|
||||
so callers only need the return id and the original sale.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection to tenant DB
|
||||
ret: dict with keys:
|
||||
id: return id
|
||||
sale_id: original sale id (optional)
|
||||
sale_type: 'cash' | 'credit' (optional, looked up from sales)
|
||||
payment_method: original payment method (optional, looked up)
|
||||
total: refund total (used only when the item detail is missing)
|
||||
|
||||
Returns:
|
||||
int: journal entry ID
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
entry_date = date.today()
|
||||
_check_period_open(cur, entry_date)
|
||||
|
||||
entry_number = get_next_entry_number(conn)
|
||||
return_id = ret['id']
|
||||
sale_id = ret.get('sale_id')
|
||||
sale_type = ret.get('sale_type')
|
||||
payment_method = ret.get('payment_method')
|
||||
|
||||
cur.execute("""
|
||||
SELECT si.unit_price, si.discount_pct, si.tax_rate, si.unit_cost, ri.quantity
|
||||
FROM return_items ri
|
||||
JOIN sale_items si ON si.id = ri.sale_item_id
|
||||
WHERE ri.return_id = %s
|
||||
""", (return_id,))
|
||||
item_rows = cur.fetchall()
|
||||
|
||||
if sale_id and (sale_type is None or payment_method is None):
|
||||
cur.execute("SELECT sale_type, payment_method FROM sales WHERE id = %s", (sale_id,))
|
||||
sale_row = cur.fetchone()
|
||||
if sale_row:
|
||||
sale_type = sale_type or sale_row[0]
|
||||
payment_method = payment_method or sale_row[1]
|
||||
|
||||
net = Decimal('0')
|
||||
tax = Decimal('0')
|
||||
cost_total = Decimal('0')
|
||||
for unit_price, discount_pct, tax_rate, unit_cost, quantity in item_rows:
|
||||
qty = int(quantity or 0)
|
||||
line_gross = (_to_dec(unit_price) * qty).quantize(TWO, ROUND_HALF_UP)
|
||||
line_net = (line_gross * (Decimal('1') - _to_dec(discount_pct) / Decimal('100'))
|
||||
).quantize(TWO, ROUND_HALF_UP)
|
||||
net += line_net
|
||||
tax += (line_net * _to_dec(tax_rate)).quantize(TWO, ROUND_HALF_UP)
|
||||
cost_total += (_to_dec(unit_cost) * qty).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
if net == 0 and tax == 0:
|
||||
# No item detail available: fall back to the refund total (no IVA split)
|
||||
net = _to_dec(ret.get('total', 0))
|
||||
|
||||
total = (net + tax).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '420', '510'])
|
||||
|
||||
if sale_type == 'credit':
|
||||
refund_account_id = accounts['120'] # Clientes
|
||||
refund_desc = f'Clientes - Devolucion #{return_id}'
|
||||
elif payment_method in ('transferencia', 'tarjeta'):
|
||||
refund_account_id = accounts['111'] # Bancos
|
||||
refund_desc = f'Bancos - Devolucion #{return_id}'
|
||||
else:
|
||||
refund_account_id = accounts['110'] # Caja
|
||||
refund_desc = f'Caja - Devolucion #{return_id}'
|
||||
|
||||
lines = [
|
||||
{
|
||||
'account_id': accounts['420'],
|
||||
'debit': float(net.quantize(TWO, ROUND_HALF_UP)),
|
||||
'credit': 0,
|
||||
'description': f'Devoluciones sobre ventas - Devolucion #{return_id}',
|
||||
},
|
||||
{
|
||||
'account_id': refund_account_id,
|
||||
'debit': 0,
|
||||
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
||||
'description': refund_desc,
|
||||
},
|
||||
]
|
||||
|
||||
if tax > 0:
|
||||
lines.append({
|
||||
'account_id': accounts['220'],
|
||||
'debit': float(tax.quantize(TWO, ROUND_HALF_UP)),
|
||||
'credit': 0,
|
||||
'description': f'IVA Trasladado - Devolucion #{return_id}',
|
||||
})
|
||||
|
||||
if cost_total > 0:
|
||||
lines.append({
|
||||
'account_id': accounts['130'],
|
||||
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
||||
'credit': 0,
|
||||
'description': f'Inventarios - Devolucion #{return_id}',
|
||||
})
|
||||
lines.append({
|
||||
'account_id': accounts['510'],
|
||||
'debit': 0,
|
||||
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
||||
'description': f'Costo mercancia - Devolucion #{return_id}',
|
||||
})
|
||||
|
||||
entry_id = _create_entry(
|
||||
cur, entry_number, entry_date, 'egreso',
|
||||
f'Devolucion #{return_id}' + (f' - Venta #{sale_id}' if sale_id else ''),
|
||||
'return', return_id, lines
|
||||
)
|
||||
|
||||
cur.close()
|
||||
return entry_id
|
||||
|
||||
|
||||
def record_layaway_entry(conn, layaway):
|
||||
"""Generate the journal entry for a layaway (apartado) payment.
|
||||
|
||||
A layaway deposit is money held on behalf of the customer, not revenue,
|
||||
so it is recorded as a customer advance:
|
||||
Debit 110 Caja (or 111 Bancos) = amount
|
||||
Credit 240 Anticipos de Clientes = amount
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
layaway: dict with keys:
|
||||
id: layaway id
|
||||
amount: float
|
||||
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
|
||||
|
||||
Returns:
|
||||
int or None: journal entry ID (None when amount <= 0)
|
||||
"""
|
||||
amount = _to_dec(layaway.get('amount', 0))
|
||||
if amount <= 0:
|
||||
return None
|
||||
|
||||
cur = conn.cursor()
|
||||
entry_date = date.today()
|
||||
_check_period_open(cur, entry_date)
|
||||
|
||||
entry_number = get_next_entry_number(conn)
|
||||
layaway_id = layaway['id']
|
||||
method = layaway.get('payment_method', 'efectivo')
|
||||
|
||||
accounts = _get_account_ids(cur, ['110', '111'])
|
||||
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
|
||||
|
||||
if method in ('transferencia', 'tarjeta'):
|
||||
debit_account = accounts['111']
|
||||
debit_desc = f'Bancos - Apartado #{layaway_id}'
|
||||
else:
|
||||
debit_account = accounts['110']
|
||||
debit_desc = f'Caja - Apartado #{layaway_id}'
|
||||
|
||||
lines = [
|
||||
{
|
||||
'account_id': debit_account,
|
||||
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
||||
'credit': 0,
|
||||
'description': debit_desc,
|
||||
},
|
||||
{
|
||||
'account_id': advance_account,
|
||||
'debit': 0,
|
||||
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
||||
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
|
||||
},
|
||||
]
|
||||
|
||||
entry_id = _create_entry(
|
||||
cur, entry_number, entry_date, 'ingreso',
|
||||
f'Apartado #{layaway_id} - anticipo ({method})',
|
||||
'layaway', layaway_id, lines
|
||||
)
|
||||
|
||||
cur.close()
|
||||
return entry_id
|
||||
|
||||
|
||||
def record_layaway_conversion_entry(conn, sale):
|
||||
"""Generate the journal entry for a fully paid layaway converted to a sale.
|
||||
|
||||
Applies the advances collected during the layaway and recognizes the
|
||||
revenue, trasladado IVA and cost of goods:
|
||||
Debit 240 Anticipos de Clientes = total
|
||||
Credit 410 Ventas = subtotal
|
||||
Credit 220 IVA Trasladado = tax_total
|
||||
Debit 510 Costo de Mercancia Vendida = cost
|
||||
Credit 130 Inventarios = cost
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
sale: dict with keys:
|
||||
id: sale id created for the layaway
|
||||
layaway_id: layaway id (optional)
|
||||
subtotal: float (net, after discounts, before tax)
|
||||
tax_total: float
|
||||
total: float (subtotal + tax_total)
|
||||
items: [{unit_cost, quantity}] (optional; read from DB when absent)
|
||||
|
||||
Returns:
|
||||
int: journal entry ID
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
entry_date = date.today()
|
||||
_check_period_open(cur, entry_date)
|
||||
|
||||
entry_number = get_next_entry_number(conn)
|
||||
sale_id = sale['id']
|
||||
layaway_id = sale.get('layaway_id')
|
||||
subtotal = _to_dec(sale['subtotal'])
|
||||
tax_total = _to_dec(sale['tax_total'])
|
||||
total = _to_dec(sale['total'])
|
||||
|
||||
items = sale.get('items')
|
||||
if not items:
|
||||
cur.execute("SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s", (sale_id,))
|
||||
items = [{'unit_cost': r[0], 'quantity': r[1]} for r in cur.fetchall()]
|
||||
|
||||
cost_total = Decimal('0')
|
||||
for item in items:
|
||||
cost_total += (_to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
|
||||
).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
accounts = _get_account_ids(cur, ['130', '220', '410', '510'])
|
||||
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
|
||||
|
||||
lines = [
|
||||
{
|
||||
'account_id': advance_account,
|
||||
'debit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
||||
'credit': 0,
|
||||
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
|
||||
},
|
||||
{
|
||||
'account_id': accounts['410'],
|
||||
'debit': 0,
|
||||
'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
||||
'description': f'Ventas - Apartado #{layaway_id} (venta #{sale_id})',
|
||||
},
|
||||
{
|
||||
'account_id': accounts['220'],
|
||||
'debit': 0,
|
||||
'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
|
||||
'description': f'IVA Trasladado - Apartado #{layaway_id} (venta #{sale_id})',
|
||||
},
|
||||
]
|
||||
|
||||
if cost_total > 0:
|
||||
lines.append({
|
||||
'account_id': accounts['510'],
|
||||
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
||||
'credit': 0,
|
||||
'description': f'Costo mercancia - Apartado #{layaway_id}',
|
||||
})
|
||||
lines.append({
|
||||
'account_id': accounts['130'],
|
||||
'debit': 0,
|
||||
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
||||
'description': f'Inventarios - Apartado #{layaway_id}',
|
||||
})
|
||||
|
||||
entry_id = _create_entry(
|
||||
cur, entry_number, entry_date, 'ingreso',
|
||||
f'Apartado #{layaway_id} - venta #{sale_id}',
|
||||
'layaway', layaway_id, lines
|
||||
)
|
||||
|
||||
cur.close()
|
||||
return entry_id
|
||||
|
||||
|
||||
def ensure_supplier_payments_table(conn):
|
||||
"""Create the supplier_payments table if it does not exist (idempotent).
|
||||
|
||||
The table links each payment to the purchase order (cuentas por pagar)
|
||||
it settles. It is created on first use so tenants that predate it work
|
||||
without a manual migration.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS supplier_payments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
po_id INTEGER REFERENCES purchase_orders(id) ON DELETE CASCADE,
|
||||
supplier_id INTEGER REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
amount NUMERIC(12,2) NOT NULL,
|
||||
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
|
||||
reference VARCHAR(100),
|
||||
employee_id INTEGER REFERENCES employees(id),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_supplier_payments_po ON supplier_payments(po_id)"
|
||||
)
|
||||
cur.close()
|
||||
|
||||
|
||||
def record_supplier_payment_entry(conn, payment):
|
||||
"""Register a payment to a supplier against a purchase order (CxP).
|
||||
|
||||
Debit 210 Proveedores = amount
|
||||
Credit 110 Caja / 111 Bancos = amount
|
||||
|
||||
The payment is stored in supplier_payments (created on first use) and the
|
||||
purchase order is marked 'paid' once the accumulated payments cover its
|
||||
total. This is what feeds the 'paid' column of the accounts-payable aging.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
payment: dict with keys:
|
||||
po_id: purchase order id (required)
|
||||
amount: float (required, in the PO currency)
|
||||
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
|
||||
reference: str (optional)
|
||||
|
||||
Returns:
|
||||
dict: {entry_id, payment_id, po_id, status, paid, balance}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
po_id = payment.get('po_id')
|
||||
amount = _to_dec(payment.get('amount', 0))
|
||||
if not po_id:
|
||||
raise ValueError("po_id is required")
|
||||
if amount <= 0:
|
||||
raise ValueError("amount must be greater than 0")
|
||||
|
||||
cur.execute("""
|
||||
SELECT supplier_id, total, status FROM purchase_orders WHERE id = %s FOR UPDATE
|
||||
""", (po_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Purchase order {po_id} not found")
|
||||
supplier_id, po_total, po_status = row
|
||||
if po_status == 'cancelled':
|
||||
raise ValueError("Cannot pay a cancelled purchase order")
|
||||
|
||||
ensure_supplier_payments_table(conn)
|
||||
cur.execute("SELECT COALESCE(SUM(amount), 0) FROM supplier_payments WHERE po_id = %s", (po_id,))
|
||||
already_paid = _to_dec(cur.fetchone()[0])
|
||||
|
||||
po_total = _to_dec(po_total)
|
||||
paid_total = already_paid + amount
|
||||
if paid_total > po_total:
|
||||
raise ValueError(
|
||||
f"Payment ${amount} exceeds balance ${(po_total - already_paid).quantize(TWO)} "
|
||||
f"of purchase order {po_id}"
|
||||
)
|
||||
|
||||
entry_date = date.today()
|
||||
_check_period_open(cur, entry_date)
|
||||
|
||||
try:
|
||||
created_by = getattr(g, 'employee_id', None)
|
||||
except RuntimeError:
|
||||
created_by = None
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO supplier_payments
|
||||
(po_id, supplier_id, amount, payment_method, reference, employee_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
po_id, supplier_id, float(amount),
|
||||
payment.get('payment_method', 'efectivo'),
|
||||
payment.get('reference'), created_by
|
||||
))
|
||||
payment_id = cur.fetchone()[0]
|
||||
|
||||
accounts = _get_account_ids(cur, ['110', '111', '210'])
|
||||
method = payment.get('payment_method', 'efectivo')
|
||||
if method in ('transferencia', 'tarjeta'):
|
||||
credit_account = accounts['111']
|
||||
credit_desc = f'Bancos - Pago a proveedor OC #{po_id}'
|
||||
else:
|
||||
credit_account = accounts['110']
|
||||
credit_desc = f'Caja - Pago a proveedor OC #{po_id}'
|
||||
|
||||
entry_number = get_next_entry_number(conn)
|
||||
entry_id = _create_entry(
|
||||
cur, entry_number, entry_date, 'egreso',
|
||||
f'Pago a proveedor - OC #{po_id}',
|
||||
'purchase', po_id,
|
||||
[
|
||||
{
|
||||
'account_id': accounts['210'],
|
||||
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
||||
'credit': 0,
|
||||
'description': f'Proveedores - Pago OC #{po_id}',
|
||||
},
|
||||
{
|
||||
'account_id': credit_account,
|
||||
'debit': 0,
|
||||
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
||||
'description': credit_desc,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
new_status = 'paid' if paid_total >= po_total else po_status
|
||||
if new_status != po_status:
|
||||
cur.execute("UPDATE purchase_orders SET status = %s WHERE id = %s", (new_status, po_id))
|
||||
|
||||
cur.close()
|
||||
return {
|
||||
'entry_id': entry_id,
|
||||
'payment_id': payment_id,
|
||||
'po_id': po_id,
|
||||
'status': new_status,
|
||||
'paid': float(paid_total.quantize(TWO, ROUND_HALF_UP)),
|
||||
'balance': float((po_total - paid_total).quantize(TWO, ROUND_HALF_UP)),
|
||||
}
|
||||
|
||||
|
||||
def create_manual_entry(conn, entry_data):
|
||||
"""Create a manual journal entry (type='diario').
|
||||
|
||||
|
||||
@@ -3,17 +3,8 @@
|
||||
|
||||
import requests
|
||||
import json
|
||||
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)
|
||||
@@ -403,7 +394,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=800, temperature=0.2, timeout=70)
|
||||
result = _post_chat_completion(url, key, model_id, messages, max_tokens=300, temperature=0.2, timeout=15)
|
||||
if result is None:
|
||||
continue
|
||||
content = result["content"]
|
||||
@@ -552,7 +543,7 @@ def chat_cache_clear():
|
||||
_RESPONSE_CACHE.clear()
|
||||
|
||||
|
||||
def chat(user_message, conversation_history=None, inventory_context=None, persona=None):
|
||||
def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
"""Send a message to the AI and get a response with search suggestions.
|
||||
|
||||
Caches responses for repeated identical questions (subject to bypass
|
||||
@@ -562,7 +553,6 @@ def chat(user_message, conversation_history=None, inventory_context=None, person
|
||||
user_message: The user's chat message.
|
||||
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
|
||||
@@ -574,9 +564,6 @@ def chat(user_message, conversation_history=None, inventory_context=None, person
|
||||
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
|
||||
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
"""Business anomaly detection (A5): discounts, below-cost sales, cancellations
|
||||
and off-hours activity.
|
||||
|
||||
Works on the tenant DB tables ``sale_items`` (discount_pct, unit_price,
|
||||
unit_cost), ``sales`` (hour, employee, total, status) and ``audit_log``
|
||||
(SALE / CANCEL). Everything is computed with a robust z-score (median + MAD)
|
||||
so a handful of extreme tickets cannot drag the baseline.
|
||||
|
||||
Cold start is explicit: statistical rules only fire once there are at least
|
||||
``min_samples`` observations in the window. The hard rules (a sale below its
|
||||
recorded cost) fire regardless, because they do not need a baseline — but
|
||||
``unit_cost`` of 0/NULL is treated as "unknown" and never flagged.
|
||||
|
||||
Public API:
|
||||
detect_anomalies(conn, days=90, min_samples=5, z_threshold=3.5) -> dict
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import numpy as np
|
||||
|
||||
DEFAULT_WINDOW_DAYS = 90
|
||||
DEFAULT_MIN_SAMPLES = 5
|
||||
DEFAULT_Z = 3.5
|
||||
|
||||
# Local business hours (in BUSINESS_TZ). Sales outside are "off-hours".
|
||||
BUSINESS_TZ = os.environ.get('ANOMALY_TZ', 'America/Mexico_City')
|
||||
OFF_HOURS_START = int(os.environ.get('ANOMALY_OFF_HOURS_START', '7'))
|
||||
OFF_HOURS_END = int(os.environ.get('ANOMALY_OFF_HOURS_END', '21'))
|
||||
|
||||
# Minimum repeated cancellations before we raise a flag (cold-start guard).
|
||||
MIN_CANCELLATIONS = int(os.environ.get('ANOMALY_MIN_CANCELLATIONS', '3'))
|
||||
CANCEL_RATE_THRESHOLD = float(os.environ.get('ANOMALY_CANCEL_RATE', '0.25'))
|
||||
|
||||
# A discount must be at least this high (percent points) to be worth flagging;
|
||||
# keeps the detector from calling a 2% discount "anomalous" in a cash-register
|
||||
# that normally discounts 0%.
|
||||
DISCOUNT_ALERT_MIN = float(os.environ.get('ANOMALY_DISCOUNT_MIN', '15'))
|
||||
|
||||
# Off-hours needs at least this many tickets before it is reported, so a
|
||||
# single late-night sale does not raise a flag.
|
||||
OFF_HOURS_MIN = int(os.environ.get('ANOMALY_OFF_HOURS_MIN', '2'))
|
||||
|
||||
SEVERITY_POINTS = {'critical': 40, 'high': 25, 'medium': 15, 'low': 8}
|
||||
|
||||
|
||||
def _robust_stats(values):
|
||||
"""Return (median, scale) robust stats, or (None, None) for empty input.
|
||||
|
||||
Scale is the MAD when it is non-zero. When most values are identical
|
||||
(MAD == 0 — very common with discounts, where the baseline is 0%), it
|
||||
falls back to a scaled mean absolute deviation so a clear outlier is
|
||||
still measurable instead of yielding a division by zero.
|
||||
"""
|
||||
arr = np.asarray(values, dtype=float)
|
||||
if arr.size == 0:
|
||||
return None, None
|
||||
med = float(np.median(arr))
|
||||
mad = float(np.median(np.abs(arr - med)))
|
||||
if mad > 1e-9:
|
||||
return med, mad
|
||||
meanad = float(np.mean(np.abs(arr - med)))
|
||||
if meanad > 1e-9:
|
||||
return med, meanad * 1.2533
|
||||
return med, 0.0
|
||||
|
||||
|
||||
def _robust_z(value, med, scale):
|
||||
"""Robust z-score (0.6745 * (x - med) / scale), None if scale == 0."""
|
||||
if med is None or scale is None or scale <= 1e-9:
|
||||
return None
|
||||
return 0.6745 * (float(value) - med) / scale
|
||||
|
||||
|
||||
def _is_off_hours(local_hour):
|
||||
if local_hour is None:
|
||||
return False
|
||||
return local_hour < OFF_HOURS_START or local_hour > OFF_HOURS_END
|
||||
|
||||
|
||||
def _anomaly(kind, severity, detail, metric=None, sample=None):
|
||||
return {
|
||||
'type': kind,
|
||||
'severity': severity,
|
||||
'detail': detail,
|
||||
'metric': metric,
|
||||
'sample_size': sample,
|
||||
}
|
||||
|
||||
|
||||
def _load_items(cur, days):
|
||||
"""Sale items in the window, joined with their sale/employee/hour."""
|
||||
cur.execute("""
|
||||
SELECT si.sale_id, si.inventory_id, si.part_number, si.name,
|
||||
si.quantity, si.unit_price, si.unit_cost, si.discount_pct,
|
||||
s.employee_id, COALESCE(e.name, 'Empleado ' || s.employee_id),
|
||||
EXTRACT(HOUR FROM (s.created_at AT TIME ZONE %s))::int AS local_hour
|
||||
FROM sale_items si
|
||||
JOIN sales s ON s.id = si.sale_id
|
||||
LEFT JOIN employees e ON e.id = s.employee_id
|
||||
WHERE s.created_at >= NOW() - make_interval(days => %s)
|
||||
""", (BUSINESS_TZ, days))
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def _load_cancellations(cur, days):
|
||||
"""Cancelled-sale counts per employee (sales.status + audit_log CANCEL)."""
|
||||
cur.execute("""
|
||||
SELECT s.employee_id, COALESCE(e.name, 'Empleado ' || s.employee_id), COUNT(*)
|
||||
FROM sales s
|
||||
LEFT JOIN employees e ON e.id = s.employee_id
|
||||
WHERE s.status = 'cancelled'
|
||||
AND s.created_at >= NOW() - make_interval(days => %s)
|
||||
GROUP BY 1, 2
|
||||
""", (days,))
|
||||
by_sales = {r[0]: (r[1], r[2]) for r in cur.fetchall()}
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT employee_id, COUNT(*)
|
||||
FROM audit_log
|
||||
WHERE action ILIKE '%%CANCEL%%'
|
||||
AND created_at >= NOW() - make_interval(days => %s)
|
||||
GROUP BY 1
|
||||
""", (days,))
|
||||
for emp_id, cnt in cur.fetchall():
|
||||
name, prev = by_sales.get(emp_id, (None, 0))
|
||||
by_sales[emp_id] = (name or f'Empleado {emp_id}', max(prev, cnt))
|
||||
except Exception:
|
||||
cur.connection.rollback()
|
||||
return by_sales
|
||||
|
||||
|
||||
def _load_sale_counts(cur, days):
|
||||
"""Total sales count per employee in the window (cancel-rate denominator)."""
|
||||
cur.execute("""
|
||||
SELECT employee_id, COUNT(*)
|
||||
FROM sales
|
||||
WHERE created_at >= NOW() - make_interval(days => %s)
|
||||
GROUP BY 1
|
||||
""", (days,))
|
||||
return {r[0]: r[1] for r in cur.fetchall()}
|
||||
|
||||
|
||||
def detect_anomalies(conn, days=DEFAULT_WINDOW_DAYS, min_samples=DEFAULT_MIN_SAMPLES,
|
||||
z_threshold=DEFAULT_Z, limit=50):
|
||||
"""Scan a tenant for business anomalies.
|
||||
|
||||
Returns a dict with per-employee and per-SKU findings, plus a summary and
|
||||
a ``cold_start`` flag (True when there is not enough data for statistical
|
||||
rules — only hard rules are reported then).
|
||||
"""
|
||||
days = max(1, int(days))
|
||||
min_samples = max(1, int(min_samples))
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
items = _load_items(cur, days)
|
||||
cancels = _load_cancellations(cur, days)
|
||||
sale_counts = _load_sale_counts(cur, days)
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
total_items = len(items)
|
||||
total_sales = len({r[0] for r in items})
|
||||
employees_seen = {(r[8], r[9]) for r in items if r[8] is not None}
|
||||
cold_start = total_items < min_samples
|
||||
|
||||
emp = {} # employee_id -> {name, anomalies: [], sales_count}
|
||||
|
||||
def add_emp(emp_id, name, anomaly):
|
||||
if emp_id is None:
|
||||
emp_id = 0
|
||||
name = name or 'Sin empleado'
|
||||
entry = emp.setdefault(emp_id, {'employee_id': emp_id, 'employee_name': name,
|
||||
'anomalies': []})
|
||||
entry['anomalies'].append(anomaly)
|
||||
|
||||
sku = {} # inventory_id -> {part_number, name, anomalies: [], tickets: n}
|
||||
|
||||
def add_sku(inv_id, part_number, name, anomaly):
|
||||
if inv_id is None:
|
||||
return
|
||||
entry = sku.setdefault(inv_id, {'inventory_id': inv_id, 'part_number': part_number,
|
||||
'name': name, 'anomalies': []})
|
||||
entry['anomalies'].append(anomaly)
|
||||
|
||||
# ── 1. Sales below recorded cost (hard rule, no baseline needed) ──────
|
||||
below_cost_emp = {}
|
||||
for (sale_id, inv_id, part_number, name, qty, price, cost, disc,
|
||||
emp_id, emp_name, hour) in items:
|
||||
if cost is None or float(cost) <= 0:
|
||||
continue # unknown cost — cannot judge
|
||||
effective = float(price or 0) * (1 - float(disc or 0) / 100.0)
|
||||
if effective < float(cost):
|
||||
margin = effective - float(cost)
|
||||
below_cost_emp[emp_id] = below_cost_emp.get(emp_id, 0) + 1
|
||||
add_emp(emp_id, emp_name, _anomaly(
|
||||
'below_cost', 'high',
|
||||
f"Vendió {name} a {effective:.2f} por debajo del costo {float(cost):.2f}",
|
||||
metric={'sale_id': sale_id, 'inventory_id': inv_id,
|
||||
'unit_price': effective, 'unit_cost': float(cost),
|
||||
'margin': round(margin, 2)},
|
||||
))
|
||||
add_sku(inv_id, part_number, name, _anomaly(
|
||||
'below_cost', 'high',
|
||||
f"Ticket #{sale_id} vendido bajo costo ({margin:.2f})",
|
||||
metric={'sale_id': sale_id, 'margin': round(margin, 2)},
|
||||
))
|
||||
|
||||
# ── 2. Discounts: robust z of the tenant-wide distribution ────────────
|
||||
discounts = [float(r[7]) for r in items if r[7] is not None]
|
||||
disc_med, disc_scale = _robust_stats(discounts)
|
||||
if not cold_start:
|
||||
for (sale_id, inv_id, part_number, name, qty, price, cost, disc,
|
||||
emp_id, emp_name, hour) in items:
|
||||
if disc is None or float(disc) < DISCOUNT_ALERT_MIN:
|
||||
continue
|
||||
z = _robust_z(float(disc), disc_med, disc_scale)
|
||||
if z is not None and z > z_threshold:
|
||||
add_emp(emp_id, emp_name, _anomaly(
|
||||
'discount_atypical', 'medium',
|
||||
f"Descuento {float(disc):.1f}% en {name} (z={z:.1f})",
|
||||
metric={'sale_id': sale_id, 'discount_pct': float(disc),
|
||||
'z': round(z, 2), 'baseline_median': round(disc_med, 2)},
|
||||
sample=len(discounts),
|
||||
))
|
||||
add_sku(inv_id, part_number, name, _anomaly(
|
||||
'discount_atypical', 'medium',
|
||||
f"Descuento {float(disc):.1f}% (z={z:.1f})",
|
||||
metric={'z': round(z, 2), 'discount_pct': float(disc)},
|
||||
sample=len(discounts),
|
||||
))
|
||||
|
||||
# Employee-level: each cajero's median discount vs the tenant baseline.
|
||||
per_emp_disc = {}
|
||||
for r in items:
|
||||
if r[7] is not None and r[8] is not None:
|
||||
per_emp_disc.setdefault(r[8], []).append(float(r[7]))
|
||||
for emp_id, vals in per_emp_disc.items():
|
||||
if len(vals) < min_samples:
|
||||
continue
|
||||
med = float(np.median(vals))
|
||||
z = _robust_z(med, disc_med, disc_scale)
|
||||
if med >= DISCOUNT_ALERT_MIN and z is not None and z > z_threshold:
|
||||
add_emp(emp_id, dict(employees_seen).get(emp_id, f'Empleado {emp_id}'),
|
||||
_anomaly(
|
||||
'discount_employee', 'high',
|
||||
f"Descuento mediano {med:.1f}% sobre la media del negocio ({disc_med:.1f}%)",
|
||||
metric={'median_discount': round(med, 2), 'z': round(z, 2)},
|
||||
sample=len(vals),
|
||||
))
|
||||
|
||||
# SKU-level: each SKU's median discount vs the tenant baseline.
|
||||
per_sku_disc = {}
|
||||
for r in items:
|
||||
if r[7] is not None and r[1] is not None:
|
||||
per_sku_disc.setdefault(r[1], []).append(float(r[7]))
|
||||
for inv_id, vals in per_sku_disc.items():
|
||||
if len(vals) < min_samples:
|
||||
continue
|
||||
med = float(np.median(vals))
|
||||
z = _robust_z(med, disc_med, disc_scale)
|
||||
if med >= DISCOUNT_ALERT_MIN and z is not None and z > z_threshold:
|
||||
part_number = next((r[2] for r in items if r[1] == inv_id), None)
|
||||
name = next((r[3] for r in items if r[1] == inv_id), None)
|
||||
add_sku(inv_id, part_number, name, _anomaly(
|
||||
'discount_sku', 'medium',
|
||||
f"Descuento mediano {med:.1f}% sobre la media ({disc_med:.1f}%)",
|
||||
metric={'median_discount': round(med, 2), 'z': round(z, 2)},
|
||||
sample=len(vals),
|
||||
))
|
||||
|
||||
# ── 3. Repeated cancellations (employee) ──────────────────────────────
|
||||
for emp_id, (emp_name, cnt) in cancels.items():
|
||||
total = sale_counts.get(emp_id, cnt) + cnt
|
||||
rate = cnt / total if total else 0
|
||||
repeats = cnt >= MIN_CANCELLATIONS
|
||||
proportional = total >= min_samples and rate >= CANCEL_RATE_THRESHOLD
|
||||
if repeats or proportional:
|
||||
add_emp(emp_id, emp_name, _anomaly(
|
||||
'cancellations', 'high' if repeats else 'medium',
|
||||
f"{cnt} cancelaciones en {days} días ({rate * 100:.0f}% de sus ventas)",
|
||||
metric={'cancellations': cnt, 'sale_rate': round(rate, 3)},
|
||||
sample=total,
|
||||
))
|
||||
|
||||
# ── 4. Off-hours sales (rule; needs a repeat to avoid one-off noise) ──
|
||||
off_hours = {}
|
||||
for (sale_id, inv_id, part_number, name, qty, price, cost, disc,
|
||||
emp_id, emp_name, hour) in items:
|
||||
if _is_off_hours(hour):
|
||||
off_hours.setdefault(emp_id, {'name': emp_name, 'sales': set()})
|
||||
off_hours[emp_id]['sales'].add(sale_id)
|
||||
for emp_id, info in off_hours.items():
|
||||
count = len(info['sales'])
|
||||
if count < OFF_HOURS_MIN and total_items >= min_samples:
|
||||
continue
|
||||
add_emp(emp_id, info['name'], _anomaly(
|
||||
'off_hours', 'low' if count < 3 else 'medium',
|
||||
f"{count} ventas fuera de horario ({OFF_HOURS_START}:00-{OFF_HOURS_END}:59)",
|
||||
metric={'off_hours_sales': count},
|
||||
sample=total_items,
|
||||
))
|
||||
|
||||
# ── Score + serialise ─────────────────────────────────────────────────
|
||||
def _score(anomalies):
|
||||
total = 0.0
|
||||
for a in anomalies:
|
||||
total += SEVERITY_POINTS.get(a['severity'], 5)
|
||||
return min(total, 100.0)
|
||||
|
||||
employees = []
|
||||
for entry in emp.values():
|
||||
entry['score'] = round(_score(entry['anomalies']), 1)
|
||||
entry['sales_count'] = sale_counts.get(entry['employee_id'], 0)
|
||||
employees.append(entry)
|
||||
employees.sort(key=lambda e: e['score'], reverse=True)
|
||||
|
||||
skus = []
|
||||
for entry in sku.values():
|
||||
entry['score'] = round(_score(entry['anomalies']), 1)
|
||||
skus.append(entry)
|
||||
skus.sort(key=lambda s: s['score'], reverse=True)
|
||||
|
||||
by_type = {}
|
||||
for entry in employees:
|
||||
for a in entry['anomalies']:
|
||||
by_type[a['type']] = by_type.get(a['type'], 0) + 1
|
||||
for entry in skus:
|
||||
for a in entry['anomalies']:
|
||||
by_type[a['type']] = by_type.get(a['type'], 0) + 1
|
||||
|
||||
return {
|
||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||
'window_days': days,
|
||||
'cold_start': cold_start,
|
||||
'sample': {
|
||||
'sale_items': total_items,
|
||||
'sales': total_sales,
|
||||
'employees': len(employees_seen),
|
||||
},
|
||||
'thresholds': {
|
||||
'min_samples': min_samples,
|
||||
'z_threshold': z_threshold,
|
||||
'business_hours': [OFF_HOURS_START, OFF_HOURS_END],
|
||||
'timezone': BUSINESS_TZ,
|
||||
'min_cancellations': MIN_CANCELLATIONS,
|
||||
},
|
||||
'employees': employees[:limit],
|
||||
'skus': skus[:limit],
|
||||
'summary': {
|
||||
'flagged_employees': len(employees),
|
||||
'flagged_skus': len(skus),
|
||||
'by_type': by_type,
|
||||
},
|
||||
}
|
||||
@@ -1,985 +0,0 @@
|
||||
# /home/Autopartes/pos/services/b2b_service.py
|
||||
"""Nexus B2B yonke marketplace: wholesale used parts between deshuesaderos.
|
||||
|
||||
A yonke (seller) publishes its used/rebuild pieces to a shared catalog; another
|
||||
yonke (buyer) places an order. Listings live in the MASTER DB (nexus_autoparts)
|
||||
so they are visible cross-tenant; the pieces themselves stay in each seller's
|
||||
tenant DB.
|
||||
|
||||
Tables (master DB):
|
||||
b2b_listings — published pieces (UNIQUE seller_tenant_id+inventory_id)
|
||||
marketplace_orders — order header (buyer/seller tenant ids, total, status)
|
||||
marketplace_order_items — order lines (+ listing_id link)
|
||||
|
||||
Order lifecycle:
|
||||
pending → confirmed → shipped → received → completed
|
||||
pending/confirmed → cancelled
|
||||
|
||||
Actor permissions per transition:
|
||||
buyer : cancelled (from pending), received (from confirmed/shipped)
|
||||
seller : confirmed (from pending), shipped (from pending/confirmed),
|
||||
cancelled (from pending/confirmed)
|
||||
both : completed (from received)
|
||||
|
||||
The source states are deliberately permissive (the UI exposes "Marcar
|
||||
enviado"/"Recibido" a step early) so a valid actor never hits a dead end.
|
||||
|
||||
Conventions:
|
||||
* Master reads/writes go through the `master_conn` passed by the caller
|
||||
(the blueprint owns commit/rollback), EXCEPT `update_order_status`, which
|
||||
commits the master transaction itself: on `received` it must persist the
|
||||
status BEFORE running the cross-tenant side effects, so the order state can
|
||||
never be rolled back while pieces were already created in another tenant.
|
||||
* Tenant connections are opened/committed/closed inside this module, because
|
||||
one order touches two tenants (buyer + seller) at once.
|
||||
* Everything degrades: side effects at `received` run inside SAVEPOINTs and
|
||||
never abort the order on failure — failures are returned as `warnings`.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from psycopg2 import errors as pg_errors
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
# ─── Domain constants ──────────────────────────────────────────────────────
|
||||
|
||||
LISTING_STATUSES = ('available', 'reserved', 'sold', 'removed')
|
||||
ORDER_STATUSES = ('pending', 'confirmed', 'shipped', 'received', 'completed', 'cancelled')
|
||||
|
||||
# status -> {new_status: (allowed actor roles)}
|
||||
ORDER_TRANSITIONS = {
|
||||
'pending': {'confirmed': ('seller',),
|
||||
'shipped': ('seller',),
|
||||
'cancelled': ('buyer', 'seller')},
|
||||
'confirmed': {'shipped': ('seller',),
|
||||
'received': ('buyer',),
|
||||
'cancelled': ('seller',)},
|
||||
'shipped': {'received': ('buyer',)},
|
||||
'received': {'completed': ('buyer', 'seller')},
|
||||
'completed': {},
|
||||
'cancelled': {},
|
||||
}
|
||||
|
||||
USED_CONDITIONS = ('usado', 'reconstruido')
|
||||
|
||||
|
||||
class B2BError(Exception):
|
||||
"""Base class for all B2B errors (mapped to HTTP by the blueprint)."""
|
||||
|
||||
|
||||
class B2BValidationError(B2BError):
|
||||
"""Bad input / illegal state transition -> HTTP 400."""
|
||||
|
||||
|
||||
class B2BForbidden(B2BError):
|
||||
"""Caller is not allowed -> HTTP 403."""
|
||||
|
||||
|
||||
class B2BNotFound(B2BError):
|
||||
"""Entity missing -> HTTP 404."""
|
||||
|
||||
|
||||
# ─── Small helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _to_int(val, default=None):
|
||||
if val is None or val == '':
|
||||
return default
|
||||
try:
|
||||
return int(float(str(val).replace(',', '')))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _to_float(val, default=0.0):
|
||||
if val is None or val == '':
|
||||
return default
|
||||
try:
|
||||
return float(str(val).replace(',', ''))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _jval(v):
|
||||
"""Make a psycopg2 value JSON-safe (Decimal -> float, date -> isoformat)."""
|
||||
if isinstance(v, Decimal):
|
||||
return float(v)
|
||||
if hasattr(v, 'isoformat'):
|
||||
return v.isoformat()
|
||||
return v
|
||||
|
||||
|
||||
def _clean(d):
|
||||
return {k: _jval(v) for k, v in (d or {}).items()}
|
||||
|
||||
|
||||
def _cur(conn):
|
||||
return conn.cursor(cursor_factory=RealDictCursor)
|
||||
|
||||
|
||||
def _tenant_name(master_conn, tenant_id):
|
||||
"""Best-effort tenant display name from the master DB (never raises)."""
|
||||
if not tenant_id:
|
||||
return None
|
||||
try:
|
||||
cur = _cur(master_conn)
|
||||
cur.execute("SELECT name FROM tenants WHERE id = %s", (tenant_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row['name'] if row else None
|
||||
except Exception:
|
||||
try:
|
||||
master_conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def is_seller(master_conn, tenant_id):
|
||||
"""True when the tenant has the seller flag enabled in the master DB."""
|
||||
try:
|
||||
cur = _cur(master_conn)
|
||||
cur.execute("SELECT COALESCE(is_seller, false) AS is_seller FROM tenants WHERE id = %s",
|
||||
(tenant_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return bool(row['is_seller']) if row else False
|
||||
except Exception:
|
||||
try:
|
||||
master_conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _audit_best_effort(conn, action, entity_type=None, entity_id=None, new_value=None):
|
||||
try:
|
||||
from services.audit import log_action
|
||||
log_action(conn, action, entity_type, entity_id, new_value=new_value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ─── Listings: serialization ───────────────────────────────────────────────
|
||||
|
||||
_LISTING_FIELDS = (
|
||||
'id', 'seller_tenant_id', 'seller_inventory_id', 'part_number', 'name',
|
||||
'condition', 'condition_grade', 'price_wholesale', 'donor_make',
|
||||
'donor_model', 'donor_year', 'image_url', 'status', 'notes',
|
||||
'created_at', 'updated_at',
|
||||
)
|
||||
_LISTING_COLS = ', '.join(_LISTING_FIELDS)
|
||||
_LISTING_COLS_L = ', '.join('l.' + f for f in _LISTING_FIELDS)
|
||||
|
||||
|
||||
def _listing_dict(row, seller_name=None, include_private=False):
|
||||
"""Map a b2b_listings row to the API dict.
|
||||
|
||||
`inventory_id` is the public alias of `seller_inventory_id` (the contract
|
||||
field). Extra fields (condition, notes, seller_tenant_id, timestamps) are
|
||||
additive and harmless for the UI.
|
||||
"""
|
||||
d = _clean(dict(row))
|
||||
d['inventory_id'] = d.get('seller_inventory_id')
|
||||
d['price_wholesale'] = float(d.get('price_wholesale') or 0)
|
||||
if seller_name is not None:
|
||||
d['seller_name'] = seller_name
|
||||
if not include_private:
|
||||
d.pop('seller_tenant_id', None)
|
||||
return d
|
||||
|
||||
|
||||
# ─── Seller: my listings ───────────────────────────────────────────────────
|
||||
|
||||
def list_my_listings(master_conn, tenant_id, status=None):
|
||||
"""Listings published by `tenant_id` (removed ones hidden by default)."""
|
||||
cur = _cur(master_conn)
|
||||
where = ["seller_tenant_id = %s"]
|
||||
params = [tenant_id]
|
||||
if status:
|
||||
status = (status or '').strip().lower()
|
||||
if status not in LISTING_STATUSES:
|
||||
raise B2BValidationError(f"status inválido: {status}")
|
||||
where.append("status = %s")
|
||||
params.append(status)
|
||||
else:
|
||||
where.append("status <> 'removed'")
|
||||
cur.execute(
|
||||
f"SELECT {_LISTING_COLS} FROM b2b_listings "
|
||||
f"WHERE {' AND '.join(where)} ORDER BY created_at DESC, id DESC",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [_listing_dict(r, include_private=True) for r in rows]
|
||||
|
||||
|
||||
def get_listing(master_conn, listing_id):
|
||||
"""Single listing + seller_name, or None."""
|
||||
cur = _cur(master_conn)
|
||||
cur.execute(f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = %s", (listing_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return None
|
||||
return _listing_dict(row, seller_name=_tenant_name(master_conn, row['seller_tenant_id']))
|
||||
|
||||
|
||||
# ─── Seller: publish / edit / remove ───────────────────────────────────────
|
||||
|
||||
def _fetch_piece(tenant_id, inventory_id):
|
||||
"""Read the source piece from the seller's tenant DB (+ primary image)."""
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, condition, condition_grade,
|
||||
donor_make, donor_model, donor_year, image_url, is_active
|
||||
FROM inventory WHERE id = %s
|
||||
""", (inventory_id,))
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
cur.execute("""
|
||||
SELECT url FROM inventory_images
|
||||
WHERE inventory_id = %s
|
||||
ORDER BY is_primary DESC, sort_order ASC, id ASC
|
||||
LIMIT 1
|
||||
""", (inventory_id,))
|
||||
img = cur.fetchone()
|
||||
if img and img.get('url'):
|
||||
row = dict(row)
|
||||
row['image_url'] = img['url']
|
||||
cur.close()
|
||||
return dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def publish_listing(master_conn, tenant_id, data):
|
||||
"""Create or refresh a listing for one of the tenant's used pieces.
|
||||
|
||||
Raises B2BForbidden when the tenant is not a seller, B2BValidationError on
|
||||
bad input / ineligible piece, B2BNotFound when the piece does not exist.
|
||||
"""
|
||||
data = data or {}
|
||||
if not is_seller(master_conn, tenant_id):
|
||||
raise B2BForbidden(
|
||||
'Tu cuenta no está habilitada como vendedor (is_seller). '
|
||||
'Contacta al administrador para activar el modo mayoreo.'
|
||||
)
|
||||
|
||||
inventory_id = _to_int(data.get('inventory_id'))
|
||||
if not inventory_id:
|
||||
raise B2BValidationError('inventory_id requerido')
|
||||
|
||||
if 'price_wholesale' not in data or data.get('price_wholesale') in (None, ''):
|
||||
raise B2BValidationError('price_wholesale requerido')
|
||||
price = _to_float(data.get('price_wholesale'), None)
|
||||
if price is None or price < 0:
|
||||
raise B2BValidationError('price_wholesale inválido')
|
||||
|
||||
piece = _fetch_piece(tenant_id, inventory_id)
|
||||
if not piece:
|
||||
raise B2BNotFound('Pieza no encontrada')
|
||||
condition = (piece.get('condition') or '').strip().lower()
|
||||
if condition not in USED_CONDITIONS:
|
||||
raise B2BValidationError(
|
||||
"Solo se pueden publicar piezas usadas o reconstruidas "
|
||||
f"(condition actual: {condition or 'desconocida'})"
|
||||
)
|
||||
if piece.get('is_active') is False:
|
||||
raise B2BValidationError('La pieza no está activa en inventario')
|
||||
|
||||
notes = (data.get('notes') or '').strip() or None
|
||||
cur = _cur(master_conn)
|
||||
cur.execute("""
|
||||
INSERT INTO b2b_listings
|
||||
(seller_tenant_id, seller_inventory_id, part_number, name, condition,
|
||||
condition_grade, price_wholesale, donor_make, donor_model, donor_year,
|
||||
image_url, status, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'available', %s)
|
||||
ON CONFLICT (seller_tenant_id, seller_inventory_id) DO UPDATE SET
|
||||
part_number = EXCLUDED.part_number,
|
||||
name = EXCLUDED.name,
|
||||
condition = EXCLUDED.condition,
|
||||
condition_grade = EXCLUDED.condition_grade,
|
||||
price_wholesale = EXCLUDED.price_wholesale,
|
||||
donor_make = EXCLUDED.donor_make,
|
||||
donor_model = EXCLUDED.donor_model,
|
||||
donor_year = EXCLUDED.donor_year,
|
||||
image_url = EXCLUDED.image_url,
|
||||
notes = COALESCE(EXCLUDED.notes, b2b_listings.notes),
|
||||
status = 'available',
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
""", (
|
||||
tenant_id, inventory_id, piece.get('part_number'), piece.get('name'),
|
||||
condition or 'usado', piece.get('condition_grade'), price,
|
||||
piece.get('donor_make'), piece.get('donor_model'), piece.get('donor_year'),
|
||||
piece.get('image_url'), notes,
|
||||
))
|
||||
listing_id = cur.fetchone()['id']
|
||||
cur.close()
|
||||
|
||||
listing = get_listing(master_conn, listing_id)
|
||||
_audit_publish_best_effort(tenant_id, listing)
|
||||
return listing
|
||||
|
||||
|
||||
def _audit_publish_best_effort(tenant_id, listing):
|
||||
try:
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
try:
|
||||
_audit_best_effort(conn, 'B2B_LISTING_PUBLISH', 'b2b_listing',
|
||||
(listing or {}).get('id'),
|
||||
{'price_wholesale': (listing or {}).get('price_wholesale')})
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def update_listing(master_conn, tenant_id, listing_id, data):
|
||||
"""Update price/status of a listing (owner only). Returns the listing."""
|
||||
data = data or {}
|
||||
sets = []
|
||||
params = []
|
||||
if 'price_wholesale' in data:
|
||||
price = _to_float(data.get('price_wholesale'), None)
|
||||
if price is None or price < 0:
|
||||
raise B2BValidationError('price_wholesale inválido')
|
||||
sets.append('price_wholesale = %s')
|
||||
params.append(price)
|
||||
if 'status' in data:
|
||||
status = (data.get('status') or '').strip().lower()
|
||||
if status not in LISTING_STATUSES:
|
||||
raise B2BValidationError(f"status inválido: {status}")
|
||||
sets.append('status = %s')
|
||||
params.append(status)
|
||||
if 'notes' in data:
|
||||
sets.append('notes = %s')
|
||||
params.append((data.get('notes') or '').strip() or None)
|
||||
if not sets:
|
||||
raise B2BValidationError('Nada que actualizar (price_wholesale, status, notes)')
|
||||
|
||||
cur = _cur(master_conn)
|
||||
cur.execute("SELECT seller_tenant_id FROM b2b_listings WHERE id = %s", (listing_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise B2BNotFound('Listing no encontrado')
|
||||
if row['seller_tenant_id'] != tenant_id:
|
||||
cur.close()
|
||||
raise B2BForbidden('Este listing pertenece a otro vendedor')
|
||||
|
||||
sets.append('updated_at = NOW()')
|
||||
params.append(listing_id)
|
||||
cur.execute(f"UPDATE b2b_listings SET {', '.join(sets)} WHERE id = %s", params)
|
||||
cur.close()
|
||||
return get_listing(master_conn, listing_id)
|
||||
|
||||
|
||||
def remove_listing(master_conn, tenant_id, listing_id):
|
||||
"""Soft-delete a listing (status='removed'). Owner only. Returns bool."""
|
||||
cur = _cur(master_conn)
|
||||
cur.execute("SELECT seller_tenant_id FROM b2b_listings WHERE id = %s", (listing_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise B2BNotFound('Listing no encontrado')
|
||||
if row['seller_tenant_id'] != tenant_id:
|
||||
cur.close()
|
||||
raise B2BForbidden('Este listing pertenece a otro vendedor')
|
||||
cur.execute(
|
||||
"UPDATE b2b_listings SET status = 'removed', updated_at = NOW() WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
# ─── Buyer: catalog ────────────────────────────────────────────────────────
|
||||
|
||||
def list_catalog(master_conn, buyer_tenant_id, filters=None, page=1, per_page=12):
|
||||
"""Available listings from OTHER tenants, with seller_name and pagination."""
|
||||
filters = filters or {}
|
||||
page = max(_to_int(page, 1) or 1, 1)
|
||||
per_page = min(max(_to_int(per_page, 12) or 12, 1), 100)
|
||||
|
||||
where = ["l.status = 'available'", 'l.seller_tenant_id <> %s']
|
||||
params = [buyer_tenant_id]
|
||||
|
||||
q = (filters.get('q') or '').strip()
|
||||
if q:
|
||||
where.append("(l.name ILIKE %s OR l.part_number ILIKE %s "
|
||||
"OR l.donor_make ILIKE %s OR l.donor_model ILIKE %s)")
|
||||
params.extend([f'%{q}%'] * 4)
|
||||
|
||||
for field, col in (('make', 'l.donor_make'), ('model', 'l.donor_model')):
|
||||
val = (filters.get(field) or '').strip()
|
||||
if val:
|
||||
where.append(f"{col} ILIKE %s")
|
||||
params.append(f'%{val}%')
|
||||
|
||||
year = _to_int(filters.get('year'))
|
||||
if year:
|
||||
where.append("l.donor_year = %s")
|
||||
params.append(year)
|
||||
|
||||
grade = (filters.get('grade') or '').strip().upper()
|
||||
if grade:
|
||||
where.append("UPPER(l.condition_grade) = %s")
|
||||
params.append(grade)
|
||||
|
||||
where_sql = ' AND '.join(where)
|
||||
cur = _cur(master_conn)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) AS total
|
||||
FROM b2b_listings l
|
||||
WHERE {where_sql}
|
||||
""", params)
|
||||
total = cur.fetchone()['total'] or 0
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT {_LISTING_COLS_L}, t.name AS seller_name
|
||||
FROM b2b_listings l
|
||||
LEFT JOIN tenants t ON t.id = l.seller_tenant_id
|
||||
WHERE {where_sql}
|
||||
ORDER BY l.created_at DESC, l.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, (page - 1) * per_page])
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
items = [_listing_dict(r, seller_name=r.get('seller_name')) for r in rows]
|
||||
pagination = {
|
||||
'page': page, 'per_page': per_page, 'total': total,
|
||||
'total_pages': (total + per_page - 1) // per_page,
|
||||
}
|
||||
return items, pagination
|
||||
|
||||
|
||||
# ─── Orders ────────────────────────────────────────────────────────────────
|
||||
|
||||
_ORDER_COLS = (
|
||||
'id, buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, total, '
|
||||
'status, notes, created_at, updated_at'
|
||||
)
|
||||
|
||||
|
||||
def _order_items(master_conn, order_ids):
|
||||
"""Bulk-load order lines grouped by order_id."""
|
||||
if not order_ids:
|
||||
return {}
|
||||
cur = _cur(master_conn)
|
||||
cur.execute("""
|
||||
SELECT id, order_id, listing_id, part_number, part_name, quantity,
|
||||
unit_price, subtotal
|
||||
FROM marketplace_order_items
|
||||
WHERE order_id = ANY(%s)
|
||||
ORDER BY order_id, id
|
||||
""", (list(order_ids),))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
out = {}
|
||||
for r in rows:
|
||||
item = _clean(dict(r))
|
||||
item['name'] = item.get('part_name') # UI-friendly alias
|
||||
out.setdefault(r['order_id'], []).append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _order_dict(row, items, role):
|
||||
d = _clean(dict(row))
|
||||
d['total'] = float(d.get('total') or 0)
|
||||
d['role'] = role
|
||||
if role == 'buyer':
|
||||
d['other_party'] = d.get('seller_name')
|
||||
d['other_tenant_id'] = d.get('seller_tenant_id')
|
||||
else:
|
||||
d['other_party'] = d.get('buyer_name')
|
||||
d['other_tenant_id'] = d.get('buyer_tenant_id')
|
||||
d['items'] = items or []
|
||||
return d
|
||||
|
||||
|
||||
def create_order(master_conn, buyer_tenant_id, items, notes=None):
|
||||
"""Create a pending order + items and reserve the listings.
|
||||
|
||||
All lines must reference available listings from ONE other tenant.
|
||||
"""
|
||||
if not isinstance(items, list) or not items:
|
||||
raise B2BValidationError('items requerido (lista no vacía)')
|
||||
|
||||
wanted = []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
raise B2BValidationError('cada item debe ser un objeto {listing_id, quantity}')
|
||||
listing_id = _to_int(it.get('listing_id'))
|
||||
qty = _to_int(it.get('quantity'), 1)
|
||||
if not listing_id:
|
||||
raise B2BValidationError('listing_id requerido en cada item')
|
||||
if qty is None or qty < 1:
|
||||
raise B2BValidationError('quantity debe ser >= 1')
|
||||
wanted.append({'listing_id': listing_id, 'quantity': qty})
|
||||
|
||||
listing_ids = [w['listing_id'] for w in wanted]
|
||||
cur = _cur(master_conn)
|
||||
cur.execute(
|
||||
f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = ANY(%s) FOR UPDATE",
|
||||
(listing_ids,),
|
||||
)
|
||||
rows = {r['id']: dict(r) for r in cur.fetchall()}
|
||||
cur.close()
|
||||
|
||||
sellers = set()
|
||||
resolved = []
|
||||
for w in wanted:
|
||||
listing = rows.get(w['listing_id'])
|
||||
if not listing:
|
||||
raise B2BNotFound(f"Listing {w['listing_id']} no encontrado")
|
||||
if listing['status'] != 'available':
|
||||
raise B2BValidationError(
|
||||
f"Listing {w['listing_id']} no está disponible (status: {listing['status']})")
|
||||
if listing['seller_tenant_id'] == buyer_tenant_id:
|
||||
raise B2BValidationError('No puedes comprar tus propias piezas')
|
||||
sellers.add(listing['seller_tenant_id'])
|
||||
resolved.append((listing, w['quantity']))
|
||||
|
||||
if len(sellers) > 1:
|
||||
raise B2BValidationError(
|
||||
'Una orden solo puede tener un vendedor; crea una orden por vendedor')
|
||||
seller_tenant_id = sellers.pop()
|
||||
|
||||
total = round(sum(
|
||||
_to_float(listing['price_wholesale'], 0) * qty
|
||||
for listing, qty in resolved), 2)
|
||||
buyer_name = _tenant_name(master_conn, buyer_tenant_id) or ''
|
||||
seller_name = _tenant_name(master_conn, seller_tenant_id) or ''
|
||||
|
||||
cur = _cur(master_conn)
|
||||
cur.execute("""
|
||||
INSERT INTO marketplace_orders
|
||||
(buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
|
||||
total, status, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, 'pending', %s)
|
||||
RETURNING id
|
||||
""", (buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
|
||||
total, (notes or '').strip() or None))
|
||||
order_id = cur.fetchone()['id']
|
||||
|
||||
order_items = []
|
||||
for listing, qty in resolved:
|
||||
price = _to_float(listing['price_wholesale'], 0)
|
||||
subtotal = round(price * qty, 2)
|
||||
cur.execute("""
|
||||
INSERT INTO marketplace_order_items
|
||||
(order_id, listing_id, part_number, part_name, quantity, unit_price, subtotal)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (order_id, listing['id'], listing.get('part_number'), listing.get('name'),
|
||||
qty, price, subtotal))
|
||||
order_items.append({
|
||||
'listing_id': listing['id'],
|
||||
'part_number': listing.get('part_number'),
|
||||
'part_name': listing.get('name'),
|
||||
'name': listing.get('name'),
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'subtotal': subtotal,
|
||||
})
|
||||
|
||||
cur.execute("""
|
||||
UPDATE b2b_listings SET status = 'reserved', updated_at = NOW()
|
||||
WHERE id = ANY(%s)
|
||||
""", ([listing['id'] for listing, _ in resolved],))
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'id': order_id,
|
||||
'total': total,
|
||||
'status': 'pending',
|
||||
'seller_tenant_id': seller_tenant_id,
|
||||
'buyer_tenant_id': buyer_tenant_id,
|
||||
'seller_name': seller_name,
|
||||
'buyer_name': buyer_name,
|
||||
'items': order_items,
|
||||
}
|
||||
|
||||
|
||||
def list_orders(master_conn, tenant_id, role):
|
||||
"""Orders where `tenant_id` is buyer ('buyer') or seller ('seller')."""
|
||||
col = 'buyer_tenant_id' if role == 'buyer' else 'seller_tenant_id'
|
||||
cur = _cur(master_conn)
|
||||
cur.execute(
|
||||
f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE {col} = %s "
|
||||
f"ORDER BY created_at DESC, id DESC",
|
||||
(tenant_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
items = _order_items(master_conn, [r['id'] for r in rows])
|
||||
return [_order_dict(r, items.get(r['id']), role) for r in rows]
|
||||
|
||||
|
||||
def get_order(master_conn, tenant_id, order_id):
|
||||
"""Order detail; only participants. Raises B2BNotFound / B2BForbidden."""
|
||||
cur = _cur(master_conn)
|
||||
cur.execute(f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE id = %s", (order_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
raise B2BNotFound('Orden no encontrada')
|
||||
if row['buyer_tenant_id'] == tenant_id:
|
||||
role = 'buyer'
|
||||
elif row['seller_tenant_id'] == tenant_id:
|
||||
role = 'seller'
|
||||
else:
|
||||
raise B2BForbidden('No participas en esta orden')
|
||||
items = _order_items(master_conn, [order_id])
|
||||
return _order_dict(row, items.get(order_id), role)
|
||||
|
||||
|
||||
def update_order_status(master_conn, tenant_id, order_id, new_status, branch_id=None):
|
||||
"""Apply an order status transition (role-checked).
|
||||
|
||||
Commits the master transaction itself: when moving to `received` the status
|
||||
must be durable before the cross-tenant side effects (marking the seller's
|
||||
piece sold and creating the piece in the buyer's inventory) run.
|
||||
"""
|
||||
new_status = (new_status or '').strip().lower()
|
||||
if new_status not in ORDER_STATUSES:
|
||||
raise B2BValidationError(
|
||||
f"status inválido: {new_status} (válidos: {', '.join(ORDER_STATUSES)})")
|
||||
|
||||
cur = _cur(master_conn)
|
||||
cur.execute(
|
||||
f"SELECT {_ORDER_COLS} FROM marketplace_orders WHERE id = %s FOR UPDATE",
|
||||
(order_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise B2BNotFound('Orden no encontrada')
|
||||
|
||||
if row['buyer_tenant_id'] == tenant_id:
|
||||
role = 'buyer'
|
||||
elif row['seller_tenant_id'] == tenant_id:
|
||||
role = 'seller'
|
||||
else:
|
||||
cur.close()
|
||||
raise B2BForbidden('No participas en esta orden')
|
||||
|
||||
current = row['status'] or 'pending'
|
||||
allowed = ORDER_TRANSITIONS.get(current, {}).get(new_status)
|
||||
if not allowed:
|
||||
cur.close()
|
||||
raise B2BValidationError(f"No se puede pasar de '{current}' a '{new_status}'")
|
||||
if role not in allowed:
|
||||
cur.close()
|
||||
raise B2BForbidden(
|
||||
f"Rol '{role}' no puede cambiar la orden de '{current}' a '{new_status}'")
|
||||
|
||||
cur.execute(
|
||||
"UPDATE marketplace_orders SET status = %s, updated_at = NOW() WHERE id = %s",
|
||||
(new_status, order_id),
|
||||
)
|
||||
cur.close()
|
||||
master_conn.commit()
|
||||
|
||||
result = get_order(master_conn, tenant_id, order_id)
|
||||
result['previous_status'] = current
|
||||
|
||||
if new_status == 'received':
|
||||
extras = _finalize_received(master_conn, dict(row), branch_id=branch_id)
|
||||
result.update(extras)
|
||||
return result
|
||||
|
||||
|
||||
# ─── Received: cross-tenant side effects (best-effort) ─────────────────────
|
||||
|
||||
def _resolve_branch(conn, branch_id=None):
|
||||
if branch_id:
|
||||
return branch_id
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
||||
r = cur.fetchone()
|
||||
cur.close()
|
||||
return r[0] if r else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _mark_seller_piece_sold(conn, inventory_id):
|
||||
"""Mark the seller's inventory row as sold (SAVEPOINT, never raises).
|
||||
|
||||
`inventory` has no `status` column in the standard tenant schema, so we set
|
||||
`is_active = false` (piece no longer available) when it is absent; if a
|
||||
tenant ever adds `inventory.status`, we write 'sold' there instead.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SAVEPOINT b2b_sold")
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'inventory' AND column_name = 'status' LIMIT 1
|
||||
""")
|
||||
has_status = cur.fetchone() is not None
|
||||
if has_status:
|
||||
cur.execute("UPDATE inventory SET status = 'sold' WHERE id = %s", (inventory_id,))
|
||||
else:
|
||||
cur.execute("UPDATE inventory SET is_active = false WHERE id = %s", (inventory_id,))
|
||||
cur.execute("RELEASE SAVEPOINT b2b_sold")
|
||||
return True
|
||||
except Exception:
|
||||
try:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT b2b_sold")
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def _insert_buyer_inventory(conn, item, listing, unit_price, warnings):
|
||||
"""Insert the purchased piece into the buyer's inventory (SAVEPOINT)."""
|
||||
from services.yonke_service import generate_unique_sku
|
||||
|
||||
name = (listing.get('name') or item.get('part_name') or item.get('part_number')
|
||||
or 'Pieza usada')
|
||||
candidate = (listing.get('part_number') or item.get('part_number') or '').strip() or None
|
||||
grade = listing.get('condition_grade')
|
||||
|
||||
cur = conn.cursor()
|
||||
inv_id = None
|
||||
for _attempt in range(5):
|
||||
if candidate is None:
|
||||
try:
|
||||
candidate = generate_unique_sku(conn, None)
|
||||
except Exception:
|
||||
candidate = None
|
||||
cur.execute("SAVEPOINT b2b_inv")
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO inventory
|
||||
(part_number, name, unit, cost, price_1, price_2, price_3, tax_rate,
|
||||
condition, condition_grade, is_unique_piece,
|
||||
donor_make, donor_model, donor_year, ownership, image_url, is_active)
|
||||
VALUES (%s, %s, 'PZA', %s, %s, %s, %s, %s,
|
||||
'usado', %s, true,
|
||||
%s, %s, %s, 'propio', %s, true)
|
||||
RETURNING id
|
||||
""", (
|
||||
candidate, name, unit_price, unit_price, unit_price, unit_price, 0.16,
|
||||
grade, listing.get('donor_make'), listing.get('donor_model'),
|
||||
listing.get('donor_year'), listing.get('image_url'),
|
||||
))
|
||||
inv_id = cur.fetchone()[0]
|
||||
cur.execute("RELEASE SAVEPOINT b2b_inv")
|
||||
break
|
||||
except pg_errors.UniqueViolation:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT b2b_inv")
|
||||
candidate = None
|
||||
except Exception as e:
|
||||
try:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT b2b_inv")
|
||||
except Exception:
|
||||
pass
|
||||
warnings.append(f"no se pudo crear la pieza en el inventario del comprador: {e}")
|
||||
cur.close()
|
||||
return None
|
||||
cur.close()
|
||||
return inv_id
|
||||
|
||||
|
||||
def _seed_stock(conn, inv_id, branch_id, qty, cost, warnings):
|
||||
"""Seed initial stock for a freshly created buyer piece (SAVEPOINT)."""
|
||||
if not branch_id or qty <= 0:
|
||||
return
|
||||
from services.inventory_engine import record_initial
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("SAVEPOINT b2b_stock")
|
||||
try:
|
||||
record_initial(conn, inv_id, branch_id, qty, cost=cost)
|
||||
cur.execute("RELEASE SAVEPOINT b2b_stock")
|
||||
except Exception as e:
|
||||
try:
|
||||
cur.execute("ROLLBACK TO SAVEPOINT b2b_stock")
|
||||
except Exception:
|
||||
pass
|
||||
warnings.append(f"no se registró la operación de stock: {e}")
|
||||
# Idempotent safety net: the trigger normally creates this row already.
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO inventory_stock (inventory_id, branch_id, stock)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (inventory_id, branch_id) DO UPDATE SET stock = EXCLUDED.stock
|
||||
""", (inv_id, branch_id, qty))
|
||||
except Exception as e:
|
||||
warnings.append(f"no se pudo fijar el stock inicial: {e}")
|
||||
cur.close()
|
||||
|
||||
|
||||
def _finalize_received(master_conn, order, branch_id=None):
|
||||
"""On `received`: sell the listings/seller pieces and stock the buyer.
|
||||
|
||||
Best-effort by design: every step runs in its own SAVEPOINT / connection,
|
||||
failures are collected in `warnings`, and the order is never aborted.
|
||||
"""
|
||||
warnings = []
|
||||
extras = {'listings_sold': 0, 'inventory_created': 0, 'warnings': warnings}
|
||||
order_id = order['id']
|
||||
seller_id = order['seller_tenant_id']
|
||||
buyer_id = order['buyer_tenant_id']
|
||||
|
||||
cur = _cur(master_conn)
|
||||
cur.execute("""
|
||||
SELECT id, listing_id, part_number, part_name, quantity, unit_price
|
||||
FROM marketplace_order_items WHERE order_id = %s ORDER BY id
|
||||
""", (order_id,))
|
||||
items = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
listing_ids = [it['listing_id'] for it in items if it['listing_id']]
|
||||
listings = {}
|
||||
if listing_ids:
|
||||
cur.execute(f"SELECT {_LISTING_COLS} FROM b2b_listings WHERE id = ANY(%s)",
|
||||
(listing_ids,))
|
||||
for r in cur.fetchall():
|
||||
listings[r['id']] = dict(r)
|
||||
|
||||
# Legacy rows may lack listing_id: fall back to (seller, part_number).
|
||||
for it in items:
|
||||
if it['listing_id'] in listings:
|
||||
continue
|
||||
cur.execute(f"""
|
||||
SELECT {_LISTING_COLS} FROM b2b_listings
|
||||
WHERE seller_tenant_id = %s AND part_number = %s
|
||||
ORDER BY (status = 'reserved') DESC, id DESC LIMIT 1
|
||||
""", (seller_id, it.get('part_number')))
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
listings[it['listing_id']] = dict(r)
|
||||
|
||||
# 1) Mark listings sold (master DB) ------------------------------------
|
||||
sold = 0
|
||||
for it in items:
|
||||
listing = listings.get(it['listing_id']) if it['listing_id'] else None
|
||||
if not listing and it['listing_id']:
|
||||
continue
|
||||
try:
|
||||
if listing:
|
||||
cur.execute(
|
||||
"UPDATE b2b_listings SET status = 'sold', updated_at = NOW() WHERE id = %s",
|
||||
(listing['id'],))
|
||||
else:
|
||||
cur.execute("""
|
||||
UPDATE b2b_listings SET status = 'sold', updated_at = NOW()
|
||||
WHERE seller_tenant_id = %s AND part_number = %s AND status = 'reserved'
|
||||
""", (seller_id, it.get('part_number')))
|
||||
sold += cur.rowcount
|
||||
except Exception as e:
|
||||
master_conn.rollback()
|
||||
warnings.append(f"no se pudo marcar el listing como vendido: {e}")
|
||||
cur = _cur(master_conn)
|
||||
try:
|
||||
master_conn.commit()
|
||||
except Exception as e:
|
||||
warnings.append(f"no se pudo confirmar el estado de los listings: {e}")
|
||||
extras['listings_sold'] = sold
|
||||
cur.close()
|
||||
|
||||
# 2) Mark the seller's pieces sold (seller tenant DB) ------------------
|
||||
seller_conn = None
|
||||
try:
|
||||
seller_conn = get_tenant_conn(seller_id)
|
||||
for it in items:
|
||||
listing = listings.get(it['listing_id'])
|
||||
inv_id = _to_int((listing or {}).get('seller_inventory_id'))
|
||||
if inv_id:
|
||||
_mark_seller_piece_sold(seller_conn, inv_id)
|
||||
seller_conn.commit()
|
||||
except Exception as e:
|
||||
warnings.append(f"no se pudieron marcar las piezas del vendedor: {e}")
|
||||
if seller_conn is not None:
|
||||
try:
|
||||
seller_conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if seller_conn is not None:
|
||||
try:
|
||||
seller_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) Create the purchased pieces in the buyer's inventory --------------
|
||||
buyer_conn = None
|
||||
try:
|
||||
buyer_conn = get_tenant_conn(buyer_id)
|
||||
buyer_branch = _resolve_branch(buyer_conn, branch_id)
|
||||
created = 0
|
||||
for it in items:
|
||||
listing = listings.get(it['listing_id']) or {}
|
||||
qty = max(_to_int(it.get('quantity'), 1) or 1, 1)
|
||||
price = _to_float(it.get('unit_price'), 0)
|
||||
inv_id = _insert_buyer_inventory(buyer_conn, it, listing, price, warnings)
|
||||
if inv_id:
|
||||
_seed_stock(buyer_conn, inv_id, buyer_branch, qty, price, warnings)
|
||||
created += 1
|
||||
buyer_conn.commit()
|
||||
extras['inventory_created'] = created
|
||||
try:
|
||||
_audit_best_effort(buyer_conn, 'B2B_ORDER_RECEIVED', 'marketplace_order',
|
||||
order_id, {'inventory_created': created})
|
||||
buyer_conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
warnings.append(f"no se pudo crear el inventario del comprador: {e}")
|
||||
if buyer_conn is not None:
|
||||
try:
|
||||
buyer_conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
if buyer_conn is not None:
|
||||
try:
|
||||
buyer_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return extras
|
||||
|
||||
|
||||
# ─── Seller status (UI badge) ──────────────────────────────────────────────
|
||||
|
||||
def get_seller_status(master_conn, tenant_id):
|
||||
"""{is_seller, listings_count, incoming_orders} for the current tenant."""
|
||||
seller = is_seller(master_conn, tenant_id)
|
||||
cur = _cur(master_conn)
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) AS c FROM b2b_listings
|
||||
WHERE seller_tenant_id = %s AND status <> 'removed'
|
||||
""", (tenant_id,))
|
||||
listings_count = cur.fetchone()['c'] or 0
|
||||
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) AS c FROM marketplace_orders
|
||||
WHERE seller_tenant_id = %s AND status IN ('pending', 'confirmed', 'shipped')
|
||||
""", (tenant_id,))
|
||||
incoming = cur.fetchone()['c'] or 0
|
||||
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) AS c FROM marketplace_orders WHERE seller_tenant_id = %s
|
||||
""", (tenant_id,))
|
||||
total_incoming = cur.fetchone()['c'] or 0
|
||||
cur.close()
|
||||
|
||||
return {
|
||||
'is_seller': seller,
|
||||
'listings_count': listings_count,
|
||||
'incoming_orders': incoming,
|
||||
'incoming_orders_total': total_incoming,
|
||||
}
|
||||
@@ -3,21 +3,13 @@
|
||||
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', 'demo', 'basic', 'standard', 'pro', 'enterprise']
|
||||
PLAN_ORDER = ['free', 'basic', 'pro', 'enterprise']
|
||||
|
||||
|
||||
def get_plan(tenant_id):
|
||||
|
||||
@@ -737,10 +737,9 @@ def get_nexpart_groups_for_vehicle(master_conn, mye_id, tenant_conn=None):
|
||||
|
||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||
# 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
|
||||
@@ -770,10 +769,9 @@ def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug, tenant_co
|
||||
)
|
||||
|
||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||
# 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 []
|
||||
@@ -1174,10 +1172,9 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
||||
"""
|
||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||
# 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
|
||||
@@ -1330,10 +1327,9 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
|
||||
)
|
||||
|
||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||
# 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 []
|
||||
@@ -1856,169 +1852,49 @@ def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
|
||||
}
|
||||
|
||||
|
||||
def _alternatives_from_supplier_catalog(conn, catalog_id=None, part_number=None,
|
||||
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
|
||||
def _get_alternatives(cur, part_id):
|
||||
"""Get cross-references + aftermarket parts for a given OEM part."""
|
||||
results = []
|
||||
seen = set()
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
# Cross-references (other OEM numbers that reference this part)
|
||||
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)
|
||||
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': sku,
|
||||
'manufacturer': sup,
|
||||
'name': cname,
|
||||
'part_number': r[0],
|
||||
'manufacturer': r[1] or 'OEM Cross-Ref',
|
||||
'name': None,
|
||||
'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:
|
||||
# Aftermarket alternatives
|
||||
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)
|
||||
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': sku,
|
||||
'manufacturer': sup,
|
||||
'name': cname,
|
||||
'type': 'semantic',
|
||||
'similarity': round(item['similarity'], 3),
|
||||
'catalog_id': cid,
|
||||
'part_number': r[0],
|
||||
'manufacturer': r[1],
|
||||
'name': r[2],
|
||||
'type': 'aftermarket',
|
||||
'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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -2063,46 +1939,7 @@ def _search_meili_fallback(master_conn, q, limit):
|
||||
return 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'):
|
||||
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None, tenant_id=None):
|
||||
"""Search parts by part number or text. Enriches with local stock.
|
||||
|
||||
Strategy:
|
||||
@@ -2110,9 +1947,6 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
||||
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
|
||||
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:
|
||||
@@ -2140,7 +1974,6 @@ 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',
|
||||
})
|
||||
@@ -2150,9 +1983,6 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
||||
|
||||
# ── Inject supplier catalog items ───────────────────────────────────────
|
||||
if master_conn:
|
||||
# Meilisearch first (typo tolerance, sub-100ms); SQL fallback below.
|
||||
supplier_items = _search_meili_supplier_catalog(q, limit)
|
||||
if not supplier_items:
|
||||
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
|
||||
for si in supplier_items:
|
||||
if f"sc:{si['id']}" in seen_local_ids:
|
||||
@@ -2173,7 +2003,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return _apply_rerank(q, results, rerank_scope)
|
||||
return results
|
||||
|
||||
|
||||
def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
|
||||
@@ -2197,33 +2027,6 @@ def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
|
||||
return prices
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -2289,181 +2092,13 @@ 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()
|
||||
|
||||
@@ -2487,7 +2122,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, i.brand, COALESCE(s.stock, 0) as stock
|
||||
i.price_1, 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
|
||||
@@ -2502,7 +2137,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, i.brand, COALESCE(SUM(s.stock), 0) as stock
|
||||
i.price_1, 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
|
||||
@@ -2510,7 +2145,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, i.brand
|
||||
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
|
||||
ORDER BY i.name
|
||||
LIMIT %s
|
||||
""", (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
|
||||
@@ -2519,7 +2154,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||
if branch_id:
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||
i.price_1, i.brand, COALESCE(s.stock, 0) as stock
|
||||
i.price_1, COALESCE(s.stock, 0) as stock
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_stock_summary s
|
||||
ON s.inventory_id = i.id AND s.branch_id = %s
|
||||
@@ -2532,32 +2167,30 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||
else:
|
||||
cur.execute(f"""
|
||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock
|
||||
i.price_1, COALESCE(SUM(s.stock), 0) as stock
|
||||
FROM inventory i
|
||||
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, i.brand
|
||||
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
|
||||
ORDER BY i.name
|
||||
LIMIT %s
|
||||
""", (f'%{clean_q}%', f'%{_q_unaccent}%', limit))
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
results = [
|
||||
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,
|
||||
'stock': int(r[5]) if r[5] is not None else 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return _augment_with_aliases(tenant_conn, results, q, mye_id, branch_id, limit)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -2699,50 +2332,11 @@ def get_bodega_availability(master_conn, part_id):
|
||||
]
|
||||
|
||||
|
||||
def get_alternatives(master_conn, catalog_id=None, part_number=None, name=None,
|
||||
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.
|
||||
"""
|
||||
def get_alternatives(master_conn, part_id):
|
||||
"""Public wrapper: get cross-references + aftermarket parts for a given OEM part."""
|
||||
cur = master_conn.cursor()
|
||||
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:
|
||||
results = _get_alternatives(cur, part_id)
|
||||
cur.close()
|
||||
|
||||
if tenant_conn is not None and results:
|
||||
alt_pns = [a['part_number'] for a in results if a.get('part_number')]
|
||||
alt_cids = [a['catalog_id'] for a in results if a.get('catalog_id')]
|
||||
try:
|
||||
local = _get_local_stock_bulk(tenant_conn, branch_id, alt_pns, alt_cids)
|
||||
except Exception:
|
||||
local = {}
|
||||
for a in results:
|
||||
l = local.get(a.get('part_number')) or local.get(f"cat:{a.get('catalog_id')}")
|
||||
a['local_stock'] = l['stock'] if l else 0
|
||||
a.setdefault('bodega_count', 0)
|
||||
return results
|
||||
|
||||
|
||||
|
||||
@@ -63,28 +63,6 @@ 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}')
|
||||
@@ -105,9 +83,6 @@ 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)
|
||||
@@ -200,10 +175,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_sat_keys(item)[0])
|
||||
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
||||
concepto.set('Cantidad', str(qty))
|
||||
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
|
||||
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||
concepto.set('Unidad', 'PZA')
|
||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||
@@ -326,10 +301,10 @@ def build_egreso_xml(sale, tenant_config, customer, original_uuid):
|
||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
concepto = _make_element(conceptos, 'Concepto')
|
||||
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
|
||||
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
||||
concepto.set('Cantidad', str(qty))
|
||||
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
|
||||
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||
concepto.set('Unidad', 'PZA')
|
||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||
@@ -581,10 +556,10 @@ def build_global_invoice_xml(sales, tenant_config, year, month):
|
||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
concepto = _make_element(conceptos, 'Concepto')
|
||||
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
|
||||
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
|
||||
concepto.set('Cantidad', str(qty))
|
||||
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
|
||||
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||
concepto.set('Unidad', 'PZA')
|
||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||
|
||||
@@ -69,27 +69,6 @@ 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 []:
|
||||
@@ -97,13 +76,12 @@ 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": product_key,
|
||||
"unit_key": unit_key,
|
||||
"product_key": item.get("clave_prod_serv") or "25174800",
|
||||
"unit_key": item.get("clave_unidad") or "H87",
|
||||
"unit_name": "Pieza",
|
||||
"price": _fmt2(unit_price),
|
||||
"tax_included": False,
|
||||
@@ -158,91 +136,65 @@ def build_ingreso_payload(sale, tenant_config, customer=None):
|
||||
"folio_number": sale["id"],
|
||||
}
|
||||
|
||||
# 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"):
|
||||
# Optional exchange rate for USD
|
||||
if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"):
|
||||
payload["exchange"] = _fmt6(sale["exchange_rate"])
|
||||
payload["currency"] = sale["currency"]
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_egreso_payload(sale, tenant_config, customer, original_uuid, folio_number=None, idempotency_key=None):
|
||||
def build_egreso_payload(sale, tenant_config, customer, original_uuid):
|
||||
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
|
||||
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||
payload["type"] = "E"
|
||||
payload["use"] = "G02" # Devoluciones, descuentos o bonificaciones
|
||||
payload["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}]
|
||||
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 _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.
|
||||
"""
|
||||
def build_pago_payload(payment, tenant_config, customer, original_uuid):
|
||||
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago)."""
|
||||
tenant_cp = tenant_config.get("cp", "00000")
|
||||
customer_payload = _build_customer_payload(customer, tenant_cp)
|
||||
|
||||
amount = _to_dec(payment.get("amount", 0))
|
||||
rate = _to_dec(payment.get("tax_rate") or "0.16") or Decimal("0.16")
|
||||
base = (amount / (Decimal("1") + rate)).quantize(TWO, ROUND_HALF_UP)
|
||||
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
|
||||
|
||||
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
if "T" not in str(payment_date):
|
||||
payment_date = f"{payment_date}T12:00:00"
|
||||
|
||||
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
|
||||
|
||||
payload = {
|
||||
"type": "P",
|
||||
"customer": customer_payload,
|
||||
"payment_form": forma_pago,
|
||||
"complements": [
|
||||
{
|
||||
"type": "pago",
|
||||
"data": [
|
||||
{
|
||||
"data": {
|
||||
"payment_form": forma_pago,
|
||||
"date": _normalize_payment_date(payment.get("date")),
|
||||
"payment_date": payment_date,
|
||||
"amount": _fmt2(amount),
|
||||
"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),
|
||||
"rate": 0.16,
|
||||
"factor": "Tasa",
|
||||
"withholding": False,
|
||||
"base": _fmt2(base),
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
if idempotency_key:
|
||||
payload["idempotency_key"] = idempotency_key
|
||||
return payload
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
|
||||
Flow:
|
||||
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
||||
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
|
||||
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
|
||||
|
||||
Facturapi endpoints used:
|
||||
POST /v2/invoices — create and stamp an invoice
|
||||
@@ -19,11 +17,9 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from datetime import datetime
|
||||
|
||||
from services import facturapi_service
|
||||
from services.cfdi_facturapi_builder import build_egreso_payload, build_pago_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,73 +27,10 @@ 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.
|
||||
|
||||
Uses a transaction-level advisory lock to avoid duplicate provisional
|
||||
folios when multiple CFDIs are enqueued concurrently.
|
||||
"""
|
||||
"""Generate a provisional folio like PRE-00001."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
|
||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||
seq = cur.fetchone()[0]
|
||||
cur.close()
|
||||
@@ -143,340 +76,6 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
|
||||
}
|
||||
|
||||
|
||||
# ─── Payment complements (REP) and credit notes reconciliation ──────────────
|
||||
|
||||
|
||||
def _to_dec(val):
|
||||
if val is None:
|
||||
return Decimal("0")
|
||||
return Decimal(str(val))
|
||||
|
||||
|
||||
def _payload_customer(payload):
|
||||
"""Adapt the receptor block of a stored CFDI payload to builder input."""
|
||||
customer = payload.get("customer") or {}
|
||||
tax_id = customer.get("tax_id")
|
||||
if not tax_id:
|
||||
return None
|
||||
address = customer.get("address") or {}
|
||||
return {
|
||||
"rfc": tax_id,
|
||||
"razon_social": customer.get("legal_name"),
|
||||
"regimen_fiscal": customer.get("tax_system"),
|
||||
"cp": address.get("zip"),
|
||||
}
|
||||
|
||||
|
||||
def _pago_related_amounts(payload):
|
||||
"""Amounts of the related documents of a stored pago payload."""
|
||||
amounts = []
|
||||
for complement in payload.get("complements") or []:
|
||||
data = complement.get("data")
|
||||
blocks = data if isinstance(data, list) else [data]
|
||||
for block in blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
for doc in block.get("related_documents") or []:
|
||||
try:
|
||||
amounts.append(round(float(doc.get("amount") or 0), 2))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return amounts
|
||||
|
||||
|
||||
def _items_signature(payload):
|
||||
"""Comparable signature of a payload's concepts (product key, qty, price)."""
|
||||
signature = []
|
||||
for item in payload.get("items") or []:
|
||||
product = item.get("product") or {}
|
||||
try:
|
||||
signature.append(
|
||||
(
|
||||
str(product.get("product_key") or ""),
|
||||
int(item.get("quantity") or 0),
|
||||
round(float(product.get("price") or 0), 2),
|
||||
)
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return tuple(sorted(signature))
|
||||
|
||||
|
||||
def _stored_signatures(cur, sale_id, cfdi_type):
|
||||
"""Signatures/amounts of the non-cancelled CFDIs of a sale, for dedupe."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT payload_unsigned FROM cfdi_queue
|
||||
WHERE sale_id = %s AND type = %s AND status <> 'cancelled'
|
||||
""",
|
||||
(sale_id, cfdi_type),
|
||||
)
|
||||
values = []
|
||||
for (payload_unsigned,) in cur.fetchall():
|
||||
try:
|
||||
payload = json.loads(payload_unsigned or "{}")
|
||||
except ValueError:
|
||||
continue
|
||||
if cfdi_type == "pago":
|
||||
values.extend(_pago_related_amounts(payload))
|
||||
else:
|
||||
signature = _items_signature(payload)
|
||||
if signature:
|
||||
values.append(signature)
|
||||
return values
|
||||
|
||||
|
||||
def _enqueue_pending_pagos(conn, cur, tenant_config, since_dt):
|
||||
"""Enqueue payment complements for every payment of a stamped PPD invoice."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT q.sale_id, q.uuid_fiscal, q.payload_unsigned,
|
||||
s.total, s.subtotal, s.discount_total, s.tax_total
|
||||
FROM cfdi_queue q
|
||||
JOIN sales s ON s.id = q.sale_id
|
||||
WHERE q.type = 'ingreso' AND q.status = 'stamped'
|
||||
AND q.uuid_fiscal IS NOT NULL AND q.sale_id IS NOT NULL
|
||||
AND (%s::timestamptz IS NULL OR EXISTS (
|
||||
SELECT 1 FROM sale_payments sp
|
||||
WHERE sp.sale_id = q.sale_id
|
||||
AND sp.amount > 0
|
||||
AND sp.created_at >= %s))
|
||||
ORDER BY q.sale_id
|
||||
""",
|
||||
(since_dt, since_dt),
|
||||
)
|
||||
|
||||
created = []
|
||||
for sale_id, original_uuid, payload_unsigned, total, subtotal, discount_total, tax_total in cur.fetchall():
|
||||
try:
|
||||
invoice_payload = json.loads(payload_unsigned or "{}")
|
||||
except ValueError:
|
||||
continue
|
||||
if str(invoice_payload.get("payment_method") or "").upper() != "PPD":
|
||||
continue
|
||||
|
||||
customer = _payload_customer(invoice_payload)
|
||||
if not customer:
|
||||
continue
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, amount, method, created_at
|
||||
FROM sale_payments
|
||||
WHERE sale_id = %s AND amount > 0
|
||||
ORDER BY id
|
||||
""",
|
||||
(sale_id,),
|
||||
)
|
||||
payments = cur.fetchall()
|
||||
if not payments:
|
||||
continue
|
||||
|
||||
already = _stored_signatures(cur, sale_id, "pago")
|
||||
|
||||
base_total = _to_dec(subtotal) - _to_dec(discount_total)
|
||||
tax_rate = (_to_dec(tax_total) / base_total) if base_total > 0 else Decimal("0.16")
|
||||
|
||||
balance = _to_dec(total)
|
||||
for installment, (payment_id, amount, method, created_at) in enumerate(payments, start=1):
|
||||
paid = _to_dec(amount)
|
||||
previous_balance = balance if balance > Decimal("0") else Decimal("0")
|
||||
balance -= paid
|
||||
if paid <= 0:
|
||||
continue
|
||||
if round(float(paid), 2) in already:
|
||||
already.remove(round(float(paid), 2))
|
||||
continue
|
||||
if since_dt and created_at and created_at < since_dt:
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = build_pago_payload(
|
||||
{
|
||||
"id": payment_id,
|
||||
"amount": paid,
|
||||
"payment_method": method,
|
||||
"date": created_at,
|
||||
"installment": installment,
|
||||
"last_balance": previous_balance,
|
||||
"tax_rate": tax_rate,
|
||||
},
|
||||
tenant_config,
|
||||
customer,
|
||||
original_uuid,
|
||||
idempotency_key=f"nexus-rep-{payment_id}",
|
||||
)
|
||||
queued = enqueue_cfdi(conn, sale_id, "pago", payload)
|
||||
except Exception as e:
|
||||
logger.warning("Could not enqueue payment complement for sale_payment %s: %s", payment_id, e)
|
||||
continue
|
||||
|
||||
created.append(
|
||||
{
|
||||
"cfdi_id": queued["id"],
|
||||
"sale_id": sale_id,
|
||||
"sale_payment_id": payment_id,
|
||||
"amount": float(paid),
|
||||
"installment": installment,
|
||||
"original_uuid": original_uuid,
|
||||
}
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
def _enqueue_pending_egresos(conn, cur, tenant_config, since_dt):
|
||||
"""Enqueue credit notes (egreso) for returns of already stamped sales."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT r.id, r.sale_id, r.created_at, s.currency, s.exchange_rate,
|
||||
ing.uuid_fiscal, ing.payload_unsigned
|
||||
FROM returns r
|
||||
JOIN sales s ON s.id = r.sale_id
|
||||
JOIN LATERAL (
|
||||
SELECT q.uuid_fiscal, q.payload_unsigned
|
||||
FROM cfdi_queue q
|
||||
WHERE q.sale_id = r.sale_id AND q.type = 'ingreso'
|
||||
AND q.status = 'stamped' AND q.uuid_fiscal IS NOT NULL
|
||||
ORDER BY q.stamped_at DESC NULLS LAST, q.id DESC
|
||||
LIMIT 1
|
||||
) ing ON TRUE
|
||||
WHERE r.status = 'completed'
|
||||
AND (%s::timestamptz IS NULL OR r.created_at >= %s)
|
||||
ORDER BY r.id
|
||||
""",
|
||||
(since_dt, since_dt),
|
||||
)
|
||||
|
||||
created = []
|
||||
for return_id, sale_id, created_at, currency, exchange_rate, original_uuid, payload_unsigned in cur.fetchall():
|
||||
if since_dt and created_at and created_at < since_dt:
|
||||
continue
|
||||
|
||||
try:
|
||||
invoice_payload = json.loads(payload_unsigned or "{}")
|
||||
except ValueError:
|
||||
invoice_payload = {}
|
||||
customer = _payload_customer(invoice_payload)
|
||||
if not customer:
|
||||
continue
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT si.part_number, si.name, ri.quantity, si.unit_price, si.discount_pct,
|
||||
si.tax_rate, si.clave_prod_serv, si.clave_unidad
|
||||
FROM return_items ri
|
||||
JOIN sale_items si ON si.id = ri.sale_item_id
|
||||
WHERE ri.return_id = %s
|
||||
ORDER BY ri.id
|
||||
""",
|
||||
(return_id,),
|
||||
)
|
||||
items = []
|
||||
for part_number, name, quantity, unit_price, discount_pct, tax_rate, clave_prod, clave_unidad in cur.fetchall():
|
||||
price = float(unit_price or 0)
|
||||
items.append(
|
||||
{
|
||||
"part_number": part_number,
|
||||
"name": name or part_number,
|
||||
"quantity": int(quantity or 0),
|
||||
"unit_price": price,
|
||||
"discount_amount": round(price * float(discount_pct or 0) / 100 * int(quantity or 0), 2),
|
||||
"tax_rate": float(tax_rate if tax_rate is not None else 0.16),
|
||||
"clave_prod_serv": clave_prod,
|
||||
"clave_unidad": clave_unidad,
|
||||
}
|
||||
)
|
||||
if not items:
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = build_egreso_payload(
|
||||
{
|
||||
"id": sale_id,
|
||||
"items": items,
|
||||
"currency": currency,
|
||||
"exchange_rate": exchange_rate,
|
||||
"forma_pago_sat": invoice_payload.get("payment_form"),
|
||||
},
|
||||
tenant_config,
|
||||
customer,
|
||||
original_uuid,
|
||||
folio_number=return_id,
|
||||
idempotency_key=f"nexus-egreso-{return_id}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not build credit note for return %s: %s", return_id, e)
|
||||
continue
|
||||
|
||||
signature = _items_signature(payload)
|
||||
already = _stored_signatures(cur, sale_id, "egreso")
|
||||
if signature and signature in already:
|
||||
already.remove(signature)
|
||||
continue
|
||||
|
||||
try:
|
||||
queued = enqueue_cfdi(conn, sale_id, "egreso", payload)
|
||||
except Exception as e:
|
||||
logger.warning("Could not enqueue credit note for return %s: %s", return_id, e)
|
||||
continue
|
||||
|
||||
created.append(
|
||||
{
|
||||
"cfdi_id": queued["id"],
|
||||
"sale_id": sale_id,
|
||||
"return_id": return_id,
|
||||
"original_uuid": original_uuid,
|
||||
}
|
||||
)
|
||||
return created
|
||||
|
||||
|
||||
def enqueue_pending_complements(conn, tenant_config=None, since=None, backfill=False):
|
||||
"""Enqueue the missing payment complements and credit notes.
|
||||
|
||||
Every payment of a stamped PPD invoice gets a CFDI type "P" linked to the
|
||||
original UUID, and every completed return of a stamped sale gets a CFDI
|
||||
type "E". Idempotent: a movement that already has a non-cancelled CFDI of
|
||||
the matching type/amount is skipped.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection
|
||||
tenant_config: issuer config (only the default zip is used)
|
||||
since: datetime or ISO string; reconcile movements created after it.
|
||||
Defaults to COMPLEMENT_LOOKBACK_DAYS ago.
|
||||
backfill: reconcile the whole history, ignoring `since`
|
||||
|
||||
Returns:
|
||||
dict: {pagos: [...], egresos: [...], since: iso string}
|
||||
"""
|
||||
if backfill:
|
||||
since_dt = None
|
||||
elif since:
|
||||
if isinstance(since, datetime):
|
||||
since_dt = since
|
||||
else:
|
||||
since_dt = datetime.fromisoformat(str(since))
|
||||
if since_dt.tzinfo is None:
|
||||
since_dt = since_dt.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
since_dt = datetime.now(timezone.utc) - timedelta(days=COMPLEMENT_LOOKBACK_DAYS)
|
||||
|
||||
tenant_config = tenant_config or {}
|
||||
cur = conn.cursor()
|
||||
result = {"pagos": [], "egresos": [], "since": str(since_dt) if since_dt else None}
|
||||
|
||||
for key, sweep in (("pagos", _enqueue_pending_pagos), ("egresos", _enqueue_pending_egresos)):
|
||||
try:
|
||||
result[key] = sweep(conn, cur, tenant_config, since_dt)
|
||||
except Exception as e:
|
||||
logger.warning("Could not reconcile %s complements: %s", key, e)
|
||||
conn.rollback()
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return result
|
||||
|
||||
|
||||
def process_queue(conn, tenant_config, dry_run=False):
|
||||
"""Process all pending CFDI items in the queue.
|
||||
|
||||
@@ -492,15 +91,6 @@ 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(
|
||||
@@ -511,7 +101,6 @@ def process_queue(conn, tenant_config, dry_run=False):
|
||||
AND retry_count < %s
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 50
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
(MAX_RETRIES,),
|
||||
)
|
||||
@@ -573,13 +162,6 @@ def process_queue(conn, tenant_config, dry_run=False):
|
||||
)
|
||||
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(
|
||||
{
|
||||
@@ -782,7 +364,7 @@ def get_queue_status(conn, filters=None):
|
||||
params.append(int(filters["sale_id"]))
|
||||
|
||||
if filters.get("type"):
|
||||
where_clauses.append("LOWER(q.type) = LOWER(%s)")
|
||||
where_clauses.append("q.type = %s")
|
||||
params.append(filters["type"])
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
@@ -794,12 +376,8 @@ def get_queue_status(conn, filters=None):
|
||||
f"""
|
||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||
q.retry_count, q.provisional_folio, q.error_message,
|
||||
q.cancel_motive, q.created_at, q.stamped_at, q.external_id,
|
||||
c.name as customer_name, c.rfc,
|
||||
s.subtotal, s.tax_total, s.total, s.payment_method
|
||||
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
|
||||
FROM cfdi_queue q
|
||||
LEFT JOIN sales s ON q.sale_id = s.id
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
WHERE {where}
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
@@ -823,12 +401,6 @@ def get_queue_status(conn, filters=None):
|
||||
"created_at": str(r[9]) if r[9] else None,
|
||||
"stamped_at": str(r[10]) if r[10] else None,
|
||||
"external_id": r[11],
|
||||
"customer_name": r[12],
|
||||
"rfc": r[13],
|
||||
"subtotal": float(r[14]) if r[14] else 0,
|
||||
"tax_total": float(r[15]) if r[15] else 0,
|
||||
"total": float(r[16]) if r[16] else 0,
|
||||
"payment_method": r[17],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,463 +0,0 @@
|
||||
"""Credit risk scoring for store credit ("fiado") — A9.
|
||||
|
||||
Pure rule-based / heuristic scoring: no ML dependencies, works with very little
|
||||
history and stays conservative when evidence is scarce. Every point of the score
|
||||
is traceable to an explicit rule listed in ``reasons``.
|
||||
|
||||
Public API:
|
||||
score_customer(tenant_conn, customer_id) -> dict
|
||||
is_enforcement_enabled(tenant_conn) -> bool
|
||||
|
||||
The score is 0..100 where **higher is safer**. It is built from up to four
|
||||
independent signals, each 0..100, then combined with fixed weights over the
|
||||
signals that actually have data (missing signals are not invented):
|
||||
|
||||
tenure 0.15 months since customers.created_at
|
||||
purchase 0.25 frequency + recency + value from sales
|
||||
payment 0.35 punctuality on credit sales + open overdue aging
|
||||
utilization 0.25 credit_balance / credit_limit
|
||||
|
||||
Credit sales are assumed to be due 30 days after the sale, matching the
|
||||
receivables aging convention in ``blueprints/accounting_bp.py``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
logger = logging.getLogger('pos')
|
||||
|
||||
# Credit sale terms used across the app for receivables aging.
|
||||
CREDIT_TERMS_DAYS = 30
|
||||
|
||||
# Weight of each signal in the combined score.
|
||||
_WEIGHTS = {
|
||||
'tenure': 0.15,
|
||||
'purchase': 0.25,
|
||||
'payment': 0.35,
|
||||
'utilization': 0.25,
|
||||
}
|
||||
|
||||
_TIER_ORDER = {'risk': 0, 'medium': 1, 'good': 2}
|
||||
|
||||
# Enforcement flag key in tenant_config. Off by default.
|
||||
ENFORCE_FLAG = 'credit_scoring_enforce'
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _aware(dt):
|
||||
"""Normalize a possibly-naive datetime to UTC-aware."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _clamp(value, low=0, high=100):
|
||||
return max(low, min(high, value))
|
||||
|
||||
|
||||
def is_enforcement_enabled(tenant_conn):
|
||||
"""Return True when the tenant opted into blocking credit sales for 'risk'.
|
||||
|
||||
Defaults to False on any error or missing flag so the sale flow never
|
||||
breaks because of scoring.
|
||||
"""
|
||||
try:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (ENFORCE_FLAG,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return False
|
||||
return str(row[0]).strip().lower() in ('true', '1', 'yes', 'on')
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("credit_scoring: could not read enforce flag: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _collect_evidence(cur, customer_id):
|
||||
"""Read every raw signal in a few queries. Returns a dict or None if the
|
||||
customer does not exist."""
|
||||
cur.execute("""
|
||||
SELECT id, name, created_at, credit_limit, credit_balance,
|
||||
price_tier, is_active
|
||||
FROM customers WHERE id = %s
|
||||
""", (customer_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
ev = {
|
||||
'customer_id': row[0],
|
||||
'name': row[1],
|
||||
'created_at': _aware(row[2]),
|
||||
'credit_limit': float(row[3] or 0),
|
||||
'credit_balance': float(row[4] or 0),
|
||||
'price_tier': row[5],
|
||||
'is_active': row[6],
|
||||
}
|
||||
|
||||
# Completed sales aggregate (frequency / value / recency).
|
||||
cur.execute("""
|
||||
SELECT COUNT(*), COALESCE(SUM(total), 0), MIN(created_at),
|
||||
MAX(created_at), COALESCE(AVG(total), 0),
|
||||
COUNT(*) FILTER (WHERE sale_type = 'credit')
|
||||
FROM sales
|
||||
WHERE customer_id = %s AND status = 'completed'
|
||||
""", (customer_id,))
|
||||
total_orders, ltv, first_purchase, last_purchase, aov, credit_sales = cur.fetchone()
|
||||
ev.update({
|
||||
'total_orders': int(total_orders or 0),
|
||||
'ltv': float(ltv or 0),
|
||||
'first_purchase': _aware(first_purchase),
|
||||
'last_purchase': _aware(last_purchase),
|
||||
'avg_order_value': float(aov or 0),
|
||||
'credit_sales': int(credit_sales or 0),
|
||||
})
|
||||
|
||||
# Open (unpaid) credit sales: balance and due date for aging.
|
||||
cur.execute("""
|
||||
SELECT s.id, s.total, s.created_at,
|
||||
s.total - COALESCE(SUM(sp.amount), 0) AS balance
|
||||
FROM sales s
|
||||
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
|
||||
WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed'
|
||||
GROUP BY s.id, s.total, s.created_at
|
||||
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0.005
|
||||
ORDER BY s.created_at
|
||||
""", (customer_id,))
|
||||
|
||||
now = _now()
|
||||
open_sales = []
|
||||
for sale_id, total, created_at, balance in cur.fetchall():
|
||||
created_at = _aware(created_at)
|
||||
due = created_at + timedelta(days=CREDIT_TERMS_DAYS) if created_at else None
|
||||
overdue = max(0, (now - due).days) if due else 0
|
||||
open_sales.append({
|
||||
'sale_id': sale_id,
|
||||
'total': float(total or 0),
|
||||
'created_at': created_at,
|
||||
'balance': float(balance or 0),
|
||||
'due_date': due,
|
||||
'days_overdue': overdue,
|
||||
})
|
||||
ev['open_sales'] = open_sales
|
||||
ev['open_balance'] = round(sum(s['balance'] for s in open_sales), 2)
|
||||
ev['max_overdue_days'] = max((s['days_overdue'] for s in open_sales), default=0)
|
||||
ev['overdue_balance'] = round(
|
||||
sum(s['balance'] for s in open_sales if s['days_overdue'] > 0), 2
|
||||
)
|
||||
|
||||
# Aging buckets (by days overdue).
|
||||
buckets = {'current': 0.0, 'd1_30': 0.0, 'd31_60': 0.0, 'd61_90': 0.0, 'd90_plus': 0.0}
|
||||
for s in open_sales:
|
||||
d = s['days_overdue']
|
||||
if d == 0:
|
||||
buckets['current'] += s['balance']
|
||||
elif d <= 30:
|
||||
buckets['d1_30'] += s['balance']
|
||||
elif d <= 60:
|
||||
buckets['d31_60'] += s['balance']
|
||||
elif d <= 90:
|
||||
buckets['d61_90'] += s['balance']
|
||||
else:
|
||||
buckets['d90_plus'] += s['balance']
|
||||
ev['aging'] = {k: round(v, 2) for k, v in buckets.items()}
|
||||
|
||||
# Settled credit sales: was the final payment within terms?
|
||||
cur.execute("""
|
||||
SELECT s.created_at, MAX(sp.created_at) AS last_payment
|
||||
FROM sales s
|
||||
JOIN sale_payments sp ON sp.sale_id = s.id
|
||||
WHERE s.customer_id = %s AND s.sale_type = 'credit' AND s.status = 'completed'
|
||||
GROUP BY s.id, s.total, s.created_at
|
||||
HAVING SUM(sp.amount) >= s.total - 0.005
|
||||
""", (customer_id,))
|
||||
settled = []
|
||||
for created_at, last_payment in cur.fetchall():
|
||||
created_at = _aware(created_at)
|
||||
last_payment = _aware(last_payment)
|
||||
if not created_at or not last_payment:
|
||||
continue
|
||||
due = created_at + timedelta(days=CREDIT_TERMS_DAYS)
|
||||
settled.append({'days_late': max(0, (last_payment - due).days)})
|
||||
ev['settled_credit_sales'] = settled
|
||||
|
||||
# Direct customer payments (abonos) — extra evidence of engagement.
|
||||
cur.execute("""
|
||||
SELECT COUNT(*), COALESCE(SUM(amount), 0), MAX(created_at)
|
||||
FROM customer_payments WHERE customer_id = %s
|
||||
""", (customer_id,))
|
||||
cp_count, cp_total, cp_last = cur.fetchone()
|
||||
ev.update({
|
||||
'customer_payments_count': int(cp_count or 0),
|
||||
'customer_payments_total': float(cp_total or 0),
|
||||
'customer_payments_last': _aware(cp_last),
|
||||
})
|
||||
|
||||
return ev
|
||||
|
||||
|
||||
# ─── Signal scoring ─────────────────────────────────────
|
||||
|
||||
def _tenure_signal(ev, reasons):
|
||||
created_at = ev['created_at']
|
||||
if not created_at:
|
||||
return None
|
||||
days = max(0, (_now() - created_at).days)
|
||||
months = days / 30.44
|
||||
if months < 3:
|
||||
score = 45
|
||||
elif months < 6:
|
||||
score = 60
|
||||
elif months < 12:
|
||||
score = 75
|
||||
elif months < 24:
|
||||
score = 85
|
||||
else:
|
||||
score = 95
|
||||
reasons.append(f"Cliente con {int(months)} meses de antigüedad ({days} días).")
|
||||
if ev.get('is_active') is False:
|
||||
score = min(score, 40)
|
||||
reasons.append("Cliente marcado como inactivo.")
|
||||
return score
|
||||
|
||||
|
||||
def _purchase_signal(ev, reasons):
|
||||
total_orders = ev['total_orders']
|
||||
if total_orders <= 0:
|
||||
reasons.append("Sin historial de compras registrado.")
|
||||
return None
|
||||
|
||||
if total_orders == 1:
|
||||
freq = 50
|
||||
elif total_orders <= 3:
|
||||
freq = 65
|
||||
elif total_orders <= 9:
|
||||
freq = 80
|
||||
elif total_orders <= 24:
|
||||
freq = 90
|
||||
else:
|
||||
freq = 95
|
||||
|
||||
last = ev['last_purchase']
|
||||
days_since = (_now() - last).days if last else None
|
||||
if days_since is None:
|
||||
recency = 60
|
||||
elif days_since <= 30:
|
||||
recency = 100
|
||||
elif days_since <= 90:
|
||||
recency = 80
|
||||
elif days_since <= 180:
|
||||
recency = 60
|
||||
elif days_since <= 365:
|
||||
recency = 40
|
||||
else:
|
||||
recency = 25
|
||||
|
||||
score = round(0.6 * freq + 0.4 * recency)
|
||||
reasons.append(
|
||||
f"{total_orders} compras por ${ev['ltv']:.2f} "
|
||||
f"(ticket promedio ${ev['avg_order_value']:.2f}), "
|
||||
f"última hace {days_since} días."
|
||||
if days_since is not None else
|
||||
f"{total_orders} compras por ${ev['ltv']:.2f}."
|
||||
)
|
||||
return _clamp(score)
|
||||
|
||||
|
||||
def _payment_signal(ev, reasons):
|
||||
has_history = (
|
||||
ev['credit_sales'] > 0
|
||||
or ev['open_balance'] > 0
|
||||
or ev['customer_payments_count'] > 0
|
||||
)
|
||||
if not has_history:
|
||||
reasons.append("Sin historial de crédito ni abonos.")
|
||||
return None
|
||||
|
||||
settled = ev['settled_credit_sales']
|
||||
if settled:
|
||||
on_time = sum(1 for s in settled if s['days_late'] <= 0)
|
||||
late_ratio = (len(settled) - on_time) / len(settled)
|
||||
base = 90 - 60 * late_ratio
|
||||
reasons.append(
|
||||
f"{on_time}/{len(settled)} créditos liquidados dentro del plazo de "
|
||||
f"{CREDIT_TERMS_DAYS} días."
|
||||
)
|
||||
else:
|
||||
base = 70 if ev['credit_sales'] > 0 or ev['open_balance'] > 0 else 75
|
||||
reasons.append("Sin créditos liquidados aún; sin historial de puntualidad.")
|
||||
|
||||
max_overdue = ev['max_overdue_days']
|
||||
if max_overdue > 0:
|
||||
penalty = min(55.0, 15.0 + max_overdue * 0.5)
|
||||
base -= penalty
|
||||
reasons.append(
|
||||
f"Mora abierta: ${ev['overdue_balance']:.2f} con hasta {max_overdue} "
|
||||
f"días de atraso."
|
||||
)
|
||||
|
||||
if ev['credit_limit'] > 0:
|
||||
util = ev['credit_balance'] / ev['credit_limit']
|
||||
if util > 1.0:
|
||||
base -= 15
|
||||
reasons.append(f"Crédito excedido ({util * 100:.0f}% del límite).")
|
||||
elif util > 0.9:
|
||||
base -= 8
|
||||
reasons.append(f"Crédito casi al límite ({util * 100:.0f}%).")
|
||||
|
||||
return _clamp(round(base))
|
||||
|
||||
|
||||
def _utilization_signal(ev, reasons):
|
||||
limit = ev['credit_limit']
|
||||
balance = ev['credit_balance']
|
||||
if limit <= 0:
|
||||
if balance > 0:
|
||||
reasons.append(f"Saldo de ${balance:.2f} sin límite de crédito definido.")
|
||||
return 45
|
||||
return None
|
||||
|
||||
util = balance / limit
|
||||
if util <= 0.001:
|
||||
score = 100
|
||||
elif util <= 0.3:
|
||||
score = 95
|
||||
elif util <= 0.6:
|
||||
score = 80
|
||||
elif util <= 0.9:
|
||||
score = 60
|
||||
elif util <= 1.0:
|
||||
score = 40
|
||||
else:
|
||||
score = 15
|
||||
reasons.append(f"Utilización de crédito {util * 100:.0f}% (${balance:.2f} / ${limit:.2f}).")
|
||||
return score
|
||||
|
||||
|
||||
def _tier_from_score(score, data_points, max_overdue_days, behavioral_evidence):
|
||||
if max_overdue_days > 60:
|
||||
return 'risk'
|
||||
if score >= 75 and data_points >= 2 and behavioral_evidence:
|
||||
tier = 'good'
|
||||
elif score >= 50:
|
||||
tier = 'medium'
|
||||
else:
|
||||
tier = 'risk'
|
||||
# Conservative when mora is building or evidence is thin.
|
||||
if max_overdue_days > 30 and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
|
||||
tier = 'medium'
|
||||
if not behavioral_evidence and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
|
||||
tier = 'medium'
|
||||
if data_points <= 1 and _TIER_ORDER[tier] > _TIER_ORDER['medium']:
|
||||
tier = 'medium'
|
||||
return tier
|
||||
|
||||
|
||||
def _recommended_limit(tier, ev):
|
||||
if ev['total_orders'] <= 0:
|
||||
return 0.0
|
||||
aov = ev['avg_order_value'] or (ev['ltv'] / ev['total_orders'])
|
||||
if tier == 'good':
|
||||
factor = 4.0
|
||||
elif tier == 'medium':
|
||||
factor = 2.0
|
||||
else:
|
||||
return 0.0 # risk: do not recommend extending new credit
|
||||
suggested = aov * factor
|
||||
suggested = round(suggested / 100.0) * 100.0
|
||||
return float(min(suggested, 200000.0))
|
||||
|
||||
|
||||
def score_customer(tenant_conn, customer_id):
|
||||
"""Score a customer's credit risk.
|
||||
|
||||
Returns a dict::
|
||||
|
||||
{
|
||||
'score': 0..100, # higher = safer
|
||||
'tier': 'good'|'medium'|'risk',
|
||||
'recommended_limit': float,
|
||||
'reasons': [str, ...],
|
||||
'data_points': int, # number of signals with real data (0..4)
|
||||
'components': {...}, # per-signal sub-scores (None when absent)
|
||||
'metrics': {...}, # raw evidence for auditing
|
||||
'computed_at': iso8601 str,
|
||||
}
|
||||
|
||||
Raises ValueError if the customer does not exist.
|
||||
"""
|
||||
cur = tenant_conn.cursor()
|
||||
try:
|
||||
ev = _collect_evidence(cur, customer_id)
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
if ev is None:
|
||||
raise ValueError(f"Customer {customer_id} not found")
|
||||
|
||||
reasons = []
|
||||
components = {
|
||||
'tenure': _tenure_signal(ev, reasons),
|
||||
'purchase': _purchase_signal(ev, reasons),
|
||||
'payment': _payment_signal(ev, reasons),
|
||||
'utilization': _utilization_signal(ev, reasons),
|
||||
}
|
||||
|
||||
available = {k: v for k, v in components.items() if v is not None}
|
||||
data_points = len(available)
|
||||
|
||||
if available:
|
||||
weight_sum = sum(_WEIGHTS[k] for k in available)
|
||||
score = sum(available[k] * _WEIGHTS[k] for k in available) / weight_sum
|
||||
else:
|
||||
score = 50.0
|
||||
score = int(round(_clamp(score)))
|
||||
|
||||
behavioral = components['purchase'] is not None or components['payment'] is not None
|
||||
|
||||
# Hard cap: heavy mora can never yield a high score.
|
||||
if ev['max_overdue_days'] > 60:
|
||||
score = min(score, 39)
|
||||
elif ev['max_overdue_days'] > 30:
|
||||
score = min(score, 60)
|
||||
|
||||
tier = _tier_from_score(score, data_points, ev['max_overdue_days'], behavioral)
|
||||
recommended = _recommended_limit(tier, ev)
|
||||
|
||||
if data_points <= 1:
|
||||
reasons.append(
|
||||
f"Pocos datos ({data_points} señal{'es' if data_points != 1 else ''}); "
|
||||
"evaluación conservadora."
|
||||
)
|
||||
|
||||
return {
|
||||
'customer_id': customer_id,
|
||||
'score': score,
|
||||
'tier': tier,
|
||||
'recommended_limit': recommended,
|
||||
'reasons': reasons,
|
||||
'data_points': data_points,
|
||||
'components': components,
|
||||
'metrics': {
|
||||
'months_active': round((_now() - ev['created_at']).days / 30.44, 1) if ev['created_at'] else None,
|
||||
'total_orders': ev['total_orders'],
|
||||
'ltv': round(ev['ltv'], 2),
|
||||
'avg_order_value': round(ev['avg_order_value'], 2),
|
||||
'days_since_last_purchase': (_now() - ev['last_purchase']).days if ev['last_purchase'] else None,
|
||||
'credit_limit': ev['credit_limit'],
|
||||
'credit_balance': ev['credit_balance'],
|
||||
'open_credit_balance': ev['open_balance'],
|
||||
'max_overdue_days': ev['max_overdue_days'],
|
||||
'overdue_balance': ev['overdue_balance'],
|
||||
'aging': ev['aging'],
|
||||
'credit_sales': ev['credit_sales'],
|
||||
'settled_credit_sales': len(ev['settled_credit_sales']),
|
||||
},
|
||||
'computed_at': _now().isoformat(),
|
||||
}
|
||||
@@ -7,16 +7,7 @@ Provides:
|
||||
- Customer analytics (LTV, frequency, churn risk)
|
||||
"""
|
||||
|
||||
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
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
# ─── Customer Activities ─────────────────────────────
|
||||
@@ -311,15 +302,11 @@ 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 = (now - last_purchase).days
|
||||
days_since_last = (datetime.utcnow() - last_purchase).days
|
||||
|
||||
# Purchase frequency (orders per month)
|
||||
frequency = 0.0
|
||||
@@ -327,69 +314,13 @@ def get_customer_analytics(conn, customer_id):
|
||||
months = max(1, (last_purchase - first_purchase).days / 30.0)
|
||||
frequency = round(total_orders / months, 2)
|
||||
|
||||
# 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: no purchase in 90+ days = high, 60-90 = medium, <60 = low
|
||||
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:
|
||||
if days_since_last is not None:
|
||||
if 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("""
|
||||
@@ -431,15 +362,6 @@ 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,
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
"""Cross-sell recommendations for the POS cart ("esto + esto").
|
||||
|
||||
Given the inventory_ids already in the cart, suggest complementary parts
|
||||
from two independent sources, merged and ranked:
|
||||
|
||||
1. Curated part kits — ``services.part_kits.KIT_SUGGESTIONS`` (Spanish
|
||||
aftermarket knowledge; works from day one, no sales data required).
|
||||
2. Real co-occurrence — inventory items that historically appear in the
|
||||
same ``sale_id`` as one of the cart items (learns from actual sales).
|
||||
|
||||
Items that already have stock are preferred, and items already in the cart
|
||||
are never suggested.
|
||||
|
||||
Public API:
|
||||
get_cross_sell(conn, inventory_ids, limit=5) -> list[dict]
|
||||
"""
|
||||
|
||||
from services.part_kits import get_kit_suggestions
|
||||
|
||||
# Same accent-stripping used by catalog_service so that "Batería" in the
|
||||
# inventory matches the unaccented keywords in KIT_SUGGESTIONS.
|
||||
_ACCENTS = [
|
||||
('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'),
|
||||
('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'),
|
||||
('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), ('Ñ', 'N'),
|
||||
]
|
||||
|
||||
# Ranking weights: stock first (a suggestion you cannot sell is useless),
|
||||
# then source (real co-occurrence beats the curated kit), then evidence.
|
||||
_W_STOCK = 1000
|
||||
_W_COOC = 100
|
||||
_W_KIT = 50
|
||||
_W_FREQ = 10
|
||||
|
||||
|
||||
def _norm_text(value):
|
||||
"""Upper-case and strip accents for keyword comparison."""
|
||||
text = (value or '').upper()
|
||||
for a, b in _ACCENTS:
|
||||
text = text.replace(a, b)
|
||||
return text
|
||||
|
||||
|
||||
def _norm_expr(column):
|
||||
"""SQL expression that upper-cases and strips accents from a column."""
|
||||
expr = f"UPPER({column})"
|
||||
for a, b in _ACCENTS:
|
||||
expr = f"REPLACE({expr}, '{a}', '{b}')"
|
||||
return expr
|
||||
|
||||
|
||||
def _cart_items(conn, inventory_ids):
|
||||
"""Load the cart items (id, name) so we can derive kit keywords."""
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, part_number, name FROM inventory WHERE id = ANY(%s)",
|
||||
(list(inventory_ids),),
|
||||
)
|
||||
return cur.fetchall()
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def _kit_candidates(conn, cart_items, exclude_ids, limit):
|
||||
"""Inventory rows whose name matches a curated kit keyword.
|
||||
|
||||
Returns list of (inventory_id, part_number, name, price, stock, reason).
|
||||
"""
|
||||
keyword_sources = {}
|
||||
patterns = []
|
||||
for _inv_id, _pn, name in cart_items:
|
||||
for keyword in get_kit_suggestions(name):
|
||||
norm = _norm_text(keyword)
|
||||
if not norm or norm in keyword_sources:
|
||||
continue
|
||||
keyword_sources[norm] = (name, keyword)
|
||||
patterns.append(f'%{norm}%')
|
||||
if not patterns:
|
||||
return []
|
||||
|
||||
name_expr = _norm_expr('i.name')
|
||||
sql = f"""
|
||||
SELECT DISTINCT ON (i.id)
|
||||
i.id, i.part_number, i.name, i.price_1,
|
||||
COALESCE(s.stock, 0) AS stock, {name_expr} AS name_norm
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = true
|
||||
AND i.id <> ALL(%s)
|
||||
AND {name_expr} LIKE ANY(%s)
|
||||
ORDER BY i.id, i.name
|
||||
LIMIT %s
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(sql, (list(exclude_ids), patterns, limit * 4))
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
out = []
|
||||
for inv_id, part_number, name, price, stock, name_norm in rows:
|
||||
source_name, keyword = None, None
|
||||
for norm, (src, kw) in keyword_sources.items():
|
||||
if norm in (name_norm or ''):
|
||||
source_name, keyword = src, kw
|
||||
break
|
||||
if source_name is None:
|
||||
continue
|
||||
out.append((inv_id, part_number, name, price, stock,
|
||||
f"Complemento habitual de {source_name} ({keyword})"))
|
||||
return out
|
||||
|
||||
|
||||
def _cooccurrence_candidates(conn, inventory_ids, exclude_ids, limit):
|
||||
"""Inventory items historically sold in the same sale as a cart item.
|
||||
|
||||
Returns list of (inventory_id, part_number, name, price, stock, freq, paired_with).
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT si2.inventory_id,
|
||||
COUNT(DISTINCT si2.sale_id) AS freq,
|
||||
MIN(si1.name) AS paired_with
|
||||
FROM sale_items si1
|
||||
JOIN sale_items si2
|
||||
ON si2.sale_id = si1.sale_id
|
||||
AND si2.inventory_id IS NOT NULL
|
||||
AND si2.inventory_id <> ALL(%s)
|
||||
WHERE si1.inventory_id = ANY(%s)
|
||||
GROUP BY si2.inventory_id
|
||||
ORDER BY freq DESC, si2.inventory_id
|
||||
LIMIT %s
|
||||
""", (list(exclude_ids), list(inventory_ids), limit * 4))
|
||||
pairs = cur.fetchall()
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
ids = [r[0] for r in pairs]
|
||||
cur.execute("""
|
||||
SELECT i.id, i.part_number, i.name, i.price_1,
|
||||
COALESCE(s.stock, 0) AS stock
|
||||
FROM inventory i
|
||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = true AND i.id = ANY(%s)
|
||||
""", (ids,))
|
||||
by_id = {r[0]: r for r in cur.fetchall()}
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
out = []
|
||||
for inv_id, freq, paired_with in pairs:
|
||||
row = by_id.get(inv_id)
|
||||
if not row:
|
||||
continue
|
||||
_rid, part_number, name, price, stock = row
|
||||
out.append((inv_id, part_number, name, price, stock, freq, paired_with))
|
||||
return out
|
||||
|
||||
|
||||
def get_cross_sell(conn, inventory_ids, limit=5):
|
||||
"""Suggest complementary parts for the given cart contents.
|
||||
|
||||
Args:
|
||||
conn: psycopg2 connection to the tenant DB.
|
||||
inventory_ids: iterable of inventory ids currently in the cart.
|
||||
limit: max number of suggestions.
|
||||
|
||||
Returns:
|
||||
list of dicts: {inventory_id, name, part_number, price, reason, stock, source}
|
||||
"""
|
||||
try:
|
||||
cart_ids = [int(i) for i in (inventory_ids or [])]
|
||||
except (TypeError, ValueError):
|
||||
cart_ids = []
|
||||
cart_ids = list(dict.fromkeys(cart_ids))[:50]
|
||||
if not cart_ids:
|
||||
return []
|
||||
|
||||
scored = {} # inventory_id -> dict with score + payload
|
||||
|
||||
try:
|
||||
cart_items = _cart_items(conn, cart_ids)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
return []
|
||||
|
||||
# 1. Co-occurrence (real sales) — highest evidence weight.
|
||||
try:
|
||||
for inv_id, part_number, name, price, stock, freq, paired_with in \
|
||||
_cooccurrence_candidates(conn, cart_ids, cart_ids, limit):
|
||||
score = (stock > 0) * _W_STOCK + _W_COOC + min(freq, 5) * _W_FREQ
|
||||
scored[inv_id] = {
|
||||
'inventory_id': inv_id,
|
||||
'name': name,
|
||||
'part_number': part_number,
|
||||
'price': float(price) if price is not None else None,
|
||||
'stock': int(stock or 0),
|
||||
'reason': f"Suele venderse con {paired_with or 'este artículo'}",
|
||||
'source': 'cooccurrence',
|
||||
'_score': score,
|
||||
}
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
# 2. Curated kits — fill in whatever co-occurrence did not cover.
|
||||
try:
|
||||
for inv_id, part_number, name, price, stock, reason in \
|
||||
_kit_candidates(conn, cart_items, cart_ids, limit):
|
||||
score = (stock > 0) * _W_STOCK + _W_KIT
|
||||
if inv_id in scored:
|
||||
# Already suggested by real sales: bump evidence, keep reason.
|
||||
scored[inv_id]['_score'] += _W_KIT
|
||||
continue
|
||||
scored[inv_id] = {
|
||||
'inventory_id': inv_id,
|
||||
'name': name,
|
||||
'part_number': part_number,
|
||||
'price': float(price) if price is not None else None,
|
||||
'stock': int(stock or 0),
|
||||
'reason': reason,
|
||||
'source': 'kit',
|
||||
'_score': score,
|
||||
}
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
ordered = sorted(scored.values(), key=lambda d: d['_score'], reverse=True)
|
||||
for item in ordered:
|
||||
item.pop('_score', None)
|
||||
return ordered[:max(1, limit)]
|
||||
@@ -108,18 +108,6 @@ 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)
|
||||
|
||||
@@ -150,9 +138,7 @@ def get_organization(org_id: str, api_key: str) -> dict:
|
||||
def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict:
|
||||
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
|
||||
|
||||
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.
|
||||
cer_b64 and key_b64 are base64-encoded strings.
|
||||
"""
|
||||
api_key = get_api_key(tenant_config)
|
||||
org_id = _get_org_id(tenant_config)
|
||||
@@ -164,11 +150,11 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
|
||||
|
||||
url = f"{BASE_URL}/organizations/{org_id}/certificate"
|
||||
files = {
|
||||
"cer": ("certificate.cer", cer_bytes, "application/octet-stream"),
|
||||
"key": ("private_key.key", key_bytes, "application/octet-stream"),
|
||||
"password": (None, password),
|
||||
"certificate": ("certificate.cer", cer_bytes, "application/octet-stream"),
|
||||
"private_key": ("private_key.key", key_bytes, "application/octet-stream"),
|
||||
"secret": (None, password),
|
||||
}
|
||||
resp = requests.put(url, auth=(api_key, ""), files=files, timeout=60)
|
||||
resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60)
|
||||
if not resp.ok:
|
||||
raise FacturapiError(
|
||||
f"CSD upload failed: {resp.status_code} {resp.text[:500]}",
|
||||
@@ -184,9 +170,13 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str:
|
||||
1. FACTURAPI_USER_KEY environment variable
|
||||
2. tenant_config.facturapi_key if it starts with sk_user_
|
||||
"""
|
||||
user_key = _find_user_key(tenant_config)
|
||||
user_key = _get_user_key()
|
||||
if user_key:
|
||||
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")
|
||||
|
||||
|
||||
@@ -216,7 +206,7 @@ def find_organization_by_rfc(tenant_config: dict) -> dict | None:
|
||||
|
||||
|
||||
def create_organization(tenant_config: dict) -> dict:
|
||||
"""Create or reuse a Facturapi organization, configure legal data and return a live key.
|
||||
"""Create a new Facturapi organization for the tenant and return live key.
|
||||
|
||||
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
|
||||
Uses tenant RFC/razon_social if available.
|
||||
@@ -226,18 +216,8 @@ def create_organization(tenant_config: dict) -> dict:
|
||||
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
|
||||
|
||||
# 1) Reuse the organization already stored locally, if it still exists in Facturapi.
|
||||
local_org_id = _get_org_id(tenant_config)
|
||||
if local_org_id:
|
||||
try:
|
||||
get_organization(local_org_id, user_key)
|
||||
org_id = local_org_id
|
||||
except FacturapiError:
|
||||
local_org_id = None
|
||||
|
||||
# 2) Try to find an existing organization by RFC (legacy / Horux-style lookup).
|
||||
if not local_org_id and rfc:
|
||||
existing = find_organization_by_rfc(tenant_config)
|
||||
# First try to find existing org by RFC
|
||||
existing = find_organization_by_rfc(tenant_config) if rfc else None
|
||||
if existing:
|
||||
org_id = existing["id"]
|
||||
else:
|
||||
@@ -246,20 +226,6 @@ def create_organization(tenant_config: dict) -> dict:
|
||||
org_id = org.get("id")
|
||||
if not org_id:
|
||||
raise FacturapiError("Could not create organization: no id returned")
|
||||
elif not local_org_id:
|
||||
payload = {"name": name}
|
||||
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
|
||||
org_id = org.get("id")
|
||||
if not org_id:
|
||||
raise FacturapiError("Could not create organization: no id returned")
|
||||
|
||||
# Configure fiscal/legal data (required before Live mode can be used)
|
||||
try:
|
||||
update_organization_legal(tenant_config, org_id)
|
||||
except FacturapiError:
|
||||
# If legal update fails, still return org info so the caller can surface it,
|
||||
# but do not generate a live key because it would be unusable.
|
||||
return {"org_id": org_id, "api_key": None, "legal_updated": False}
|
||||
|
||||
# Generate live secret key
|
||||
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
|
||||
@@ -267,80 +233,7 @@ def create_organization(tenant_config: dict) -> dict:
|
||||
if not live_key:
|
||||
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
||||
|
||||
return {"org_id": org_id, "api_key": live_key, "legal_updated": True}
|
||||
|
||||
|
||||
def _build_legal_payload(tenant_config: dict) -> dict:
|
||||
"""Build Facturapi /organizations/{id}/legal payload from tenant config.
|
||||
|
||||
Facturapi expects `name`, `legal_name`, `tax_system` and an `address` object
|
||||
with at least `street` and `exterior` non-empty. `tax_id` is not accepted
|
||||
by this endpoint (it is set from the CSD or from the organization profile).
|
||||
"""
|
||||
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||
legal_name = (tenant_config.get("razon_social") or "").strip()
|
||||
tax_system = (tenant_config.get("regimen_fiscal") or "").strip() or "601"
|
||||
zip_code = (tenant_config.get("cp") or "").strip() or "00000"
|
||||
if not rfc or not legal_name:
|
||||
raise FacturapiError("RFC y Razón Social son obligatorios para configurar la organización en Facturapi")
|
||||
|
||||
name = legal_name or rfc or "Nexus"
|
||||
street = (tenant_config.get("direccion") or "").strip() or "No especificada"
|
||||
exterior = (
|
||||
(tenant_config.get("exterior") or "").strip()
|
||||
or (tenant_config.get("numero_exterior") or "").strip()
|
||||
or "S/N"
|
||||
)
|
||||
|
||||
address = {
|
||||
"zip": zip_code,
|
||||
"street": street,
|
||||
"exterior": exterior,
|
||||
}
|
||||
|
||||
optional_address_fields = {
|
||||
"interior": (tenant_config.get("interior") or tenant_config.get("numero_interior") or "").strip(),
|
||||
"neighborhood": (tenant_config.get("colonia") or "").strip(),
|
||||
"city": (tenant_config.get("ciudad") or "").strip(),
|
||||
"municipality": (tenant_config.get("municipio") or "").strip(),
|
||||
"state": (tenant_config.get("estado") or "").strip(),
|
||||
}
|
||||
for key, value in optional_address_fields.items():
|
||||
if value:
|
||||
address[key] = value
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"legal_name": legal_name,
|
||||
"tax_system": tax_system,
|
||||
"address": address,
|
||||
}
|
||||
|
||||
|
||||
def update_organization_legal(tenant_config: dict, org_id: str) -> dict:
|
||||
"""Update the legal/fiscal data of a Facturapi organization.
|
||||
|
||||
Uses the user key (admin) because the org may not have a live key yet.
|
||||
"""
|
||||
user_key = _get_user_key_for_tenant(tenant_config)
|
||||
payload = _build_legal_payload(tenant_config)
|
||||
return _request("PUT", f"/organizations/{org_id}/legal", user_key, json_payload=payload, timeout=60)
|
||||
|
||||
|
||||
def _get_status_key(tenant_config: dict) -> str | None:
|
||||
"""Return the best key for read-only organization status checks.
|
||||
|
||||
Prefer the Facturapi user key because the live secret key cannot read
|
||||
organization metadata until the org is production-ready.
|
||||
"""
|
||||
user = _get_user_key()
|
||||
if user:
|
||||
return user
|
||||
for key in ("facturapi_key", "cfdi_facturapi_key"):
|
||||
tenant_key = (tenant_config.get(key) or "").strip()
|
||||
if tenant_key.startswith("sk_user_"):
|
||||
return tenant_key
|
||||
return _get_secret_key(tenant_config)
|
||||
return {"org_id": org_id, "api_key": live_key}
|
||||
|
||||
|
||||
def get_org_status(tenant_config: dict) -> dict:
|
||||
@@ -356,12 +249,12 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
"error": None,
|
||||
}
|
||||
|
||||
has_secret = bool(_get_secret_key(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
|
||||
try:
|
||||
api_key = get_api_key(tenant_config)
|
||||
result["has_key"] = True
|
||||
except FacturapiError as e:
|
||||
result["error"] = str(e)
|
||||
return result
|
||||
|
||||
org_id = _get_org_id(tenant_config)
|
||||
if not org_id:
|
||||
@@ -371,36 +264,20 @@ def get_org_status(tenant_config: dict) -> dict:
|
||||
result["has_org_id"] = True
|
||||
result["org_id"] = org_id
|
||||
|
||||
api_key = _get_status_key(tenant_config)
|
||||
if not api_key:
|
||||
result["error"] = "No Facturapi key available"
|
||||
return result
|
||||
|
||||
def _fetch(key=None):
|
||||
org = get_organization(org_id, key or api_key)
|
||||
legal = org.get("legal") or {}
|
||||
cert = org.get("certificate") or {}
|
||||
return {
|
||||
try:
|
||||
org = get_organization(org_id, api_key)
|
||||
legal = org.get("legal", {})
|
||||
cert = org.get("certificate", {})
|
||||
result.update(
|
||||
{
|
||||
"configured": True,
|
||||
"has_csd": bool(cert.get("has_certificate")),
|
||||
"legal_name": legal.get("name") or legal.get("legal_name"),
|
||||
"tax_id": legal.get("tax_id"),
|
||||
"pending_steps": org.get("pending_steps", []),
|
||||
}
|
||||
|
||||
try:
|
||||
result.update(_fetch())
|
||||
)
|
||||
except FacturapiError as e:
|
||||
# If the org reports "not configured for Live" (401) and we have a user key,
|
||||
# try to backfill legal data and retry.
|
||||
user_key = _find_user_key(tenant_config)
|
||||
if e.status_code == 401 and user_key:
|
||||
try:
|
||||
update_organization_legal(tenant_config, org_id)
|
||||
result.update(_fetch(user_key))
|
||||
except FacturapiError as e2:
|
||||
result["error"] = str(e2)
|
||||
else:
|
||||
result["error"] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
@@ -58,18 +58,19 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
||||
cur.close()
|
||||
return []
|
||||
|
||||
# Load sale details with items in two bulk queries (O(1) round-trips)
|
||||
# Load sale details with items
|
||||
sales = []
|
||||
for sale_id in sale_ids:
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||
FROM sales
|
||||
WHERE id = ANY(%s)
|
||||
ORDER BY created_at ASC
|
||||
""", (sale_ids,))
|
||||
FROM sales WHERE id = %s
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
continue
|
||||
|
||||
sales = {}
|
||||
for row in cur.fetchall():
|
||||
sale = {
|
||||
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
||||
'employee_id': row[3], 'sale_type': row[4],
|
||||
@@ -84,37 +85,33 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
||||
'created_at': str(row[13]),
|
||||
'items': [],
|
||||
}
|
||||
sales[row[0]] = sale
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, sale_id, inventory_id, part_number, name, quantity, unit_price,
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_price,
|
||||
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
||||
subtotal, clave_prod_serv, clave_unidad
|
||||
FROM sale_items
|
||||
WHERE sale_id = ANY(%s)
|
||||
ORDER BY sale_id, id
|
||||
""", (sale_ids,))
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
|
||||
for r in cur.fetchall():
|
||||
sale = sales.get(r[1])
|
||||
if not sale:
|
||||
continue
|
||||
sale['items'].append({
|
||||
'id': r[0], 'inventory_id': r[2], 'part_number': r[3],
|
||||
'name': r[4], 'quantity': r[5],
|
||||
'unit_price': float(r[6]) if r[6] else 0,
|
||||
'unit_cost': float(r[7]) if r[7] else 0,
|
||||
'discount_pct': float(r[8]) if r[8] else 0,
|
||||
'discount_amount': float(r[9]) if r[9] else 0,
|
||||
'tax_rate': float(r[10]) if r[10] else 0.16,
|
||||
'tax_amount': float(r[11]) if r[11] else 0,
|
||||
'subtotal': float(r[12]) if r[12] else 0,
|
||||
'clave_prod_serv': r[13] or '25174800',
|
||||
'clave_unidad': r[14] or 'H87',
|
||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
||||
'name': r[3], 'quantity': r[4],
|
||||
'unit_price': float(r[5]) if r[5] else 0,
|
||||
'unit_cost': float(r[6]) if r[6] else 0,
|
||||
'discount_pct': float(r[7]) if r[7] else 0,
|
||||
'discount_amount': float(r[8]) if r[8] else 0,
|
||||
'tax_rate': float(r[9]) if r[9] else 0.16,
|
||||
'tax_amount': float(r[10]) if r[10] else 0,
|
||||
'subtotal': float(r[11]) if r[11] else 0,
|
||||
'clave_prod_serv': r[12] or '25174800',
|
||||
'clave_unidad': r[13] or 'H87',
|
||||
})
|
||||
|
||||
sales.append(sale)
|
||||
|
||||
cur.close()
|
||||
return list(sales.values())
|
||||
return sales
|
||||
|
||||
|
||||
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
||||
|
||||
@@ -122,14 +122,7 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
|
||||
))
|
||||
op_id = cur.fetchone()[0]
|
||||
|
||||
# Queue ML stock sync if this product has an active ML listing.
|
||||
# Skip gracefully if the marketplace tables do not exist in this tenant.
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('marketplace_listings', 'meli_sync_queue')
|
||||
""")
|
||||
if cur.fetchone()[0] >= 2:
|
||||
# Queue ML stock sync if this product has an active ML listing
|
||||
cur.execute("""
|
||||
INSERT INTO meli_sync_queue (inventory_id, action, status)
|
||||
SELECT %s, 'stock_update', 'pending'
|
||||
@@ -237,54 +230,6 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
|
||||
return op_id
|
||||
|
||||
|
||||
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
|
||||
cost_at_time=None, remaining_stock=None):
|
||||
"""Reserve stock for a counter remission note (negative quantity).
|
||||
|
||||
The reserved quantity is deducted from available stock until the note is
|
||||
paid or cancelled.
|
||||
"""
|
||||
op_id = record_operation(
|
||||
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
|
||||
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
|
||||
notes='Reserva por nota de remision'
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
|
||||
try:
|
||||
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
|
||||
if remaining <= 0:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
|
||||
inv_row = cur.fetchone()
|
||||
cur.close()
|
||||
if inv_row:
|
||||
from services.push_service import notify_owner
|
||||
notify_owner(
|
||||
conn,
|
||||
'Stock en Cero',
|
||||
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
|
||||
'/pos'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return op_id
|
||||
|
||||
|
||||
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||
"""Release a previous reservation (positive quantity)."""
|
||||
result = record_operation(
|
||||
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
|
||||
reference_id=sale_id, reference_type='sale',
|
||||
notes=notes or 'Liberacion de reserva de nota de remision'
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
return result
|
||||
|
||||
|
||||
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||
"""Record a customer return (positive quantity)."""
|
||||
result = record_operation(
|
||||
@@ -429,7 +374,7 @@ def get_movement_history(conn, inventory_id, limit=50):
|
||||
history.append({
|
||||
'id': r[0], 'type': r[1], 'quantity': r[2],
|
||||
'cost': float(r[3]) if r[3] else None,
|
||||
'notes': r[4], 'date': r[5].isoformat() if r[5] else None,
|
||||
'notes': r[4], 'date': str(r[5]),
|
||||
'employee': r[6], 'branch_id': r[7]
|
||||
})
|
||||
cur.close()
|
||||
|
||||
@@ -35,83 +35,184 @@ 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 via the supplier catalog.
|
||||
"""Find vehicle compatibility for an inventory item by part_number.
|
||||
|
||||
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.
|
||||
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)
|
||||
|
||||
Returns dict {'matched': bool, 'matches': [...], 'myes': [...], 'inserted': int}
|
||||
(same shape as the legacy implementation).
|
||||
If a brand hint is provided, results are preferentially filtered to
|
||||
vehicles of that brand.
|
||||
|
||||
Returns:
|
||||
dict: {'matched': bool, 'matches': [...], 'myes': [...]}
|
||||
"""
|
||||
cur = master_conn.cursor()
|
||||
clean_pn = _norm_pn(part_number)
|
||||
catalog_ids = set()
|
||||
oem_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 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():
|
||||
catalog_ids.add(r[0])
|
||||
|
||||
# 2. Exact match on interchange part numbers (cross references)
|
||||
if not catalog_ids:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT catalog_id FROM supplier_catalog_interchange
|
||||
WHERE REPLACE(UPPER(REPLACE(part_number, '-', '')), ' ', '') = %s
|
||||
LIMIT 50
|
||||
SELECT id_part FROM parts
|
||||
WHERE REPLACE(UPPER(oem_part_number), ' ', '') = %s
|
||||
""", (clean_pn,))
|
||||
for r in cur.fetchall():
|
||||
catalog_ids.add(r[0])
|
||||
oem_ids.add(r[0])
|
||||
|
||||
# 3. Prefix fallback for longer numbers when exact found little
|
||||
if len(clean_pn) >= 5 and len(catalog_ids) < 3:
|
||||
if clean_pn and not oem_ids:
|
||||
cur.execute("""
|
||||
SELECT id FROM supplier_catalog
|
||||
WHERE REPLACE(UPPER(sku), '-', '') LIKE %s
|
||||
LIMIT 10
|
||||
""", (clean_pn[:8] + '%',))
|
||||
SELECT DISTINCT oem_part_id FROM aftermarket_parts
|
||||
WHERE REPLACE(UPPER(part_number), ' ', '') = %s
|
||||
""", (clean_pn,))
|
||||
for r in cur.fetchall():
|
||||
catalog_ids.add(r[0])
|
||||
if r[0]:
|
||||
oem_ids.add(r[0])
|
||||
|
||||
mye_ids = []
|
||||
if catalog_ids:
|
||||
if clean_pn and not oem_ids:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT model_year_engine_id FROM supplier_catalog_compat
|
||||
WHERE catalog_id = ANY(%s) AND model_year_engine_id IS NOT NULL
|
||||
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:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT oem_part_id FROM aftermarket_parts
|
||||
WHERE REPLACE(UPPER(part_number), ' ', '') LIKE %s
|
||||
AND oem_part_id NOT IN (SELECT unnest(%s::int[]))
|
||||
LIMIT 20
|
||||
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
|
||||
for r in cur.fetchall():
|
||||
if r[0]:
|
||||
oem_ids.add(r[0])
|
||||
|
||||
# Cross-reference partial
|
||||
if len(oem_ids) < 10:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT part_id FROM part_cross_references
|
||||
WHERE REPLACE(UPPER(cross_reference_number), ' ', '') LIKE %s
|
||||
AND part_id NOT IN (SELECT unnest(%s::int[]))
|
||||
LIMIT 20
|
||||
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
|
||||
for r in cur.fetchall():
|
||||
if r[0]:
|
||||
oem_ids.add(r[0])
|
||||
|
||||
# Separator-stripped fallback (e.g. KYB-343412 → KYB343412)
|
||||
if len(no_seps) >= 4 and len(oem_ids) < 10:
|
||||
cur.execute("""
|
||||
SELECT id_part FROM parts
|
||||
WHERE REPLACE(REPLACE(REPLACE(REPLACE(UPPER(oem_part_number), ' ', ''), '-', ''), '/', ''), '.', '') = %s
|
||||
AND id_part NOT IN (SELECT unnest(%s::int[]))
|
||||
LIMIT 10
|
||||
""", (no_seps, list(oem_ids) or [0]))
|
||||
for r in cur.fetchall():
|
||||
oem_ids.add(r[0])
|
||||
|
||||
# ── 3. Search by item name (when part_number yields nothing) ─────────
|
||||
if not oem_ids and name_q and len(name_q) >= 3:
|
||||
# Name-based ILIKE search on parts
|
||||
cur.execute("""
|
||||
SELECT id_part FROM parts
|
||||
WHERE UPPER(name_part) LIKE %s OR UPPER(COALESCE(name_es, '')) LIKE %s
|
||||
LIMIT 20
|
||||
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
|
||||
for r in cur.fetchall():
|
||||
oem_ids.add(r[0])
|
||||
|
||||
# Name-based ILIKE search on aftermarket_parts
|
||||
if len(oem_ids) < 20:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT oem_part_id FROM aftermarket_parts
|
||||
WHERE UPPER(COALESCE(name_aftermarket_parts, '')) LIKE %s
|
||||
OR UPPER(COALESCE(name_es, '')) LIKE %s
|
||||
LIMIT 20
|
||||
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
|
||||
for r in cur.fetchall():
|
||||
if r[0]:
|
||||
oem_ids.add(r[0])
|
||||
|
||||
if not oem_ids:
|
||||
cur.close()
|
||||
return {'matched': False, 'matches': [], 'myes': []}
|
||||
|
||||
# ── Resolve MYEs ─────────────────────────────────────────────────────
|
||||
oem_ids = list(oem_ids)
|
||||
|
||||
if brand_hint and brand_hint != 'GENERAL':
|
||||
# Brand-aware: only MYEs for vehicles of the hinted brand
|
||||
cur.execute("""
|
||||
SELECT DISTINCT vp.model_year_engine_id
|
||||
FROM vehicle_parts vp
|
||||
JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id
|
||||
JOIN models m ON m.id_model = mye.model_id
|
||||
JOIN brands b ON b.id_brand = m.brand_id
|
||||
WHERE vp.part_id = ANY(%s)
|
||||
AND b.name_brand = %s
|
||||
LIMIT 500
|
||||
""", (list(catalog_ids),))
|
||||
""", (oem_ids, brand_hint))
|
||||
mye_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
# Fallback: if brand filter yields nothing, the brand hint may be an
|
||||
# aftermarket supplier (e.g. Motorcraft, NGK, Bosch) rather than an
|
||||
# OEM vehicle brand. Search without brand filter.
|
||||
if not mye_ids:
|
||||
cur.execute("""
|
||||
SELECT DISTINCT model_year_engine_id
|
||||
FROM vehicle_parts
|
||||
WHERE part_id = ANY(%s)
|
||||
LIMIT 500
|
||||
""", (oem_ids,))
|
||||
mye_ids = [r[0] for r in cur.fetchall()]
|
||||
else:
|
||||
# No brand hint — return all MYEs for these parts
|
||||
cur.execute("""
|
||||
SELECT DISTINCT model_year_engine_id
|
||||
FROM vehicle_parts
|
||||
WHERE part_id = ANY(%s)
|
||||
LIMIT 500
|
||||
""", (oem_ids,))
|
||||
mye_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
cur.close()
|
||||
|
||||
# ── Insert into tenant table ─────────────────────────────────────────
|
||||
inserted = 0
|
||||
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)
|
||||
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:
|
||||
@@ -121,7 +222,7 @@ def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, par
|
||||
|
||||
return {
|
||||
'matched': len(mye_ids) > 0,
|
||||
'matches': sorted(catalog_ids),
|
||||
'matches': oem_ids,
|
||||
'myes': mye_ids,
|
||||
'inserted': inserted,
|
||||
}
|
||||
|
||||
@@ -1,474 +0,0 @@
|
||||
# /home/Autopartes/pos/services/invoice_ocr.py
|
||||
"""Supplier-invoice OCR / parsing.
|
||||
|
||||
Accepts an image or PDF of a supplier invoice and returns structured lines:
|
||||
|
||||
[{part_number, name, quantity, unit_cost}]
|
||||
|
||||
Pipeline (first stage that yields lines wins):
|
||||
1. PDF with a text layer -> ``pdftotext -layout``
|
||||
2. Scanned PDF -> ``pdftoppm`` (first page) + Tesseract
|
||||
3. Image -> Tesseract
|
||||
4. Vision fallback -> ``ai_chat`` (QWEN / OpenRouter vision model)
|
||||
|
||||
Stock is NEVER touched here: the caller returns the parsed lines so the
|
||||
front-end can confirm before creating a purchase order.
|
||||
|
||||
Public API:
|
||||
parse_invoice(data, filename=None, content_type=None, use_vision=True) -> dict
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
_MONEY_RE = re.compile(r"^\$?\s?\d{1,3}(?:[,\d]{0,15})(?:\.\d{1,2})?$")
|
||||
_INT_RE = re.compile(r"^\d{1,4}$")
|
||||
_PN_RE = re.compile(r"^[A-Z0-9][A-Z0-9\-\./_]{2,}$")
|
||||
|
||||
# Lines that are clearly headers/totals, not invoice items.
|
||||
_SKIP_TOKENS = {
|
||||
"factura", "remision", "total", "subtotal", "iva", "ieps", "rfc", "fecha",
|
||||
"pedido", "cliente", "proveedor", "telefono", "direccion", "calle",
|
||||
"colonia", "cantidad", "cant", "descripcion", "precio", "importe",
|
||||
"unidad", "folio", "orden", "pagina", "gracias", "nota", "clave",
|
||||
"codigo", "producto", "articulo", "descuento", "moneda", "condiciones",
|
||||
"subtotal:", "total:", "iva:", "rfc:", "fecha:", "importe:", "precio:",
|
||||
}
|
||||
|
||||
# Any of these as a whole word marks a header/footer line (RFC, date, totals).
|
||||
_HEADER_WORDS = {
|
||||
"rfc", "fecha", "folio", "cliente", "proveedor", "factura", "remision",
|
||||
"remisión", "pedido", "telefono", "tel", "direccion", "dirección", "calle",
|
||||
"colonia", "condiciones", "moneda", "subtotal", "total", "iva", "ieps",
|
||||
"clave", "descripcion", "descripción", "cantidad", "unidad", "descuento",
|
||||
"importe", "precio", "page",
|
||||
}
|
||||
|
||||
_VISION_PROMPT = (
|
||||
"Eres un experto en facturas de refaccionarias mexicanas. Extrae TODAS las "
|
||||
"lineas de producto de la imagen. Devuelve SOLO JSON valido, sin texto extra, "
|
||||
"con esta forma exacta: "
|
||||
'{"lines":[{"part_number":"...","name":"...","quantity":1,"unit_cost":0.0}]}. '
|
||||
"part_number = clave/SKU/numero de parte; name = descripcion; quantity = "
|
||||
"cantidad (entero); unit_cost = precio unitario (numero, sin simbolos). "
|
||||
"Si no hay lineas claras devuelve {\"lines\": []}."
|
||||
)
|
||||
|
||||
|
||||
# ── Binary helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _is_pdf(data, filename):
|
||||
if data[:5] == b"%PDF-":
|
||||
return True
|
||||
return bool(filename and filename.lower().endswith(".pdf"))
|
||||
|
||||
|
||||
def _pdftotext(data):
|
||||
"""Extract the text layer of a PDF. Returns '' on failure."""
|
||||
exe = shutil.which("pdftotext")
|
||||
if not exe:
|
||||
return ""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[exe, "-layout", "-", "-"],
|
||||
input=data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return proc.stdout.decode("utf-8", "replace")
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _pdf_first_page_png(data, dpi=200):
|
||||
"""Render the first PDF page to PNG bytes (for OCR). None on failure."""
|
||||
exe = shutil.which("pdftoppm")
|
||||
if not exe:
|
||||
return None
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
pdf_path = os.path.join(td, "in.pdf")
|
||||
with open(pdf_path, "wb") as f:
|
||||
f.write(data)
|
||||
prefix = os.path.join(td, "page")
|
||||
subprocess.run(
|
||||
[exe, "-png", "-r", str(dpi), "-f", "1", "-l", "1", pdf_path, prefix],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120,
|
||||
)
|
||||
for fn in sorted(os.listdir(td)):
|
||||
if fn.startswith("page") and fn.endswith(".png"):
|
||||
with open(os.path.join(td, fn), "rb") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _ocr_langs():
|
||||
try:
|
||||
import pytesseract
|
||||
langs = pytesseract.get_languages(config="")
|
||||
for wanted in ("spa", "eng"):
|
||||
if wanted in langs:
|
||||
return wanted
|
||||
except Exception:
|
||||
pass
|
||||
return "eng"
|
||||
|
||||
|
||||
def _ocr_image(img_bytes):
|
||||
"""Run Tesseract over image bytes. Returns '' on failure."""
|
||||
try:
|
||||
from PIL import Image
|
||||
import pytesseract
|
||||
except Exception:
|
||||
return ""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(img_bytes))
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
return pytesseract.image_to_string(img, lang=_ocr_langs())
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _ocr_rows(img_bytes, y_tolerance=12):
|
||||
"""OCR an image and rebuild table rows from word positions.
|
||||
|
||||
Tesseract's default layout analysis splits a monospaced invoice table into
|
||||
one block per column, so ``image_to_string`` returns each column stacked
|
||||
instead of each row. We regroup words by their vertical center instead.
|
||||
|
||||
Returns a list of row strings (falls back to plain lines on failure).
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
import pytesseract
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
img = Image.open(io.BytesIO(img_bytes))
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
data = pytesseract.image_to_data(
|
||||
img, lang=_ocr_langs(), output_type=pytesseract.Output.DICT
|
||||
)
|
||||
except Exception:
|
||||
text = _ocr_image(img_bytes)
|
||||
return text.splitlines() if text else []
|
||||
|
||||
groups = {} # representative y -> list of (left, text)
|
||||
for i, txt in enumerate(data.get("text", [])):
|
||||
txt = (txt or "").strip()
|
||||
if not txt:
|
||||
continue
|
||||
try:
|
||||
top = int(data["top"][i])
|
||||
h = int(data["height"][i] or 0)
|
||||
left = int(data["left"][i])
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
cy = top + h // 2
|
||||
key = None
|
||||
for k in groups:
|
||||
if abs(k - cy) <= max(y_tolerance, h // 2):
|
||||
key = k
|
||||
break
|
||||
groups.setdefault(cy if key is None else key, []).append((left, txt))
|
||||
|
||||
rows = []
|
||||
for cy in sorted(groups):
|
||||
rows.append(" ".join(t for _, t in sorted(groups[cy])))
|
||||
return rows
|
||||
|
||||
|
||||
def _ocr_rows_text(img_bytes):
|
||||
"""Row-reconstructed OCR text (falls back to raw Tesseract output)."""
|
||||
rows = _ocr_rows(img_bytes)
|
||||
if rows:
|
||||
return "\n".join(rows)
|
||||
return _ocr_image(img_bytes)
|
||||
|
||||
|
||||
# ── Text line parser ───────────────────────────────────────────────────────
|
||||
|
||||
def _is_money(tok):
|
||||
if tok.startswith("$"):
|
||||
return True
|
||||
if not _MONEY_RE.match(tok):
|
||||
return False
|
||||
return ("." in tok) or ("," in tok)
|
||||
|
||||
|
||||
def _to_money(tok):
|
||||
cleaned = re.sub(r"[^\d.]", "", tok.replace(",", ""))
|
||||
try:
|
||||
return float(cleaned)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _is_part_number(tok):
|
||||
if _is_money(tok):
|
||||
return False
|
||||
if not _PN_RE.match(tok):
|
||||
return False
|
||||
has_alpha = any(c.isalpha() for c in tok)
|
||||
digits = sum(c.isdigit() for c in tok)
|
||||
if has_alpha:
|
||||
return digits >= 1 and len(tok) >= 4
|
||||
# pure numeric part numbers are accepted only if long enough to not be a qty
|
||||
return len(tok) >= 4
|
||||
|
||||
|
||||
def _skip_line(line):
|
||||
low = line.lower()
|
||||
if low.strip(".:-") in _SKIP_TOKENS:
|
||||
return True
|
||||
words = set(re.findall(r"[a-záéíóúñ]+", low))
|
||||
if words & _HEADER_WORDS:
|
||||
return True
|
||||
if words and all(t in _SKIP_TOKENS or t in ("de", "del", "la", "el") for t in words):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parse_line(line):
|
||||
tokens = line.split()
|
||||
if len(tokens) < 3:
|
||||
return None
|
||||
|
||||
money_idx = [i for i, t in enumerate(tokens) if _is_money(t)]
|
||||
int_idx = [i for i, t in enumerate(tokens) if _INT_RE.match(t)]
|
||||
pn_idx = [i for i, t in enumerate(tokens) if _is_part_number(t)]
|
||||
if not pn_idx or (not money_idx and not int_idx):
|
||||
return None
|
||||
|
||||
qty, qty_i = None, None
|
||||
for i in int_idx:
|
||||
if i in money_idx:
|
||||
continue
|
||||
v = int(tokens[i])
|
||||
if 1 <= v <= 9999:
|
||||
qty, qty_i = v, i
|
||||
break
|
||||
|
||||
unit, unit_i = None, None
|
||||
if money_idx:
|
||||
unit_i = money_idx[0]
|
||||
unit = _to_money(tokens[unit_i])
|
||||
|
||||
pn_i = pn_idx[0]
|
||||
used = {i for i in (pn_i, qty_i) if i is not None} | set(money_idx)
|
||||
name = " ".join(t for i, t in enumerate(tokens) if i not in used).strip(" -|")
|
||||
if not name:
|
||||
name = None
|
||||
if qty is None and unit is None:
|
||||
return None
|
||||
return {
|
||||
"part_number": tokens[pn_i],
|
||||
"name": name,
|
||||
"quantity": qty,
|
||||
"unit_cost": unit,
|
||||
}
|
||||
|
||||
|
||||
def parse_lines(text):
|
||||
"""Parse raw invoice text into structured lines."""
|
||||
out = []
|
||||
seen = set()
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.strip()
|
||||
if len(line) < 6 or _skip_line(line):
|
||||
continue
|
||||
row = _parse_line(line)
|
||||
if not row:
|
||||
continue
|
||||
key = (row["part_number"].upper(), row["quantity"], row["unit_cost"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
# ── Vision fallback ────────────────────────────────────────────────────────
|
||||
|
||||
def _extract_json(text):
|
||||
if not text:
|
||||
return None
|
||||
s = text.strip()
|
||||
if s.startswith("```"):
|
||||
s = re.sub(r"^```[a-zA-Z]*\n?", "", s)
|
||||
s = re.sub(r"\n?```$", "", s)
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
pass
|
||||
m = re.search(r"\{.*\}", s, re.DOTALL)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group(0))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_lines(obj):
|
||||
if not obj:
|
||||
return []
|
||||
raw = obj.get("lines") if isinstance(obj, dict) else None
|
||||
if raw is None and isinstance(obj, list):
|
||||
raw = obj
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
lines = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
pn = item.get("part_number") or item.get("clave") or item.get("sku")
|
||||
name = item.get("name") or item.get("descripcion") or item.get("description")
|
||||
qty = item.get("quantity", item.get("cantidad"))
|
||||
cost = item.get("unit_cost", item.get("precio", item.get("unit_price")))
|
||||
try:
|
||||
qty = int(qty) if qty is not None else None
|
||||
except (ValueError, TypeError):
|
||||
qty = None
|
||||
try:
|
||||
cost = float(cost) if cost is not None else None
|
||||
except (ValueError, TypeError):
|
||||
cost = None
|
||||
if pn or name:
|
||||
lines.append({
|
||||
"part_number": str(pn).strip() if pn else None,
|
||||
"name": str(name).strip() if name else None,
|
||||
"quantity": qty, "unit_cost": cost,
|
||||
})
|
||||
return lines
|
||||
|
||||
|
||||
def _vision_extract(image_bytes, hint_text=None):
|
||||
"""Extract lines with the vision model. Returns (lines, error)."""
|
||||
try:
|
||||
from services import ai_chat
|
||||
except Exception as e:
|
||||
return [], f"ai_chat import: {e}"
|
||||
|
||||
b64 = base64.b64encode(image_bytes).decode("ascii")
|
||||
prompt = _VISION_PROMPT
|
||||
if hint_text:
|
||||
prompt += "\n\nTexto OCR parcial (puede estar incompleto):\n" + hint_text[:1500]
|
||||
|
||||
# 1) reuse chat_with_image (per task requirement)
|
||||
try:
|
||||
res = ai_chat.chat_with_image(prompt, b64)
|
||||
lines = _coerce_lines(res)
|
||||
if lines:
|
||||
return lines, None
|
||||
msg = res.get("message") if isinstance(res, dict) else None
|
||||
lines = _coerce_lines(_extract_json(msg) if msg else None)
|
||||
if lines:
|
||||
return lines, None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) direct vision call with an invoice-specific system prompt
|
||||
try:
|
||||
data_url = "data:image/jpeg;base64," + b64
|
||||
messages = [
|
||||
{"role": "system", "content": "Responde SOLO JSON valido."},
|
||||
{"role": "user", "content": [
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
{"type": "text", "text": prompt},
|
||||
]},
|
||||
]
|
||||
backends = []
|
||||
if ai_chat.QWEN_ENABLED:
|
||||
backends.append((ai_chat.QWEN_CHAT_URL, ai_chat.QWEN_API_KEY, ai_chat.QWEN_MODEL))
|
||||
if ai_chat.OPENROUTER_API_KEY:
|
||||
backends.append((ai_chat.OPENROUTER_URL, ai_chat.OPENROUTER_API_KEY, ai_chat.VISION_MODEL))
|
||||
for url, key, model in backends:
|
||||
result = ai_chat._post_chat_completion(
|
||||
url, key, model, messages, max_tokens=1200, temperature=0.1, timeout=60
|
||||
)
|
||||
if not result:
|
||||
continue
|
||||
lines = _coerce_lines(_extract_json(result["content"]))
|
||||
if lines:
|
||||
return lines, None
|
||||
return [], "vision sin lineas"
|
||||
except Exception as e:
|
||||
return [], f"vision: {e}"
|
||||
|
||||
|
||||
# ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_invoice(data, filename=None, content_type=None, use_vision=True):
|
||||
"""Parse an invoice image/PDF into structured lines.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"lines": [{part_number, name, quantity, unit_cost}],
|
||||
"count": int,
|
||||
"method": str, # pdftotext | pdftoppm+tesseract | tesseract | ...+vision
|
||||
"warnings": [str],
|
||||
"text_preview": str,
|
||||
}
|
||||
"""
|
||||
warnings = []
|
||||
text = ""
|
||||
method = "unknown"
|
||||
vision_image = None # bytes to hand to the vision model when OCR fails
|
||||
|
||||
if not data:
|
||||
return {"lines": [], "count": 0, "method": "empty",
|
||||
"warnings": ["archivo vacío"], "text_preview": ""}
|
||||
|
||||
if _is_pdf(data, filename):
|
||||
text = _pdftotext(data)
|
||||
if len(text.strip()) >= 30:
|
||||
method = "pdftotext"
|
||||
else:
|
||||
png = _pdf_first_page_png(data)
|
||||
if png:
|
||||
vision_image = png
|
||||
text = _ocr_rows_text(png)
|
||||
method = "pdftoppm+tesseract" if text.strip() else "pdftoppm"
|
||||
if not text.strip():
|
||||
warnings.append("PDF sin capa de texto y OCR vacío")
|
||||
else:
|
||||
method = "pdf"
|
||||
warnings.append("No se pudo renderizar el PDF")
|
||||
if not vision_image:
|
||||
# for vision fallback on a text PDF we still need a raster copy
|
||||
vision_image = _pdf_first_page_png(data)
|
||||
else:
|
||||
vision_image = data
|
||||
text = _ocr_rows_text(data)
|
||||
method = "tesseract"
|
||||
if not text.strip():
|
||||
warnings.append("OCR sin texto reconocible")
|
||||
|
||||
lines = parse_lines(text)
|
||||
|
||||
if not lines and use_vision and vision_image:
|
||||
vlines, verr = _vision_extract(vision_image, hint_text=text)
|
||||
if vlines:
|
||||
lines = vlines
|
||||
method += "+vision"
|
||||
elif verr:
|
||||
warnings.append(f"fallback visión sin resultados: {verr}")
|
||||
|
||||
return {
|
||||
"lines": lines,
|
||||
"count": len(lines),
|
||||
"method": method,
|
||||
"warnings": warnings,
|
||||
"text_preview": text[:800],
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user