Compare commits
1 Commits
main
...
7f753376e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f753376e3 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -91,7 +91,3 @@ backups/
|
|||||||
# Local tools (AWS CLI)
|
# Local tools (AWS CLI)
|
||||||
tools/
|
tools/
|
||||||
|
|
||||||
# Rached migration session artifacts (tokens / captures / samples)
|
|
||||||
rached_*.json
|
|
||||||
rached_*.txt
|
|
||||||
|
|
||||||
|
|||||||
@@ -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="oem" onclick="setCatalogMode('oem')">OEM</button>
|
||||||
<button data-mode="local" onclick="setCatalogMode('local')">Local</button>
|
<button data-mode="local" onclick="setCatalogMode('local')">Local</button>
|
||||||
</div>
|
</div>
|
||||||
<a href="/usados" class="header-back" id="usedPartsLink" title="Partes usadas y reconstruidas">Usados</a>
|
|
||||||
<a href="/" class="header-back">← Inicio</a>
|
<a href="/" class="header-back">← Inicio</a>
|
||||||
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle">
|
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle">
|
||||||
<span id="themeIcon">☾</span>
|
<span id="themeIcon">☾</span>
|
||||||
@@ -87,7 +86,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script src="/catalog-public.js?v=2"></script>
|
<script src="/catalog-public.js"></script>
|
||||||
|
|
||||||
<!-- AI Chat Widget -->
|
<!-- AI Chat Widget -->
|
||||||
<link rel="stylesheet" href="/chat-public.css">
|
<link rel="stylesheet" href="/chat-public.css">
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
nxPartType: null, // {slug, name} ← Nexpart part type
|
nxPartType: null, // {slug, name} ← Nexpart part type
|
||||||
|
|
||||||
region: 'north-america',
|
region: 'north-america',
|
||||||
mode: (localStorage.getItem('catalog_mode') === 'oem' ? 'oem' : 'local'),
|
mode: (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem'),
|
||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
};
|
};
|
||||||
@@ -64,7 +64,6 @@
|
|||||||
state.brand = state.model = state.year = state.engine = null;
|
state.brand = state.model = state.year = state.engine = null;
|
||||||
state.level = 'brands';
|
state.level = 'brands';
|
||||||
loadBrands();
|
loadBrands();
|
||||||
waConfig(function () {}); // preload WhatsApp contact config
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Region selector (global) ──
|
// ── Region selector (global) ──
|
||||||
@@ -110,31 +109,6 @@
|
|||||||
loadBrands();
|
loadBrands();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Link a la vitrina de partes usadas del tenant (si existe) ──
|
|
||||||
(function () {
|
|
||||||
var link = document.getElementById('usedPartsLink');
|
|
||||||
if (!link) return;
|
|
||||||
var parts = window.location.hostname.split('.');
|
|
||||||
var sub = (parts.length >= 3) ? parts[0]
|
|
||||||
: (parts.length === 2 && parts[1] === 'localhost') ? parts[0] : '';
|
|
||||||
var reserved = ['www', 'api', 'admin', 'mail', 'staging', 'dev', 'nexus', 'pos', 'app', 'dashboard'];
|
|
||||||
if (reserved.indexOf(sub) !== -1) sub = '';
|
|
||||||
var urlParams2 = new URLSearchParams(window.location.search);
|
|
||||||
var tenant = urlParams2.get('tenant') || sub;
|
|
||||||
if (!tenant) return;
|
|
||||||
fetch('/api/public/used-parts?per_page=1&tenant=' + encodeURIComponent(tenant))
|
|
||||||
.then(function (r) { return r.ok ? r.json() : null; })
|
|
||||||
.then(function (d) {
|
|
||||||
if (d && d.total > 0) {
|
|
||||||
link.href = '/usado/' + encodeURIComponent(d.tenant.subdomain || tenant);
|
|
||||||
link.textContent = 'Usados (' + d.total + ')';
|
|
||||||
} else {
|
|
||||||
link.style.display = 'none';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function () { link.style.display = 'none'; });
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Enter on search
|
// Enter on search
|
||||||
// ── Smart search detector ──
|
// ── Smart search detector ──
|
||||||
function detectQueryType(raw) {
|
function detectQueryType(raw) {
|
||||||
@@ -755,7 +729,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>';
|
html += '<button class="part-detail-btn" onclick="openDetail(' + p.id_part + ')">Ver detalle y alternativas</button>';
|
||||||
html += waQuoteButtonHtml(p);
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
if (p.image_url) {
|
if (p.image_url) {
|
||||||
html += '<img class="part-img" src="' + esc(p.image_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
|
html += '<img class="part-img" src="' + esc(p.image_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||||
@@ -809,26 +782,6 @@
|
|||||||
content.innerHTML = html;
|
content.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── WhatsApp quote button ──
|
|
||||||
var _waConfig = null;
|
|
||||||
function waConfig(cb) {
|
|
||||||
if (_waConfig) return cb(_waConfig);
|
|
||||||
fetch('/api/public/contact-config').then(function (r) { return r.json(); })
|
|
||||||
.then(function (d) { _waConfig = d || { enabled: false }; cb(_waConfig); })
|
|
||||||
.catch(function () { _waConfig = { enabled: false }; cb(_waConfig); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function waQuoteButtonHtml(p) {
|
|
||||||
var cfg = _waConfig;
|
|
||||||
if (!cfg || !cfg.enabled || !cfg.wa_number) return '';
|
|
||||||
var text = 'Hola, me interesa cotizar esta pieza:%0A%0A*OEM:* ' + encodeURIComponent(p.oem_part_number || '') +
|
|
||||||
'%0A*Descripción:* ' + encodeURIComponent(p.name || '');
|
|
||||||
if (p.vehicle_info) text += '%0A*Vehículo:* ' + encodeURIComponent(p.vehicle_info);
|
|
||||||
return '<a class="btn-wa-quote" target="_blank" rel="noopener" href="https://wa.me/' + cfg.wa_number + '?text=' + text + '"' +
|
|
||||||
' style="display:inline-flex;align-items:center;gap:8px;margin-top:var(--space-4);padding:12px 20px;border-radius:var(--radius-md);background:#25D366;color:#fff;text-decoration:none;font-weight:600;">' +
|
|
||||||
'Cotizar por WhatsApp</a>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Part detail ──
|
// ── Part detail ──
|
||||||
function renderDetail(d, body) {
|
function renderDetail(d, body) {
|
||||||
if (!d || !d.part) {
|
if (!d || !d.part) {
|
||||||
@@ -878,7 +831,7 @@
|
|||||||
html += '</tbody></table></div>';
|
html += '</tbody></table></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
waConfig(function () { html += waQuoteButtonHtml(p); body.innerHTML = html; });
|
body.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
|
|||||||
@@ -31,14 +31,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var navLinks = [
|
var navLinks = [
|
||||||
{ label: 'Demo', href: '/demo.html' },
|
{ label: 'Demo', href: '/demo' },
|
||||||
{ label: 'Tienda', href: '/tienda.html' },
|
{ label: 'Tienda', href: '/tienda' },
|
||||||
{ label: 'Cat\u00e1logo', href: '/index.html' },
|
{ label: 'Cat\u00e1logo', href: '/index.html' },
|
||||||
{ label: 'Captura', href: '/captura.html' },
|
{ label: 'Captura', href: '/captura' },
|
||||||
{ label: 'POS', href: '/pos/login' },
|
{ label: 'POS', href: '/pos' },
|
||||||
{ label: 'Cuentas', href: '/cuentas.html' },
|
{ label: 'Cuentas', href: '/cuentas' },
|
||||||
{ label: 'Bodega', href: '/bodega.html' },
|
{ label: 'Bodega', href: '/bodega' },
|
||||||
{ label: 'Admin', href: '/admin.html' }
|
{ label: 'Admin', href: '/admin' }
|
||||||
];
|
];
|
||||||
|
|
||||||
var linksHTML = navLinks.map(function (link) {
|
var linksHTML = navLinks.map(function (link) {
|
||||||
|
|||||||
2084
dashboard/server.py
2084
dashboard/server.py
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
|||||||
global:
|
global:
|
||||||
resolve_timeout: 5m
|
smtp_smarthost: 'localhost:587'
|
||||||
|
smtp_from: 'alerts@nexus.local'
|
||||||
|
smtp_require_tls: false
|
||||||
|
|
||||||
route:
|
route:
|
||||||
group_by: ['alertname', 'severity']
|
group_by: ['alertname', 'severity']
|
||||||
@@ -10,14 +12,19 @@ route:
|
|||||||
|
|
||||||
receivers:
|
receivers:
|
||||||
- name: 'default'
|
- name: 'default'
|
||||||
|
email_configs:
|
||||||
|
- to: 'admin@nexus.local'
|
||||||
|
subject: 'Nexus Alert: {{ .GroupLabels.alertname }}'
|
||||||
|
body: |
|
||||||
|
{{ range .Alerts }}
|
||||||
|
Alert: {{ .Annotations.summary }}
|
||||||
|
Description: {{ .Annotations.description }}
|
||||||
|
Severity: {{ .Labels.severity }}
|
||||||
|
Time: {{ .StartsAt }}
|
||||||
|
{{ end }}
|
||||||
webhook_configs:
|
webhook_configs:
|
||||||
# El contenedor llega al host por el gateway del bridge docker
|
- url: 'http://localhost:5001/pos/api/notifications/webhook'
|
||||||
- url: 'http://172.17.0.1:5001/pos/api/notifications/webhook'
|
|
||||||
send_resolved: true
|
send_resolved: true
|
||||||
http_config:
|
|
||||||
authorization:
|
|
||||||
type: Bearer
|
|
||||||
credentials: 'c58db62766712e618a881dbe8de580960812e57a069ef92c9dd00e7e69158cb2'
|
|
||||||
|
|
||||||
inhibit_rules:
|
inhibit_rules:
|
||||||
- source_match:
|
- source_match:
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ version: "3.8"
|
|||||||
services:
|
services:
|
||||||
prometheus:
|
prometheus:
|
||||||
image: prom/prometheus:v2.51.0
|
image: prom/prometheus:v2.51.0
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
container_name: nexus-prometheus
|
container_name: nexus-prometheus
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"uid": "nexus-app",
|
"uid": "nexus-app",
|
||||||
"title": "Nexus — Application",
|
"title": "Nexus — Application",
|
||||||
"tags": ["app", "flask", "nexus"],
|
"tags": ["gunicorn", "flask"],
|
||||||
"timezone": "browser",
|
"timezone": "browser",
|
||||||
"schemaVersion": 36,
|
"schemaVersion": 36,
|
||||||
"refresh": "30s",
|
"refresh": "30s",
|
||||||
@@ -26,15 +26,15 @@
|
|||||||
"panels": [
|
"panels": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"title": "Request Rate (by app / status)",
|
"title": "Request Rate (nginx)",
|
||||||
"type": "timeseries",
|
"type": "timeseries",
|
||||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"expr": "sum by (app, status) (rate(nexus_http_requests_total[5m]))",
|
"expr": "rate(nginx_http_requests_total[5m])",
|
||||||
"legendFormat": "{{app}} {{status}}",
|
"legendFormat": "Requests/sec",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -49,20 +49,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"title": "Response Time p95 / p99",
|
"title": "Response Time (nginx)",
|
||||||
"type": "timeseries",
|
"type": "timeseries",
|
||||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"expr": "histogram_quantile(0.95, sum by (le) (rate(nexus_http_request_duration_seconds_bucket[5m])))",
|
"expr": "histogram_quantile(0.95, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||||
"legendFormat": "p95",
|
"legendFormat": "p95",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"expr": "histogram_quantile(0.99, sum by (le) (rate(nexus_http_request_duration_seconds_bucket[5m])))",
|
"expr": "histogram_quantile(0.99, sum(rate(nginx_http_request_duration_seconds_bucket[5m])) by (le))",
|
||||||
"legendFormat": "p99",
|
"legendFormat": "p99",
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
@@ -78,15 +78,15 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
"title": "In-flight Requests",
|
"title": "Active Workers",
|
||||||
"type": "timeseries",
|
"type": "timeseries",
|
||||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"expr": "sum by (app) (nexus_http_requests_in_flight)",
|
"expr": "count by (instance) (node_processes_state{state=\"S\", cmdline=~\".*gunicorn.*\"})",
|
||||||
"legendFormat": "{{app}}",
|
"legendFormat": "Workers {{instance}}",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -108,8 +108,8 @@
|
|||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"expr": "sum by (app) (rate(nexus_http_requests_total{status=\"5xx\"}[5m]))",
|
"expr": "rate(nginx_http_requests_total{status=~\"5..\"}[5m])",
|
||||||
"legendFormat": "{{app}} 5xx/s",
|
"legendFormat": "5xx/sec",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -124,22 +124,22 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
"title": "Master DB Connections",
|
"title": "Memory per Worker",
|
||||||
"type": "timeseries",
|
"type": "timeseries",
|
||||||
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
|
"gridPos": {"h": 8, "w": 24, "x": 0, "y": 16},
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
"datasource": {"type": "prometheus", "uid": "${datasource}"},
|
||||||
"expr": "nexus_db_connections",
|
"expr": "process_resident_memory_bytes{cmdline=~\".*gunicorn.*\"} / 1024 / 1024",
|
||||||
"legendFormat": "{{app}} ({{database}})",
|
"legendFormat": "{{cmdline}}",
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fieldConfig": {
|
"fieldConfig": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
"custom": {"drawStyle": "line", "lineWidth": 2, "fillOpacity": 10},
|
||||||
"unit": "short",
|
"unit": "mbytes",
|
||||||
"min": 0
|
"min": 0
|
||||||
},
|
},
|
||||||
"overrides": []
|
"overrides": []
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ scrape_configs:
|
|||||||
|
|
||||||
- job_name: 'node'
|
- job_name: 'node'
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['host.docker.internal:9100']
|
- targets: ['node-exporter:9100']
|
||||||
|
|
||||||
- job_name: 'postgres'
|
- job_name: 'postgres'
|
||||||
static_configs:
|
static_configs:
|
||||||
@@ -29,16 +29,12 @@ scrape_configs:
|
|||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['redis-exporter:9121']
|
- targets: ['redis-exporter:9121']
|
||||||
|
|
||||||
# Métricas de aplicación expuestas por POS (gunicorn :5001) y dashboard (:5000)
|
- job_name: 'nexus-pos'
|
||||||
# en el host; se alcanzan desde el contenedor por el gateway del bridge docker.
|
|
||||||
- job_name: 'nexus_pos'
|
|
||||||
metrics_path: /metrics
|
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['172.17.0.1:5001']
|
- targets: ['host.docker.internal:5001']
|
||||||
|
|
||||||
- job_name: 'nexus_dashboard'
|
|
||||||
metrics_path: /metrics
|
metrics_path: /metrics
|
||||||
|
|
||||||
|
- job_name: 'nexus-quart'
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['172.17.0.1:5000']
|
- targets: ['host.docker.internal:5002']
|
||||||
|
metrics_path: /metrics
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
## Mejoras Pendientes (Roadmap Actualizado)
|
||||||
|
|
||||||
### 🔴 Crítico — Deuda Técnica
|
### 🔴 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)
|
- **Monitorear** salud de todos los servicios (POS, DB, Redis, Quart, Systemd)
|
||||||
- **Gestionar tenants**: activar/desactivar, resetear datos, eliminar
|
- **Gestionar tenants**: activar/desactivar, resetear datos, eliminar
|
||||||
- **Ejecutar migraciones** de schema en todos los tenants desde una UI
|
- **Ejecutar migraciones** de schema en todos los tenants desde una UI
|
||||||
- **Cartera**: métricas de negocio por tenant (rollup diario), score de salud/riesgo de churn y anomalías entre tenants
|
|
||||||
- **Dashboard** con estadísticas globales y alertas de demos por expirar
|
- **Dashboard** con estadísticas globales y alertas de demos por expirar
|
||||||
|
|
||||||
## Estructura
|
## Estructura
|
||||||
@@ -22,15 +21,12 @@ manager/
|
|||||||
├── services/ # Lógica de negocio
|
├── services/ # Lógica de negocio
|
||||||
│ ├── health_service.py # Health checks de infraestructura
|
│ ├── health_service.py # Health checks de infraestructura
|
||||||
│ ├── tenant_service.py # CRUD tenants (usa tenant_manager del POS)
|
│ ├── tenant_service.py # CRUD tenants (usa tenant_manager del POS)
|
||||||
│ ├── portfolio_service.py# Rollup de métricas, lectura de cartera y anomalías
|
|
||||||
│ ├── business_metrics.py # Z-score robusto multi-tenant + score de salud
|
|
||||||
│ └── migration_service.py# Orquestación de migraciones
|
│ └── migration_service.py# Orquestación de migraciones
|
||||||
├── blueprints/ # API REST
|
├── blueprints/ # API REST
|
||||||
│ ├── auth_bp.py # Login/logout JWT
|
│ ├── auth_bp.py # Login/logout JWT
|
||||||
│ ├── tenants_bp.py # Gestión de tenants
|
│ ├── tenants_bp.py # Gestión de tenants
|
||||||
│ ├── demos_bp.py # Creación de demos
|
│ ├── demos_bp.py # Creación de demos
|
||||||
│ ├── health_bp.py # Health checks
|
│ ├── health_bp.py # Health checks
|
||||||
│ ├── portfolio_bp.py # /api/tenants/metrics y /api/portfolio/anomalies
|
|
||||||
│ └── admin_bp.py # Dashboard stats y migraciones
|
│ └── admin_bp.py # Dashboard stats y migraciones
|
||||||
├── static/ # Frontend SPA
|
├── static/ # Frontend SPA
|
||||||
│ ├── css/manager.css
|
│ ├── css/manager.css
|
||||||
@@ -197,29 +193,6 @@ systemctl start nexus-manager
|
|||||||
- Ve a **Migraciones** para ver la versión de schema de cada tenant
|
- Ve a **Migraciones** para ver la versión de schema de cada tenant
|
||||||
- **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants
|
- **Ejecutar todas pendientes** aplica migraciones en TODOS los tenants
|
||||||
|
|
||||||
### Cartera (métricas, salud y anomalías)
|
|
||||||
- La sección **Cartera** muestra el score de salud/riesgo de churn por tenant y
|
|
||||||
las anomalías detectadas entre tenants (z-score robusto con mediana + MAD).
|
|
||||||
- Los datos vienen de la tabla maestra `tenant_metrics_daily`, que se llena con
|
|
||||||
el rollup diario:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/rollup_tenant_metrics.py # hoy, tenants activos
|
|
||||||
python3 scripts/rollup_tenant_metrics.py --days 30 # backfill últimos 30 días
|
|
||||||
python3 scripts/rollup_tenant_metrics.py --all # incluir inactivos
|
|
||||||
```
|
|
||||||
|
|
||||||
El script crea la tabla de forma idempotente (no usa el runner de migraciones
|
|
||||||
de tenants) y es seguro re-ejecutarlo para la misma fecha.
|
|
||||||
|
|
||||||
- Endpoints (requieren `Authorization: Bearer <token>`):
|
|
||||||
- `GET /api/tenants/metrics?days=14` — rollup + `{health_score, band, signals}` por tenant
|
|
||||||
- `GET /api/portfolio/anomalies?date=YYYY-MM-DD&z=3.0&min_tenants=5` — anomalías entre tenants
|
|
||||||
|
|
||||||
Con menos de `min_tenants` tenants en la cohorte el detector devuelve
|
|
||||||
`cold_start: true` y no emite hallazgos. La "actividad" usa `audit_log` como
|
|
||||||
proxy porque no se persisten logins individuales.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notas de seguridad
|
## Notas de seguridad
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from blueprints.tenants_bp import tenants_bp
|
|||||||
from blueprints.demos_bp import demos_bp
|
from blueprints.demos_bp import demos_bp
|
||||||
from blueprints.health_bp import health_bp
|
from blueprints.health_bp import health_bp
|
||||||
from blueprints.admin_bp import admin_bp
|
from blueprints.admin_bp import admin_bp
|
||||||
from blueprints.portfolio_bp import portfolio_bp
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
@@ -32,7 +31,6 @@ def create_app():
|
|||||||
app.register_blueprint(demos_bp)
|
app.register_blueprint(demos_bp)
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
app.register_blueprint(admin_bp)
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(portfolio_bp)
|
|
||||||
|
|
||||||
# ─── Frontend Routes ───────────────────────────────────────────────────
|
# ─── Frontend Routes ───────────────────────────────────────────────────
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
@@ -59,10 +57,6 @@ def create_app():
|
|||||||
def health_page():
|
def health_page():
|
||||||
return render_template("index.html")
|
return render_template("index.html")
|
||||||
|
|
||||||
@app.route("/portfolio")
|
|
||||||
def portfolio_page():
|
|
||||||
return render_template("index.html")
|
|
||||||
|
|
||||||
@app.route("/migrations")
|
@app.route("/migrations")
|
||||||
def migrations_page():
|
def migrations_page():
|
||||||
return render_template("index.html")
|
return render_template("index.html")
|
||||||
|
|||||||
@@ -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"]
|
db_name = tenant["db_name"]
|
||||||
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
dsn = TENANT_DB_URL_TEMPLATE.format(db_name=db_name)
|
||||||
|
|
||||||
# Whitelist de tablas de NEGOCIO/PII a limpiar. Antes faltaban customers,
|
tables_to_truncate = [
|
||||||
# whatsapp/wa_*, audit_log, layaways, returns, marketplace_*, consignaciones
|
"inventory_operations",
|
||||||
# → el "reset" dejaba datos personales e historial de conversaciones.
|
"inventory",
|
||||||
candidate_tables = [
|
"sale_items",
|
||||||
# Inventario
|
"sales",
|
||||||
"inventory_operations", "inventory_stock", "inventory_stock_summary",
|
"customer_payments",
|
||||||
"inventory_images", "inventory_sku_aliases", "inventory_vehicle_compat",
|
"cash_register_closings",
|
||||||
"inventory_transfer_lines", "inventory_transfers", "physical_count_lines",
|
"cash_register_movements",
|
||||||
"physical_counts", "reorder_alerts", "inventory", "inventory_uploads",
|
"cash_registers",
|
||||||
# Ventas / caja
|
"invoices",
|
||||||
"sale_payments", "sale_items", "sales", "customer_payments",
|
"accounting_entries",
|
||||||
"cash_movements", "cash_register_movements", "cash_registers",
|
"journal_entries",
|
||||||
"layaway_payments", "layaway_items", "layaways",
|
"service_orders",
|
||||||
# Devoluciones / garantías
|
"fleet_vehicles",
|
||||||
"return_items", "returns", "warranty_claims", "warranties",
|
"crm_activities",
|
||||||
# Cotizaciones / compras
|
"quotations",
|
||||||
"quotation_items", "quotations",
|
"quotation_items",
|
||||||
"purchase_order_items", "purchase_orders",
|
"savings_transactions",
|
||||||
# Taller / flotilla
|
"savings_accounts",
|
||||||
"service_order_items", "service_order_labor", "service_orders",
|
"supplier_orders",
|
||||||
"fleet_maintenance_logs", "fleet_vehicles",
|
"supplier_order_items",
|
||||||
# CRM / clientes (PII)
|
"warranty_claims",
|
||||||
"customer_activities", "crm_activities", "loyalty_transactions",
|
"notifications",
|
||||||
"loyalty_points", "customers",
|
"inventory_uploads",
|
||||||
# Marketplace / MeLi
|
|
||||||
"marketplace_order_items", "marketplace_orders", "marketplace_listings",
|
|
||||||
"marketplace_questions", "meli_sync_queue",
|
|
||||||
# WhatsApp (PII)
|
|
||||||
"whatsapp_messages", "whatsapp_sessions", "wa_customer_links",
|
|
||||||
"wa_learning_sessions",
|
|
||||||
# Contabilidad / fiscal
|
|
||||||
"cfdi_queue", "invoices", "journal_lines", "journal_entries",
|
|
||||||
# Auditoría / sesiones
|
|
||||||
"audit_log", "employee_sessions", "notifications",
|
|
||||||
# Consignación (Yonke)
|
|
||||||
"consignment_items", "consignments",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
conn = psycopg2.connect(dsn)
|
conn = psycopg2.connect(dsn)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
try:
|
try:
|
||||||
# Solo se truncan las que existan en este tenant (esquema puede variar).
|
for table in tables_to_truncate:
|
||||||
cur.execute("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
|
||||||
existing = {r[0] for r in cur.fetchall()}
|
|
||||||
truncated = 0
|
|
||||||
for table in candidate_tables:
|
|
||||||
if table not in existing:
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
cur.execute(f'TRUNCATE TABLE public."{table}" RESTART IDENTITY CASCADE')
|
cur.execute(f"TRUNCATE TABLE {table} RESTART IDENTITY CASCADE")
|
||||||
truncated += 1
|
|
||||||
except Exception:
|
except Exception:
|
||||||
conn.rollback()
|
pass # Table may not exist
|
||||||
conn.commit()
|
conn.commit()
|
||||||
success = True
|
success = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -256,7 +237,7 @@ def reset_tenant(tenant_id, keep_config=True):
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return {"success": success, "tenant_id": tenant_id, "tables_reset": truncated}
|
return {"success": success, "tenant_id": tenant_id, "tables_reset": len(tables_to_truncate)}
|
||||||
|
|
||||||
|
|
||||||
def delete_tenant(tenant_id):
|
def delete_tenant(tenant_id):
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ const routes = {
|
|||||||
"#dashboard": "dashboard",
|
"#dashboard": "dashboard",
|
||||||
"#demos": "demos",
|
"#demos": "demos",
|
||||||
"#tenants": "tenants",
|
"#tenants": "tenants",
|
||||||
"#portfolio": "portfolio",
|
|
||||||
"#health": "health",
|
"#health": "health",
|
||||||
"#migrations": "migrations"
|
"#migrations": "migrations"
|
||||||
};
|
};
|
||||||
@@ -30,7 +29,6 @@ function navigate() {
|
|||||||
dashboard: "Dashboard",
|
dashboard: "Dashboard",
|
||||||
demos: "Crear Demos",
|
demos: "Crear Demos",
|
||||||
tenants: "Tenants",
|
tenants: "Tenants",
|
||||||
portfolio: "Cartera y Salud",
|
|
||||||
health: "Salud del Sistema",
|
health: "Salud del Sistema",
|
||||||
migrations: "Migraciones"
|
migrations: "Migraciones"
|
||||||
};
|
};
|
||||||
@@ -40,7 +38,6 @@ function navigate() {
|
|||||||
if (page === "dashboard") loadDashboard();
|
if (page === "dashboard") loadDashboard();
|
||||||
if (page === "demos") loadDemos();
|
if (page === "demos") loadDemos();
|
||||||
if (page === "tenants") loadTenants();
|
if (page === "tenants") loadTenants();
|
||||||
if (page === "portfolio") loadPortfolio();
|
|
||||||
if (page === "health") loadHealth();
|
if (page === "health") loadHealth();
|
||||||
if (page === "migrations") loadMigrations();
|
if (page === "migrations") loadMigrations();
|
||||||
}
|
}
|
||||||
@@ -274,77 +271,6 @@ document.getElementById("tenant-search")?.addEventListener("input", (e) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Portfolio (business metrics + health + anomalies) ─────────────────────
|
|
||||||
const BAND_TAG = {
|
|
||||||
healthy: "success",
|
|
||||||
stable: "info",
|
|
||||||
at_risk: "warning",
|
|
||||||
critical: "danger",
|
|
||||||
unknown: "default"
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadPortfolio() {
|
|
||||||
const tbody = document.getElementById("portfolio-table");
|
|
||||||
const aBody = document.getElementById("anomalies-table");
|
|
||||||
|
|
||||||
const metricsRes = await api("/api/tenants/metrics?days=14");
|
|
||||||
if (!metricsRes || metricsRes.status !== 200) {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="8" class="text-muted text-center">Error al cargar métricas</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const m = metricsRes.data;
|
|
||||||
document.getElementById("portfolio-meta").textContent = m.rollup_available
|
|
||||||
? `Último día: ${m.date} · ventana ${m.window_days}d`
|
|
||||||
: "Sin rollup";
|
|
||||||
|
|
||||||
if (!m.rollup_available) {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="8" class="text-muted text-center">Sin datos de rollup. Ejecuta <code>scripts/rollup_tenant_metrics.py</code></td></tr>`;
|
|
||||||
} else {
|
|
||||||
tbody.innerHTML = m.tenants.map(t => {
|
|
||||||
const h = t.health || {};
|
|
||||||
const x = t.metrics || {};
|
|
||||||
const score = h.health_score == null ? "—" : h.health_score;
|
|
||||||
const signals = (h.signals || []).map(s => s.code).join(", ") || "—";
|
|
||||||
const fitment = x.fitment_pct == null ? "—" : `${x.fitment_pct}%`;
|
|
||||||
return `<tr>
|
|
||||||
<td><strong>${escapeHtml(t.name)}</strong><br><span class="text-muted" style="font-size:11px">${escapeHtml(t.db_name)}</span></td>
|
|
||||||
<td>${tag(`${score} · ${h.band_label || h.band}`, BAND_TAG[h.band] || "default")}</td>
|
|
||||||
<td>${x.ventas_count ?? 0} / $${(x.ventas_total ?? 0).toLocaleString("es-MX")}</td>
|
|
||||||
<td>$${(x.ticket_promedio ?? 0).toLocaleString("es-MX")}</td>
|
|
||||||
<td>${(x.items_activos ?? 0).toLocaleString("es-MX")}</td>
|
|
||||||
<td>${(x.clientes ?? 0).toLocaleString("es-MX")}</td>
|
|
||||||
<td>${fitment}</td>
|
|
||||||
<td style="font-size:11px" class="text-muted">${escapeHtml(signals)}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
const anomRes = await api("/api/portfolio/anomalies");
|
|
||||||
if (!anomRes || anomRes.status !== 200) {
|
|
||||||
aBody.innerHTML = `<tr><td colspan="6" class="text-muted text-center">Error al cargar anomalías</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const a = anomRes.data;
|
|
||||||
document.getElementById("anomalies-note").textContent =
|
|
||||||
`Día ${a.date || "—"} · cohorte ${a.cohort_size} tenants · z≥${a.thresholds?.z_threshold} (${a.thresholds?.method})` +
|
|
||||||
(a.cold_start ? " · COLD START: cohorte insuficiente" : "");
|
|
||||||
|
|
||||||
if (!a.anomalies || !a.anomalies.length) {
|
|
||||||
aBody.innerHTML = `<tr><td colspan="6" class="text-muted text-center">Sin anomalías entre tenants</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
aBody.innerHTML = a.anomalies.map(x => `
|
|
||||||
<tr>
|
|
||||||
<td>${escapeHtml(x.tenant_name)}</td>
|
|
||||||
<td>${escapeHtml(x.label)}</td>
|
|
||||||
<td>${(x.value ?? 0).toLocaleString("es-MX")}</td>
|
|
||||||
<td>${(x.cohort_median ?? 0).toLocaleString("es-MX")}</td>
|
|
||||||
<td>${x.z}</td>
|
|
||||||
<td>${tag(x.direction === "peak" ? "Pico inusual" : "Caída inusual", x.direction === "peak" ? "info" : "danger")}</td>
|
|
||||||
</tr>
|
|
||||||
`).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Health ────────────────────────────────────────────────────────────────
|
// ─── Health ────────────────────────────────────────────────────────────────
|
||||||
async function loadHealth() {
|
async function loadHealth() {
|
||||||
const res = await api("/api/health");
|
const res = await api("/api/health");
|
||||||
|
|||||||
@@ -56,10 +56,6 @@
|
|||||||
<span>Tenants</span>
|
<span>Tenants</span>
|
||||||
<span class="badge" id="tenant-count">0</span>
|
<span class="badge" id="tenant-count">0</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#/portfolio" class="nav-item" data-page="portfolio">
|
|
||||||
<i class="fas fa-briefcase"></i>
|
|
||||||
<span>Cartera</span>
|
|
||||||
</a>
|
|
||||||
<a href="#/health" class="nav-item" data-page="health">
|
<a href="#/health" class="nav-item" data-page="health">
|
||||||
<i class="fas fa-heartbeat"></i>
|
<i class="fas fa-heartbeat"></i>
|
||||||
<span>Salud</span>
|
<span>Salud</span>
|
||||||
@@ -244,63 +240,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Portfolio Page -->
|
|
||||||
<section id="page-portfolio" class="page" style="display:none;">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3><i class="fas fa-briefcase"></i> Salud de Cartera</h3>
|
|
||||||
<div class="card-actions">
|
|
||||||
<span id="portfolio-meta" class="text-muted" style="font-size:12px;"></span>
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="loadPortfolio()">
|
|
||||||
<i class="fas fa-sync"></i> Refrescar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Tenant</th>
|
|
||||||
<th>Salud</th>
|
|
||||||
<th>Ventas (día)</th>
|
|
||||||
<th>Ticket prom.</th>
|
|
||||||
<th>Items</th>
|
|
||||||
<th>Clientes</th>
|
|
||||||
<th>Fitment</th>
|
|
||||||
<th>Señales</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="portfolio-table">
|
|
||||||
<tr><td colspan="8" class="text-muted">Cargando...</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3><i class="fas fa-exclamation-triangle"></i> Anomalías entre Tenants</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="anomalies-note" class="text-muted" style="font-size:12px;margin-bottom:8px;"></div>
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Tenant</th>
|
|
||||||
<th>Métrica</th>
|
|
||||||
<th>Valor</th>
|
|
||||||
<th>Mediana cohorte</th>
|
|
||||||
<th>z</th>
|
|
||||||
<th>Dirección</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="anomalies-table">
|
|
||||||
<tr><td colspan="6" class="text-muted">Cargando...</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Health Page -->
|
<!-- Health Page -->
|
||||||
<section id="page-health" class="page" style="display:none;">
|
<section id="page-health" class="page" style="display:none;">
|
||||||
<div class="grid-3">
|
<div class="grid-3">
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location = / {
|
location = / {
|
||||||
return 302 /pos/login2;
|
return 302 /pos/login;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
437
pos/app.py
437
pos/app.py
@@ -1,201 +1,5 @@
|
|||||||
import logging
|
from flask import Flask
|
||||||
import re
|
|
||||||
import time
|
|
||||||
|
|
||||||
from flask import Flask, request, redirect, make_response, g
|
|
||||||
from json_provider import OrjsonProvider
|
from json_provider import OrjsonProvider
|
||||||
from services.prom_metrics import CONTENT_TYPE as PROM_CONTENT_TYPE, metrics as prom_metrics
|
|
||||||
from services.prom_metrics import normalize_path as prom_normalize_path
|
|
||||||
|
|
||||||
|
|
||||||
_alert_log = logging.getLogger('nexus.pos.alerts')
|
|
||||||
|
|
||||||
# `+` opcional seguido de 10-15 dígitos (formato internacional).
|
|
||||||
_ALERT_PHONE_RE = re.compile(r'^\+?\d{10,15}$')
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_payload_tenant_ref(data, alerts):
|
|
||||||
"""Extrae el tenant referenciado por un payload de Alertmanager.
|
|
||||||
|
|
||||||
Busca `labels.tenant_id` (o `labels.tenant`) en el payload, en sus
|
|
||||||
`commonLabels` y en las etiquetas de cada alerta. Devuelve el primer valor
|
|
||||||
no vacío o None.
|
|
||||||
"""
|
|
||||||
label_dicts = []
|
|
||||||
for candidate in (data.get('labels'), data.get('commonLabels')):
|
|
||||||
if isinstance(candidate, dict):
|
|
||||||
label_dicts.append(candidate)
|
|
||||||
for alert in alerts or []:
|
|
||||||
if isinstance(alert, dict) and isinstance(alert.get('labels'), dict):
|
|
||||||
label_dicts.append(alert['labels'])
|
|
||||||
|
|
||||||
for labels in label_dicts:
|
|
||||||
for key in ('tenant_id', 'tenant'):
|
|
||||||
value = labels.get(key)
|
|
||||||
if value not in (None, '', []):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_tenant_dbname(tenant_ref):
|
|
||||||
"""Resuelve una referencia de tenant a su `db_name`.
|
|
||||||
|
|
||||||
Acepta id numérico, db_name, subdominio o nombre exacto. Devuelve None si
|
|
||||||
no existe o si falla la consulta.
|
|
||||||
"""
|
|
||||||
from tenant_db import get_master_conn
|
|
||||||
|
|
||||||
ref = str(tenant_ref).strip()
|
|
||||||
if not ref:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
conn = get_master_conn()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
if ref.isdigit():
|
|
||||||
cur.execute(
|
|
||||||
"SELECT db_name FROM tenants WHERE id = %s AND is_active = true",
|
|
||||||
(int(ref),),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cur.execute(
|
|
||||||
"""SELECT db_name FROM tenants
|
|
||||||
WHERE is_active = true
|
|
||||||
AND (db_name = %s OR LOWER(subdomain) = %s OR LOWER(name) = %s)
|
|
||||||
LIMIT 1""",
|
|
||||||
(ref, ref.lower(), ref.lower()),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
cur.close()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return row[0] if row and row[0] else None
|
|
||||||
except Exception as exc:
|
|
||||||
_alert_log.warning("alert routing: no se pudo resolver tenant %r: %s", tenant_ref, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_tenant_alert_config(db_name):
|
|
||||||
"""Lee los contactos de alerta del tenant `db_name`.
|
|
||||||
|
|
||||||
Devuelve (phones, enabled). Ante cualquier fallo devuelve ([], False) para
|
|
||||||
degradar sin error.
|
|
||||||
"""
|
|
||||||
from tenant_db import get_tenant_conn_by_dbname
|
|
||||||
|
|
||||||
phones, enabled = [], False
|
|
||||||
try:
|
|
||||||
conn = get_tenant_conn_by_dbname(db_name)
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT key, value FROM tenant_config "
|
|
||||||
"WHERE key IN ('alert_phones', 'alert_whatsapp_enabled')"
|
|
||||||
)
|
|
||||||
rows = {r[0]: r[1] for r in cur.fetchall()}
|
|
||||||
cur.close()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
phones = [p.strip() for p in str(rows.get('alert_phones') or '').replace('"', '').split(',') if p.strip()]
|
|
||||||
enabled = str(rows.get('alert_whatsapp_enabled', 'false')).lower() == 'true'
|
|
||||||
except Exception as exc:
|
|
||||||
_alert_log.warning("alert routing: no se pudo leer config de %s: %s", db_name, exc)
|
|
||||||
return phones, enabled
|
|
||||||
|
|
||||||
|
|
||||||
def _global_alert_phones():
|
|
||||||
"""Fallback de compatibilidad: `ALERT_PHONES` de /etc/nexus/alert.env."""
|
|
||||||
try:
|
|
||||||
with open('/etc/nexus/alert.env') as fh:
|
|
||||||
for line in fh:
|
|
||||||
if line.startswith('ALERT_PHONES='):
|
|
||||||
raw = line.split('=', 1)[1].strip().strip('"')
|
|
||||||
return [p.strip() for p in raw.split(',') if p.strip()]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _send_via_bridge(url, phones, msg):
|
|
||||||
"""Envía `msg` a `phones` por el bridge de WhatsApp de `url`.
|
|
||||||
|
|
||||||
Devuelve True si al menos un envío se completó. Best effort: no propaga
|
|
||||||
excepciones.
|
|
||||||
"""
|
|
||||||
import requests
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
status = requests.get(url + '/status', timeout=4).json()
|
|
||||||
if status.get('state') != 'connected':
|
|
||||||
return False
|
|
||||||
sent = False
|
|
||||||
for phone in phones:
|
|
||||||
try:
|
|
||||||
requests.post(url + '/send', json={'phone': phone, 'message': msg}, timeout=10)
|
|
||||||
sent = True
|
|
||||||
except Exception as exc:
|
|
||||||
_alert_log.warning("alert routing: fallo enviando a %s: %s", phone, exc)
|
|
||||||
return sent
|
|
||||||
except Exception as exc:
|
|
||||||
_alert_log.warning("alert routing: bridge %s no disponible: %s", url, exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _send_tenant_alert(db_name, phones, msg):
|
|
||||||
"""Envía la alerta por el bridge configurado en ESE tenant."""
|
|
||||||
from tenant_db import get_tenant_conn_by_dbname
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = get_tenant_conn_by_dbname(db_name)
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'whatsapp_bridge_url'")
|
|
||||||
row = cur.fetchone()
|
|
||||||
cur.close()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return _send_via_bridge(row[0] if row and row[0] else '', phones, msg)
|
|
||||||
except Exception as exc:
|
|
||||||
_alert_log.warning("alert routing: bridge del tenant %s no disponible: %s", db_name, exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _send_global_alert(phones, msg):
|
|
||||||
"""Fallback global: envía por el primer bridge de tenant conectado."""
|
|
||||||
from tenant_db import get_master_conn, get_tenant_conn_by_dbname
|
|
||||||
|
|
||||||
try:
|
|
||||||
conn = get_master_conn()
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT db_name FROM tenants WHERE is_active = true")
|
|
||||||
dbs = [r[0] for r in cur.fetchall()]
|
|
||||||
cur.close()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
for db in dbs:
|
|
||||||
try:
|
|
||||||
tconn = get_tenant_conn_by_dbname(db)
|
|
||||||
try:
|
|
||||||
tcur = tconn.cursor()
|
|
||||||
tcur.execute("SELECT value FROM tenant_config WHERE key = 'whatsapp_bridge_url'")
|
|
||||||
row = tcur.fetchone()
|
|
||||||
tcur.close()
|
|
||||||
finally:
|
|
||||||
tconn.close()
|
|
||||||
if not row or not row[0]:
|
|
||||||
continue
|
|
||||||
if _send_via_bridge(row[0], phones, msg):
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
return False
|
|
||||||
except Exception as exc:
|
|
||||||
_alert_log.warning("alert routing: fallback global sin bridge: %s", exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
@@ -206,200 +10,17 @@ def create_app():
|
|||||||
from middleware_tenant import resolve_tenant
|
from middleware_tenant import resolve_tenant
|
||||||
app.before_request(resolve_tenant)
|
app.before_request(resolve_tenant)
|
||||||
|
|
||||||
# ─── Prometheus metrics ────────────────────────────────────────
|
|
||||||
# Se registran DESPUÉS de resolve_tenant, así que corren después de él.
|
|
||||||
prom_metrics.configure('pos')
|
|
||||||
|
|
||||||
@app.before_request
|
|
||||||
def _metrics_request_start():
|
|
||||||
if request.path != '/metrics':
|
|
||||||
g._metrics_t0 = time.perf_counter()
|
|
||||||
prom_metrics.request_started()
|
|
||||||
|
|
||||||
@app.after_request
|
|
||||||
def _metrics_request_end(response):
|
|
||||||
t0 = getattr(g, '_metrics_t0', None)
|
|
||||||
if t0 is not None:
|
|
||||||
g._metrics_done = True
|
|
||||||
rule = getattr(request.url_rule, 'rule', None)
|
|
||||||
prom_metrics.request_finished(
|
|
||||||
request.method,
|
|
||||||
prom_normalize_path(rule, request.path),
|
|
||||||
response.status_code,
|
|
||||||
time.perf_counter() - t0,
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.teardown_request
|
|
||||||
def _metrics_request_teardown(exc):
|
|
||||||
if exc is not None:
|
|
||||||
prom_metrics.record_exception()
|
|
||||||
if getattr(g, '_metrics_t0', None) is not None and not getattr(g, '_metrics_done', False):
|
|
||||||
prom_metrics.request_aborted()
|
|
||||||
|
|
||||||
# NOTE: Page-level routing guards are handled client-side by app-init.js
|
|
||||||
# using the employee's current permissions; API endpoints enforce their own
|
|
||||||
# permission checks via @require_auth.
|
|
||||||
|
|
||||||
# ─── PWA: Service Worker must be served from /pos/ scope ──────
|
# ─── PWA: Service Worker must be served from /pos/ scope ──────
|
||||||
@app.route('/pos/api/notifications/webhook', methods=['POST'])
|
|
||||||
def notifications_webhook():
|
|
||||||
"""Recibe alertas de Alertmanager y las reenvía por WhatsApp (best effort).
|
|
||||||
|
|
||||||
El destino es POR NEGOCIO: cada tenant configura sus propios números en
|
|
||||||
su `tenant_config` (`alert_phones` + `alert_whatsapp_enabled`),
|
|
||||||
editables desde `PUT /pos/api/config/alert-contacts`. Resolución:
|
|
||||||
|
|
||||||
(a) si el payload trae `labels.tenant_id` (o `labels.tenant`) → se usan
|
|
||||||
los contactos de ESE tenant y se envía por su bridge de WhatsApp;
|
|
||||||
(b) si no, fallback de compatibilidad al `ALERT_PHONES` global de
|
|
||||||
/etc/nexus/alert.env (best effort por el primer bridge conectado);
|
|
||||||
(c) si no hay contactos → no-op con `sent:false` (nunca error).
|
|
||||||
|
|
||||||
Si un negocio no configura contactos, sus alertas no se envían: no hay
|
|
||||||
ningún teléfono global obligatorio. Autenticación por `INTERNAL_API_KEY`
|
|
||||||
(Bearer o X-Internal-Key). Tolera fallos: nunca responde 500.
|
|
||||||
"""
|
|
||||||
# Endpoint abierto solo con el secreto interno (Alertmanager lo envía como Bearer).
|
|
||||||
from config import INTERNAL_API_KEY as _internal_key
|
|
||||||
if _internal_key:
|
|
||||||
_tok = request.headers.get('X-Internal-Key', '')
|
|
||||||
_auth = request.headers.get('Authorization', '')
|
|
||||||
if _tok != _internal_key and _auth != f'Bearer {_internal_key}':
|
|
||||||
return jsonify({'error': 'unauthorized'}), 401
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
alerts = data.get('alerts') or []
|
|
||||||
status = data.get('status', 'firing')
|
|
||||||
lines = []
|
|
||||||
for a in alerts[:5]:
|
|
||||||
ann = a.get('annotations', {})
|
|
||||||
summary = ann.get('summary') or a.get('labels', {}).get('alertname', 'alerta')
|
|
||||||
desc = ann.get('description', '')
|
|
||||||
lines.append(f"• {summary}" + (f" — {desc}" if desc else ''))
|
|
||||||
if not lines:
|
|
||||||
return jsonify({'ok': True, 'sent': False, 'reason': 'sin alertas'})
|
|
||||||
|
|
||||||
try:
|
|
||||||
# (a) Destino por negocio si el payload identifica un tenant.
|
|
||||||
tenant_ref = _extract_payload_tenant_ref(data, alerts)
|
|
||||||
tenant_db, phones = None, []
|
|
||||||
if tenant_ref is not None:
|
|
||||||
tenant_db = _resolve_tenant_dbname(tenant_ref)
|
|
||||||
if tenant_db:
|
|
||||||
phones, enabled = _get_tenant_alert_config(tenant_db)
|
|
||||||
if phones and not enabled:
|
|
||||||
return jsonify({'ok': True, 'sent': False, 'scope': 'tenant',
|
|
||||||
'reason': 'alertas WhatsApp deshabilitadas para el negocio'})
|
|
||||||
else:
|
|
||||||
# (b) Fallback de compatibilidad al teléfono global.
|
|
||||||
phones = _global_alert_phones()
|
|
||||||
|
|
||||||
if not phones:
|
|
||||||
return jsonify({'ok': True, 'sent': False, 'reason': 'sin contactos configurados'})
|
|
||||||
|
|
||||||
msg = ('🚨 *Nexus Alert* [' + status + ']\n' + '\n'.join(lines))
|
|
||||||
if tenant_db:
|
|
||||||
sent = _send_tenant_alert(tenant_db, phones, msg)
|
|
||||||
scope, reason = 'tenant', 'bridge del negocio no conectado'
|
|
||||||
else:
|
|
||||||
sent = _send_global_alert(phones, msg)
|
|
||||||
scope, reason = 'global', 'sin bridge conectado'
|
|
||||||
if sent:
|
|
||||||
return jsonify({'ok': True, 'sent': True, 'scope': scope})
|
|
||||||
return jsonify({'ok': True, 'sent': False, 'scope': scope, 'reason': reason})
|
|
||||||
except Exception as exc:
|
|
||||||
_alert_log.exception("alert routing: fallo inesperado: %s", exc)
|
|
||||||
return jsonify({'ok': True, 'sent': False, 'reason': 'error interno'})
|
|
||||||
|
|
||||||
@app.route('/pos/api/public/media/<path:filename>')
|
|
||||||
def public_media(filename):
|
|
||||||
"""Sirve media temporal (imágenes/PDF) para enviar vía WA CRM."""
|
|
||||||
from flask import send_from_directory, abort
|
|
||||||
import re as _re
|
|
||||||
if not _re.fullmatch(r'[0-9a-f]{32}(_[A-Za-z0-9._-]{1,80})?\.(png|jpg|jpeg|pdf)', filename):
|
|
||||||
abort(404)
|
|
||||||
return send_from_directory('/tmp/wa_media', filename, max_age=3600)
|
|
||||||
|
|
||||||
@app.route('/pos/sw-push.js')
|
|
||||||
def pos_sw_push():
|
|
||||||
from flask import send_from_directory, make_response
|
|
||||||
response = make_response(send_from_directory('static', 'sw-push.js',
|
|
||||||
mimetype='application/javascript'))
|
|
||||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.route('/pos/sw.js')
|
@app.route('/pos/sw.js')
|
||||||
def pos_sw():
|
def pos_sw():
|
||||||
from flask import send_from_directory, make_response
|
from flask import send_from_directory
|
||||||
response = make_response(send_from_directory('static/pwa', 'sw.js',
|
return send_from_directory('static/pwa', 'sw.js',
|
||||||
mimetype='application/javascript'))
|
mimetype='application/javascript')
|
||||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
||||||
response.headers['Pragma'] = 'no-cache'
|
|
||||||
response.headers['Expires'] = '0'
|
|
||||||
return response
|
|
||||||
|
|
||||||
# ─── Manejo global de tablas retiradas (esquema TecDoc, junio 2026) ───
|
|
||||||
# Los endpoints que aún consultan el catálogo OEM antiguo devolvían 500 con
|
|
||||||
# SQL crudo. Aquí se degradan a una respuesta limpia y vacía.
|
|
||||||
from werkzeug.exceptions import HTTPException
|
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
|
||||||
def _handle_retired_schema(e):
|
|
||||||
if isinstance(e, HTTPException):
|
|
||||||
return e
|
|
||||||
try:
|
|
||||||
from psycopg2.errors import UndefinedTable, UndefinedColumn
|
|
||||||
if isinstance(e, (UndefinedTable, UndefinedColumn)):
|
|
||||||
name = getattr(e, 'diag', None)
|
|
||||||
table = getattr(name, 'table_name', '') if name else ''
|
|
||||||
return jsonify({
|
|
||||||
'data': [],
|
|
||||||
'oem_disabled': True,
|
|
||||||
'error': 'Función no disponible en este despliegue',
|
|
||||||
'detail': f'La tabla "{table}" fue retirada del catálogo maestro.'
|
|
||||||
if table else 'Consulta sobre esquema retirado.',
|
|
||||||
}), 200
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
from blueprints.auth_bp import auth_bp
|
from blueprints.auth_bp import auth_bp
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
|
|
||||||
# Storefront público por tenant (vitrina web + pedidos)
|
|
||||||
from blueprints.storefront_bp import storefront_bp
|
|
||||||
app.register_blueprint(storefront_bp)
|
|
||||||
|
|
||||||
@app.route('/tienda')
|
|
||||||
def public_storefront():
|
|
||||||
from flask import g, render_template
|
|
||||||
business_name = 'Refaccionaria'
|
|
||||||
tid = getattr(g, 'tenant_id', None)
|
|
||||||
if tid:
|
|
||||||
try:
|
|
||||||
from tenant_db import get_master_conn
|
|
||||||
mconn = get_master_conn()
|
|
||||||
mcur = mconn.cursor()
|
|
||||||
mcur.execute("SELECT name FROM tenants WHERE id = %s", (tid,))
|
|
||||||
row = mcur.fetchone()
|
|
||||||
mcur.close()
|
|
||||||
mconn.close()
|
|
||||||
if row:
|
|
||||||
business_name = row[0]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return render_template('storefront.html', business_name=business_name)
|
|
||||||
|
|
||||||
@app.route('/')
|
|
||||||
def root_redirect():
|
|
||||||
# En subdominios de tenants la raíz es la tienda pública;
|
|
||||||
# en el dominio principal manda al POS.
|
|
||||||
from flask import g
|
|
||||||
if getattr(g, 'tenant_id', None):
|
|
||||||
return redirect('/tienda')
|
|
||||||
return redirect('/pos/login')
|
|
||||||
|
|
||||||
from blueprints.config_bp import config_bp
|
from blueprints.config_bp import config_bp
|
||||||
app.register_blueprint(config_bp)
|
app.register_blueprint(config_bp)
|
||||||
|
|
||||||
@@ -499,25 +120,11 @@ def create_app():
|
|||||||
from blueprints.internal_bp import internal_bp
|
from blueprints.internal_bp import internal_bp
|
||||||
app.register_blueprint(internal_bp)
|
app.register_blueprint(internal_bp)
|
||||||
|
|
||||||
from blueprints.yonke_bp import yonke_bp
|
|
||||||
app.register_blueprint(yonke_bp)
|
|
||||||
|
|
||||||
from blueprints.b2b_bp import b2b_bp
|
|
||||||
app.register_blueprint(b2b_bp)
|
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.route('/pos/health')
|
@app.route('/pos/health')
|
||||||
def health():
|
def health():
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
|
|
||||||
# Prometheus scrape endpoint (no pasa por el contador: se excluye por path)
|
|
||||||
@app.route('/metrics')
|
|
||||||
def prometheus_metrics():
|
|
||||||
response = make_response(prom_metrics.render())
|
|
||||||
response.headers['Content-Type'] = PROM_CONTENT_TYPE
|
|
||||||
response.headers['Cache-Control'] = 'no-store'
|
|
||||||
return response
|
|
||||||
|
|
||||||
from flask import g, jsonify, render_template, send_from_directory
|
from flask import g, jsonify, render_template, send_from_directory
|
||||||
|
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
@@ -525,23 +132,11 @@ def create_app():
|
|||||||
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
|
return send_from_directory('static/pwa', 'icon-192.png', mimetype='image/png')
|
||||||
|
|
||||||
@app.route('/pos/login')
|
@app.route('/pos/login')
|
||||||
def pos_login_legacy():
|
|
||||||
# Redirect to the new login path to bypass any stale browser/SW cache
|
|
||||||
# of the old /pos/login page that did not send employee_id.
|
|
||||||
response = make_response(redirect('/pos/login2'))
|
|
||||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.route('/pos/login2')
|
|
||||||
def pos_login():
|
def pos_login():
|
||||||
response = make_response(render_template('login.html',
|
return render_template('login.html',
|
||||||
tenant_id=getattr(g, 'tenant_id', None),
|
tenant_id=getattr(g, 'tenant_id', None),
|
||||||
tenant_name=getattr(g, 'tenant_name', None),
|
tenant_name=getattr(g, 'tenant_name', None),
|
||||||
tenant_subdomain=getattr(g, 'tenant_subdomain', None)))
|
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
|
||||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
||||||
response.headers['Pragma'] = 'no-cache'
|
|
||||||
response.headers['Expires'] = '0'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.route('/pos/supplier-catalog')
|
@app.route('/pos/supplier-catalog')
|
||||||
def supplier_catalog_page():
|
def supplier_catalog_page():
|
||||||
@@ -573,11 +168,7 @@ def create_app():
|
|||||||
|
|
||||||
@app.route('/pos/dashboard')
|
@app.route('/pos/dashboard')
|
||||||
def pos_dashboard():
|
def pos_dashboard():
|
||||||
response = make_response(render_template('dashboard.html'))
|
return render_template('dashboard.html')
|
||||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
||||||
response.headers['Pragma'] = 'no-cache'
|
|
||||||
response.headers['Expires'] = '0'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.route('/pos/config')
|
@app.route('/pos/config')
|
||||||
def pos_config():
|
def pos_config():
|
||||||
@@ -619,18 +210,6 @@ def create_app():
|
|||||||
def pos_historical_sales():
|
def pos_historical_sales():
|
||||||
return render_template('historical_sales.html')
|
return render_template('historical_sales.html')
|
||||||
|
|
||||||
@app.route('/pos/remission-notes')
|
|
||||||
def pos_remission_notes():
|
|
||||||
response = make_response(render_template('remission_notes.html'))
|
|
||||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
||||||
response.headers['Pragma'] = 'no-cache'
|
|
||||||
response.headers['Expires'] = '0'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.route('/pos/yonke')
|
|
||||||
def pos_yonke():
|
|
||||||
return render_template('yonke.html')
|
|
||||||
|
|
||||||
@app.route('/pos/static/<path:filename>')
|
@app.route('/pos/static/<path:filename>')
|
||||||
def pos_static(filename):
|
def pos_static(filename):
|
||||||
return send_from_directory('static', filename)
|
return send_from_directory('static', filename)
|
||||||
@@ -686,7 +265,7 @@ def create_app():
|
|||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
|
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate,
|
||||||
i.category_id, COALESCE(s.stock, 0) AS stock,
|
i.category, COALESCE(s.stock, 0) AS stock,
|
||||||
COALESCE(sv.total_sold, 0) AS total_sold
|
COALESCE(sv.total_sold, 0) AS total_sold
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
|
|||||||
@@ -7,62 +7,16 @@ NUMERIC(14,2) in the database.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import csv
|
from datetime import date, datetime
|
||||||
import io
|
from flask import Blueprint, request, jsonify, g
|
||||||
from datetime import date, datetime, timedelta
|
|
||||||
from flask import Blueprint, request, jsonify, g, Response
|
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.accounting_engine import create_manual_entry, record_supplier_payment_entry
|
from services.accounting_engine import create_manual_entry
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
|
|
||||||
accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting')
|
accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting')
|
||||||
|
|
||||||
|
|
||||||
def _csv_response(rows, header, filename):
|
|
||||||
"""Build a UTF-8 CSV Response with BOM (Excel-friendly)."""
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output)
|
|
||||||
writer.writerow(header)
|
|
||||||
writer.writerows(rows)
|
|
||||||
csv_data = output.getvalue()
|
|
||||||
output.close()
|
|
||||||
return Response(
|
|
||||||
'\ufeff' + csv_data,
|
|
||||||
mimetype='text/csv; charset=utf-8',
|
|
||||||
headers={'Content-Disposition': f'attachment; filename={filename}'}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _period_bounds(year, month):
|
|
||||||
"""Return (period_start, period_end) ISO dates for a fiscal month."""
|
|
||||||
period_start = f'{year}-{month:02d}-01'
|
|
||||||
if month == 12:
|
|
||||||
period_end = f'{year + 1}-01-01'
|
|
||||||
else:
|
|
||||||
period_end = f'{year}-{month + 1:02d}-01'
|
|
||||||
return period_start, period_end
|
|
||||||
|
|
||||||
|
|
||||||
def _supplier_paid_totals(cur):
|
|
||||||
"""Return {po_id: paid_amount} from supplier_payments.
|
|
||||||
|
|
||||||
The table is created on first supplier payment, so tenants that never paid
|
|
||||||
a supplier simply have no data (and no table) yet.
|
|
||||||
"""
|
|
||||||
cur.execute("SELECT to_regclass('public.supplier_payments')")
|
|
||||||
if not cur.fetchone()[0]:
|
|
||||||
return {}
|
|
||||||
cur.execute("""
|
|
||||||
SELECT po_id, COALESCE(SUM(amount), 0)
|
|
||||||
FROM supplier_payments
|
|
||||||
WHERE po_id IS NOT NULL
|
|
||||||
GROUP BY po_id
|
|
||||||
""")
|
|
||||||
return {r[0]: float(r[1]) for r in cur.fetchall()}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Chart of Accounts ─────────────────────────────
|
# ─── Chart of Accounts ─────────────────────────────
|
||||||
|
|
||||||
@accounting_bp.route('/accounts', methods=['GET'])
|
@accounting_bp.route('/accounts', methods=['GET'])
|
||||||
@@ -353,11 +307,29 @@ def create_entry():
|
|||||||
|
|
||||||
# ─── Financial Reports ──────────────────────────────
|
# ─── Financial Reports ──────────────────────────────
|
||||||
|
|
||||||
def _trial_balance_rows(conn, year, month):
|
@accounting_bp.route('/trial-balance', methods=['GET'])
|
||||||
"""Balanza de comprobacion rows for a fiscal month."""
|
@require_auth('accounting.view')
|
||||||
|
def trial_balance():
|
||||||
|
"""Balanza de comprobacion for a period.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
year: int (default current year)
|
||||||
|
month: int (default current month)
|
||||||
|
|
||||||
|
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
period_start, period_end = _period_bounds(year, month)
|
year = int(request.args.get('year', date.today().year))
|
||||||
|
month = int(request.args.get('month', date.today().month))
|
||||||
|
|
||||||
|
# Period start/end dates
|
||||||
|
period_start = f'{year}-{month:02d}-01'
|
||||||
|
if month == 12:
|
||||||
|
period_end = f'{year + 1}-01-01'
|
||||||
|
else:
|
||||||
|
period_end = f'{year}-{month + 1:02d}-01'
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
WITH initial_balances AS (
|
WITH initial_balances AS (
|
||||||
@@ -410,62 +382,31 @@ def _trial_balance_rows(conn, year, month):
|
|||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/trial-balance', methods=['GET'])
|
|
||||||
@require_auth('accounting.view')
|
|
||||||
def trial_balance():
|
|
||||||
"""Balanza de comprobacion for a period.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
year: int (default current year)
|
|
||||||
month: int (default current month)
|
|
||||||
|
|
||||||
Returns each account with: saldo_inicial, cargos, abonos, saldo_final
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
|
|
||||||
year = int(request.args.get('year', date.today().year))
|
|
||||||
month = int(request.args.get('month', date.today().month))
|
|
||||||
|
|
||||||
rows = _trial_balance_rows(conn, year, month)
|
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/trial-balance/export', methods=['GET'])
|
@accounting_bp.route('/income-statement', methods=['GET'])
|
||||||
@require_auth('accounting.view')
|
@require_auth('accounting.view')
|
||||||
def export_trial_balance():
|
def income_statement():
|
||||||
"""Export the balanza de comprobacion as CSV (default) or JSON.
|
"""Estado de resultados for a period.
|
||||||
|
|
||||||
Query params: year, month, format=csv|json
|
Query params: year, month
|
||||||
|
|
||||||
|
Groups: Ingresos (400), Costos (500), Gastos (600)
|
||||||
|
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
|
||||||
"""
|
"""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
year = int(request.args.get('year', date.today().year))
|
|
||||||
month = int(request.args.get('month', date.today().month))
|
|
||||||
fmt = request.args.get('format', 'csv').lower()
|
|
||||||
|
|
||||||
rows = _trial_balance_rows(conn, year, month)
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if fmt == 'json':
|
|
||||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
|
|
||||||
|
|
||||||
return _csv_response(
|
|
||||||
[[r['code'], r['name'], r['type'], r['saldo_inicial'], r['cargos'],
|
|
||||||
r['abonos'], r['saldo_final']] for r in rows],
|
|
||||||
['Cuenta', 'Nombre', 'Tipo', 'Saldo inicial', 'Cargos', 'Abonos', 'Saldo final'],
|
|
||||||
f'balanza_{year}-{month:02d}.csv'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _income_statement_data(conn, year, month):
|
|
||||||
"""Estado de resultados grouped by section for a fiscal month."""
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
period_start, period_end = _period_bounds(year, month)
|
year = int(request.args.get('year', date.today().year))
|
||||||
|
month = int(request.args.get('month', date.today().month))
|
||||||
|
|
||||||
|
period_start = f'{year}-{month:02d}-01'
|
||||||
|
if month == 12:
|
||||||
|
period_end = f'{year + 1}-01-01'
|
||||||
|
else:
|
||||||
|
period_end = f'{year}-{month + 1:02d}-01'
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT a.id, a.code, a.name, a.type,
|
SELECT a.id, a.code, a.name, a.type,
|
||||||
@@ -513,215 +454,16 @@ def _income_statement_data(conn, year, month):
|
|||||||
utilidad_neta = round(utilidad_bruta - total_gastos, 2)
|
utilidad_neta = round(utilidad_bruta - total_gastos, 2)
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
return {
|
return jsonify({
|
||||||
'period': {'year': year, 'month': month},
|
'period': {'year': year, 'month': month},
|
||||||
'ingresos': {'items': ingresos, 'total': round(total_ingresos, 2)},
|
'ingresos': {'items': ingresos, 'total': round(total_ingresos, 2)},
|
||||||
'costos': {'items': costos, 'total': round(total_costos, 2)},
|
'costos': {'items': costos, 'total': round(total_costos, 2)},
|
||||||
'utilidad_bruta': utilidad_bruta,
|
'utilidad_bruta': utilidad_bruta,
|
||||||
'gastos': {'items': gastos, 'total': round(total_gastos, 2)},
|
'gastos': {'items': gastos, 'total': round(total_gastos, 2)},
|
||||||
'utilidad_neta': utilidad_neta,
|
'utilidad_neta': utilidad_neta,
|
||||||
}
|
})
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/income-statement', methods=['GET'])
|
|
||||||
@require_auth('accounting.view')
|
|
||||||
def income_statement():
|
|
||||||
"""Estado de resultados for a period.
|
|
||||||
|
|
||||||
Query params: year, month
|
|
||||||
|
|
||||||
Groups: Ingresos (400), Costos (500), Gastos (600)
|
|
||||||
Result: Ingresos - Costos - Gastos = Utilidad/Perdida
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
|
|
||||||
year = int(request.args.get('year', date.today().year))
|
|
||||||
month = int(request.args.get('month', date.today().month))
|
|
||||||
|
|
||||||
data = _income_statement_data(conn, year, month)
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
return jsonify(data)
|
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/income-statement/export', methods=['GET'])
|
|
||||||
@require_auth('accounting.view')
|
|
||||||
def export_income_statement():
|
|
||||||
"""Export the estado de resultados as CSV (default) or JSON.
|
|
||||||
|
|
||||||
Query params: year, month, format=csv|json
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
year = int(request.args.get('year', date.today().year))
|
|
||||||
month = int(request.args.get('month', date.today().month))
|
|
||||||
fmt = request.args.get('format', 'csv').lower()
|
|
||||||
|
|
||||||
data = _income_statement_data(conn, year, month)
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if fmt == 'json':
|
|
||||||
return jsonify(data)
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for item in data['ingresos']['items']:
|
|
||||||
rows.append(['Ingresos', item['code'], item['name'], item['amount']])
|
|
||||||
rows.append(['Ingresos', '', 'Total ingresos', data['ingresos']['total']])
|
|
||||||
for item in data['costos']['items']:
|
|
||||||
rows.append(['Costos', item['code'], item['name'], item['amount']])
|
|
||||||
rows.append(['Costos', '', 'Total costos', data['costos']['total']])
|
|
||||||
rows.append(['', '', 'Utilidad bruta', data['utilidad_bruta']])
|
|
||||||
for item in data['gastos']['items']:
|
|
||||||
rows.append(['Gastos', item['code'], item['name'], item['amount']])
|
|
||||||
rows.append(['Gastos', '', 'Total gastos', data['gastos']['total']])
|
|
||||||
rows.append(['', '', 'Utilidad neta', data['utilidad_neta']])
|
|
||||||
|
|
||||||
return _csv_response(
|
|
||||||
rows,
|
|
||||||
['Seccion', 'Cuenta', 'Nombre', 'Importe'],
|
|
||||||
f'estado_resultados_{year}-{month:02d}.csv'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/entries/export', methods=['GET'])
|
|
||||||
@require_auth('accounting.view')
|
|
||||||
def export_entries():
|
|
||||||
"""Export the polizas (journal entries) of a period with their lines.
|
|
||||||
|
|
||||||
Query params: date_from, date_to, type, reference_type, format=csv|json
|
|
||||||
|
|
||||||
CSV output has one row per journal entry line (accountant-friendly).
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
fmt = request.args.get('format', 'csv').lower()
|
|
||||||
date_from = request.args.get('date_from')
|
|
||||||
date_to = request.args.get('date_to')
|
|
||||||
entry_type = request.args.get('type')
|
|
||||||
ref_type = request.args.get('reference_type')
|
|
||||||
|
|
||||||
where_clauses = ["e.status = 'posted'"]
|
|
||||||
params = []
|
|
||||||
if date_from:
|
|
||||||
where_clauses.append("e.date >= %s")
|
|
||||||
params.append(date_from)
|
|
||||||
if date_to:
|
|
||||||
where_clauses.append("e.date <= %s")
|
|
||||||
params.append(date_to)
|
|
||||||
if entry_type:
|
|
||||||
where_clauses.append("e.type = %s")
|
|
||||||
params.append(entry_type)
|
|
||||||
if ref_type:
|
|
||||||
where_clauses.append("e.reference_type = %s")
|
|
||||||
params.append(ref_type)
|
|
||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT e.entry_number, e.date, e.type, e.description,
|
|
||||||
e.reference_type, e.reference_id,
|
|
||||||
a.code, a.name, l.description, l.debit, l.credit
|
|
||||||
FROM journal_entry_lines l
|
|
||||||
JOIN journal_entries e ON l.journal_entry_id = e.id
|
|
||||||
JOIN accounts a ON l.account_id = a.id
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY e.date, e.entry_number, l.id
|
|
||||||
""", params)
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
rows.append({
|
|
||||||
'entry_number': r[0],
|
|
||||||
'date': r[1].isoformat() if r[1] else None,
|
|
||||||
'type': r[2],
|
|
||||||
'entry_description': r[3],
|
|
||||||
'reference_type': r[4],
|
|
||||||
'reference_id': r[5],
|
|
||||||
'account_code': r[6],
|
|
||||||
'account_name': r[7],
|
|
||||||
'line_description': r[8],
|
|
||||||
'debit': float(r[9] or 0),
|
|
||||||
'credit': float(r[10] or 0),
|
|
||||||
})
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if fmt == 'json':
|
|
||||||
return jsonify({'data': rows, 'count': len(rows)})
|
|
||||||
|
|
||||||
return _csv_response(
|
|
||||||
[[r['entry_number'], r['date'], r['type'], r['entry_description'],
|
|
||||||
r['reference_type'], r['reference_id'], r['account_code'],
|
|
||||||
r['account_name'], r['line_description'], r['debit'], r['credit']]
|
|
||||||
for r in rows],
|
|
||||||
['Poliza', 'Fecha', 'Tipo', 'Descripcion poliza', 'Referencia', 'Ref ID',
|
|
||||||
'Cuenta', 'Nombre cuenta', 'Descripcion', 'Debe', 'Haber'],
|
|
||||||
f'polizas_{date.today().isoformat()}.csv'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/diot/export', methods=['GET'])
|
|
||||||
@require_auth('accounting.view')
|
|
||||||
def export_diot():
|
|
||||||
"""Export the DIOT (operations with third parties) for a fiscal month.
|
|
||||||
|
|
||||||
Data source: purchase orders received during the month, grouped by
|
|
||||||
supplier. All amounts are expressed in the purchase-order currency (MXN
|
|
||||||
for locally created orders).
|
|
||||||
|
|
||||||
Query params: year, month, format=csv|json
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
year = int(request.args.get('year', date.today().year))
|
|
||||||
month = int(request.args.get('month', date.today().month))
|
|
||||||
fmt = request.args.get('format', 'csv').lower()
|
|
||||||
|
|
||||||
period_start, period_end = _period_bounds(year, month)
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT s.rfc, s.name,
|
|
||||||
COALESCE(SUM(po.subtotal), 0) as base,
|
|
||||||
COALESCE(SUM(po.tax_total), 0) as iva,
|
|
||||||
COALESCE(SUM(po.total), 0) as total
|
|
||||||
FROM purchase_orders po
|
|
||||||
JOIN suppliers s ON s.id = po.supplier_id
|
|
||||||
WHERE po.status NOT IN ('draft', 'cancelled')
|
|
||||||
AND po.received_at >= %s AND po.received_at < %s
|
|
||||||
GROUP BY s.rfc, s.name
|
|
||||||
ORDER BY s.name
|
|
||||||
""", (period_start, period_end))
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
rows.append({
|
|
||||||
'rfc': r[0] or '',
|
|
||||||
'supplier_name': r[1],
|
|
||||||
'tipo_tercero': '04', # Proveedor
|
|
||||||
'tipo_operacion': '02', # Compra de bienes
|
|
||||||
'base': round(float(r[2]), 2),
|
|
||||||
'iva_16': round(float(r[3]), 2),
|
|
||||||
'iva_retenido': 0.0,
|
|
||||||
'total': round(float(r[4]), 2),
|
|
||||||
})
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if fmt == 'json':
|
|
||||||
return jsonify({'data': rows, 'period': {'year': year, 'month': month},
|
|
||||||
'count': len(rows)})
|
|
||||||
|
|
||||||
return _csv_response(
|
|
||||||
[[r['tipo_tercero'], r['tipo_operacion'], r['rfc'], r['supplier_name'],
|
|
||||||
r['base'], r['iva_16'], r['iva_retenido'], r['total']] for r in rows],
|
|
||||||
['Tipo de tercero', 'Tipo de operacion', 'RFC', 'Nombre', 'Base',
|
|
||||||
'IVA 16%', 'IVA retenido', 'Monto total'],
|
|
||||||
f'diot_{year}-{month:02d}.csv'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/balance-sheet', methods=['GET'])
|
@accounting_bp.route('/balance-sheet', methods=['GET'])
|
||||||
@@ -823,141 +565,21 @@ def balance_sheet():
|
|||||||
@accounting_bp.route('/aging', methods=['GET'])
|
@accounting_bp.route('/aging', methods=['GET'])
|
||||||
@require_auth('accounting.view')
|
@require_auth('accounting.view')
|
||||||
def aging_report():
|
def aging_report():
|
||||||
"""Antiguedad de saldos.
|
"""Antiguedad de saldos (accounts receivable aging).
|
||||||
|
|
||||||
Returns individual credit sales with outstanding balance when type=receivable
|
Groups outstanding credit sales by age:
|
||||||
(default), or purchase orders payable to suppliers when type=payable.
|
- Corriente (not yet due)
|
||||||
|
- 1-30 dias
|
||||||
|
- 31-60 dias
|
||||||
|
- 61-90 dias
|
||||||
|
- 90+ dias
|
||||||
"""
|
"""
|
||||||
report_type = request.args.get('type', 'receivable')
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
if report_type == 'payable':
|
|
||||||
paid_by_po = _supplier_paid_totals(cur)
|
|
||||||
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
|
|
||||||
cur.execute("""
|
|
||||||
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
|
|
||||||
po.status, s.id, s.name
|
|
||||||
FROM purchase_orders po
|
|
||||||
JOIN suppliers s ON s.id = po.supplier_id
|
|
||||||
WHERE po.status NOT IN ('paid', 'cancelled')
|
|
||||||
ORDER BY po.created_at DESC
|
|
||||||
""")
|
|
||||||
for r in cur.fetchall():
|
|
||||||
po_id = r[0]
|
|
||||||
invoice = r[1] or f'OC-{po_id}'
|
|
||||||
total = float(r[2]) if r[2] else 0
|
|
||||||
created_at = r[3]
|
|
||||||
expected_date = r[4]
|
|
||||||
status = r[5]
|
|
||||||
vendor_name = r[7]
|
|
||||||
|
|
||||||
paid = paid_by_po.get(po_id, 0.0)
|
|
||||||
balance = round(total - paid, 2)
|
|
||||||
if balance <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
|
|
||||||
if days_overdue > 0:
|
|
||||||
po_status = 'overdue'
|
|
||||||
label = 'Vencida'
|
|
||||||
elif paid > 0:
|
|
||||||
po_status = 'partial'
|
|
||||||
label = 'Parcial'
|
|
||||||
else:
|
|
||||||
po_status = 'pending'
|
|
||||||
label = 'Pendiente'
|
|
||||||
|
|
||||||
rows.append({
|
|
||||||
'po_id': po_id,
|
|
||||||
'invoice': invoice,
|
|
||||||
'vendor_name': vendor_name,
|
|
||||||
'issue_date': created_at.isoformat() if created_at else None,
|
|
||||||
'due_date': expected_date.isoformat() if expected_date else None,
|
|
||||||
'total': total,
|
|
||||||
'paid': paid,
|
|
||||||
'balance': balance,
|
|
||||||
'days_overdue': days_overdue,
|
|
||||||
'status': po_status,
|
|
||||||
'status_label': label,
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
# Accounts receivable: credit sales to customers
|
|
||||||
cur.execute("""
|
|
||||||
SELECT s.id, s.total, s.created_at, s.status,
|
|
||||||
c.id, c.name, c.rfc,
|
|
||||||
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
|
|
||||||
FROM sales s
|
|
||||||
JOIN customers c ON c.id = s.customer_id
|
|
||||||
WHERE s.sale_type = 'credit'
|
|
||||||
AND s.status = 'completed'
|
|
||||||
ORDER BY s.created_at DESC
|
|
||||||
""")
|
|
||||||
for r in cur.fetchall():
|
|
||||||
sale_id = r[0]
|
|
||||||
total = float(r[1]) if r[1] else 0
|
|
||||||
created_at = r[2]
|
|
||||||
status = r[3]
|
|
||||||
customer_name = r[5]
|
|
||||||
payments_total = float(r[7]) if r[7] else 0
|
|
||||||
paid = payments_total
|
|
||||||
balance = round(total - paid, 2)
|
|
||||||
|
|
||||||
if balance <= 0:
|
|
||||||
continue
|
|
||||||
|
|
||||||
due_date = created_at + timedelta(days=30) if created_at else None
|
|
||||||
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
|
|
||||||
|
|
||||||
if days_overdue > 0:
|
|
||||||
sale_status = 'overdue'
|
|
||||||
label = 'Vencida'
|
|
||||||
elif paid > 0:
|
|
||||||
sale_status = 'partial'
|
|
||||||
label = 'Parcial'
|
|
||||||
else:
|
|
||||||
sale_status = 'pending'
|
|
||||||
label = 'Vigente'
|
|
||||||
|
|
||||||
rows.append({
|
|
||||||
'sale_id': sale_id,
|
|
||||||
'invoice': f'VTA-{sale_id}',
|
|
||||||
'customer_name': customer_name,
|
|
||||||
'issue_date': created_at.isoformat() if created_at else None,
|
|
||||||
'due_date': due_date.isoformat() if due_date else None,
|
|
||||||
'total': total,
|
|
||||||
'paid': paid,
|
|
||||||
'balance': balance,
|
|
||||||
'days_overdue': days_overdue,
|
|
||||||
'status': sale_status,
|
|
||||||
'status_label': label,
|
|
||||||
})
|
|
||||||
|
|
||||||
totals = {
|
|
||||||
'count': len(rows),
|
|
||||||
'total': round(sum(r['total'] for r in rows), 2),
|
|
||||||
'paid': round(sum(r['paid'] for r in rows), 2),
|
|
||||||
'balance': round(sum(r['balance'] for r in rows), 2),
|
|
||||||
}
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'data': rows, 'totals': totals})
|
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/aging-summary', methods=['GET'])
|
|
||||||
@require_auth('accounting.view')
|
|
||||||
def aging_summary():
|
|
||||||
"""Customer-level accounts receivable summary (used by reports)."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
|
SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance,
|
||||||
s.id as sale_id,
|
s.id as sale_id, s.total, s.created_at,
|
||||||
s.total - COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as balance,
|
|
||||||
s.created_at,
|
|
||||||
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
|
EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN sales s ON s.customer_id = c.id
|
JOIN sales s ON s.customer_id = c.id
|
||||||
@@ -970,11 +592,6 @@ def aging_summary():
|
|||||||
customers = {}
|
customers = {}
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
cust_id = r[0]
|
cust_id = r[0]
|
||||||
balance = round(float(r[6]) if r[6] else 0, 2)
|
|
||||||
if balance <= 0:
|
|
||||||
continue
|
|
||||||
days = r[8] or 0
|
|
||||||
|
|
||||||
if cust_id not in customers:
|
if cust_id not in customers:
|
||||||
customers[cust_id] = {
|
customers[cust_id] = {
|
||||||
'id': r[0], 'name': r[1], 'rfc': r[2],
|
'id': r[0], 'name': r[1], 'rfc': r[2],
|
||||||
@@ -984,25 +601,29 @@ def aging_summary():
|
|||||||
'total': 0,
|
'total': 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if days <= 0:
|
amount = float(r[6]) if r[6] else 0
|
||||||
customers[cust_id]['corriente'] += balance
|
days = r[8] or 0
|
||||||
elif days <= 30:
|
|
||||||
customers[cust_id]['d1_30'] += balance
|
|
||||||
elif days <= 60:
|
|
||||||
customers[cust_id]['d31_60'] += balance
|
|
||||||
elif days <= 90:
|
|
||||||
customers[cust_id]['d61_90'] += balance
|
|
||||||
else:
|
|
||||||
customers[cust_id]['d90_plus'] += balance
|
|
||||||
|
|
||||||
customers[cust_id]['total'] += balance
|
if days <= 0:
|
||||||
|
customers[cust_id]['corriente'] += amount
|
||||||
|
elif days <= 30:
|
||||||
|
customers[cust_id]['d1_30'] += amount
|
||||||
|
elif days <= 60:
|
||||||
|
customers[cust_id]['d31_60'] += amount
|
||||||
|
elif days <= 90:
|
||||||
|
customers[cust_id]['d61_90'] += amount
|
||||||
|
else:
|
||||||
|
customers[cust_id]['d90_plus'] += amount
|
||||||
|
|
||||||
|
customers[cust_id]['total'] += amount
|
||||||
|
|
||||||
result = list(customers.values())
|
result = list(customers.values())
|
||||||
|
# Round all amounts
|
||||||
for c in result:
|
for c in result:
|
||||||
c['credit_balance'] = round(c['total'], 2)
|
|
||||||
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
|
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
|
||||||
c[key] = round(c[key], 2)
|
c[key] = round(c[key], 2)
|
||||||
|
|
||||||
|
# Totals row
|
||||||
totals = {
|
totals = {
|
||||||
'corriente': round(sum(c['corriente'] for c in result), 2),
|
'corriente': round(sum(c['corriente'] for c in result), 2),
|
||||||
'd1_30': round(sum(c['d1_30'] for c in result), 2),
|
'd1_30': round(sum(c['d1_30'] for c in result), 2),
|
||||||
@@ -1010,7 +631,6 @@ def aging_summary():
|
|||||||
'd61_90': round(sum(c['d61_90'] for c in result), 2),
|
'd61_90': round(sum(c['d61_90'] for c in result), 2),
|
||||||
'd90_plus': round(sum(c['d90_plus'] for c in result), 2),
|
'd90_plus': round(sum(c['d90_plus'] for c in result), 2),
|
||||||
'total': round(sum(c['total'] for c in result), 2),
|
'total': round(sum(c['total'] for c in result), 2),
|
||||||
'count': len(result),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -1018,256 +638,6 @@ def aging_summary():
|
|||||||
return jsonify({'data': result, 'totals': totals})
|
return jsonify({'data': result, 'totals': totals})
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/aging/export', methods=['GET'])
|
|
||||||
@require_auth('accounting.view')
|
|
||||||
def export_aging():
|
|
||||||
"""Export receivables or payables to CSV/PDF."""
|
|
||||||
report_type = request.args.get('type', 'receivable')
|
|
||||||
fmt = request.args.get('format', 'csv').lower()
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
if report_type == 'payable':
|
|
||||||
# Payables always CSV for now
|
|
||||||
paid_by_po = _supplier_paid_totals(cur)
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output)
|
|
||||||
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
|
|
||||||
'Total', 'Pagado', 'Saldo', 'Dias vencido', 'Estado'])
|
|
||||||
cur.execute("""
|
|
||||||
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
|
|
||||||
po.status, s.name
|
|
||||||
FROM purchase_orders po
|
|
||||||
JOIN suppliers s ON s.id = po.supplier_id
|
|
||||||
WHERE po.status NOT IN ('paid', 'cancelled')
|
|
||||||
ORDER BY po.created_at DESC
|
|
||||||
""")
|
|
||||||
for r in cur.fetchall():
|
|
||||||
po_id = r[0]
|
|
||||||
invoice = r[1] or f'OC-{po_id}'
|
|
||||||
total = float(r[2]) if r[2] else 0
|
|
||||||
created_at = r[3]
|
|
||||||
expected = r[4]
|
|
||||||
status = r[5]
|
|
||||||
vendor = r[6]
|
|
||||||
paid = paid_by_po.get(po_id, 0.0)
|
|
||||||
balance = round(total - paid, 2)
|
|
||||||
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
|
|
||||||
status_label = 'Vencida' if days_overdue > 0 else ('Parcial' if paid > 0 else 'Pendiente')
|
|
||||||
writer.writerow([
|
|
||||||
invoice, vendor,
|
|
||||||
created_at.strftime('%Y-%m-%d') if created_at else '',
|
|
||||||
expected.strftime('%Y-%m-%d') if expected else '',
|
|
||||||
total, paid, balance, days_overdue, status_label
|
|
||||||
])
|
|
||||||
cur.close(); conn.close()
|
|
||||||
csv_data = output.getvalue()
|
|
||||||
output.close()
|
|
||||||
return Response(
|
|
||||||
'\ufeff' + csv_data,
|
|
||||||
mimetype='text/csv; charset=utf-8',
|
|
||||||
headers={'Content-Disposition': f'attachment; filename=cuentas_por_pagar_{date.today().isoformat()}.csv'}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Receivables: CSV or PDF
|
|
||||||
cur.execute("""
|
|
||||||
SELECT s.id, s.subtotal, s.tax_total, s.total, s.created_at,
|
|
||||||
c.name,
|
|
||||||
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
|
|
||||||
FROM sales s
|
|
||||||
JOIN customers c ON c.id = s.customer_id
|
|
||||||
WHERE s.sale_type = 'credit'
|
|
||||||
AND s.status = 'completed'
|
|
||||||
ORDER BY s.created_at DESC
|
|
||||||
""")
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
sale_id = r[0]
|
|
||||||
subtotal = float(r[1]) if r[1] else 0
|
|
||||||
tax = float(r[2]) if r[2] else 0
|
|
||||||
total = float(r[3]) if r[3] else 0
|
|
||||||
created_at = r[4]
|
|
||||||
customer_name = r[5]
|
|
||||||
payments_total = float(r[6]) if r[6] else 0
|
|
||||||
balance = round(total - payments_total, 2)
|
|
||||||
if balance <= 0:
|
|
||||||
continue
|
|
||||||
rows.append({
|
|
||||||
'date': created_at.strftime('%d/%m/%Y') if created_at else '',
|
|
||||||
'customer': customer_name,
|
|
||||||
'subtotal': subtotal,
|
|
||||||
'tax': tax,
|
|
||||||
'total': total,
|
|
||||||
'balance': balance,
|
|
||||||
})
|
|
||||||
|
|
||||||
cur.close(); conn.close()
|
|
||||||
|
|
||||||
if fmt == 'pdf':
|
|
||||||
from fpdf import FPDF
|
|
||||||
|
|
||||||
class ReceivablesPDF(FPDF):
|
|
||||||
def header(self):
|
|
||||||
self.set_font('Arial', 'B', 14)
|
|
||||||
self.cell(0, 10, 'Reporte de Ventas por Cobrar', 0, 1, 'C')
|
|
||||||
self.set_font('Arial', '', 10)
|
|
||||||
self.cell(0, 6, f'Generado: {date.today().strftime("%d/%m/%Y")}', 0, 1, 'C')
|
|
||||||
self.ln(4)
|
|
||||||
|
|
||||||
def footer(self):
|
|
||||||
self.set_y(-15)
|
|
||||||
self.set_font('Arial', 'I', 8)
|
|
||||||
self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C')
|
|
||||||
|
|
||||||
pdf = ReceivablesPDF('L', 'mm', 'A4')
|
|
||||||
pdf.add_page()
|
|
||||||
pdf.set_font('Arial', 'B', 10)
|
|
||||||
pdf.set_fill_color(230, 230, 230)
|
|
||||||
|
|
||||||
col_widths = [30, 95, 35, 30, 35, 35]
|
|
||||||
headers = ['FECHA', 'CLIENTE', 'SUBTOTAL', 'IVA', 'TOTAL', 'SALDO']
|
|
||||||
for i, h in enumerate(headers):
|
|
||||||
pdf.cell(col_widths[i], 10, h, 1, 0, 'C', True)
|
|
||||||
pdf.ln()
|
|
||||||
|
|
||||||
pdf.set_font('Arial', '', 9)
|
|
||||||
totals = {'subtotal': 0, 'tax': 0, 'total': 0, 'balance': 0}
|
|
||||||
for row in rows:
|
|
||||||
pdf.cell(col_widths[0], 8, row['date'], 1, 0, 'C')
|
|
||||||
pdf.cell(col_widths[1], 8, row['customer'][:50], 1, 0, 'L')
|
|
||||||
pdf.cell(col_widths[2], 8, f"{row['subtotal']:.2f}", 1, 0, 'R')
|
|
||||||
pdf.cell(col_widths[3], 8, f"{row['tax']:.2f}", 1, 0, 'R')
|
|
||||||
pdf.cell(col_widths[4], 8, f"{row['total']:.2f}", 1, 0, 'R')
|
|
||||||
pdf.cell(col_widths[5], 8, f"{row['balance']:.2f}", 1, 1, 'R')
|
|
||||||
totals['subtotal'] += row['subtotal']
|
|
||||||
totals['tax'] += row['tax']
|
|
||||||
totals['total'] += row['total']
|
|
||||||
totals['balance'] += row['balance']
|
|
||||||
|
|
||||||
pdf.set_font('Arial', 'B', 9)
|
|
||||||
pdf.cell(col_widths[0] + col_widths[1], 8, 'TOTAL', 1, 0, 'R', True)
|
|
||||||
pdf.cell(col_widths[2], 8, f"{totals['subtotal']:.2f}", 1, 0, 'R', True)
|
|
||||||
pdf.cell(col_widths[3], 8, f"{totals['tax']:.2f}", 1, 0, 'R', True)
|
|
||||||
pdf.cell(col_widths[4], 8, f"{totals['total']:.2f}", 1, 0, 'R', True)
|
|
||||||
pdf.cell(col_widths[5], 8, f"{totals['balance']:.2f}", 1, 1, 'R', True)
|
|
||||||
|
|
||||||
pdf_bytes = bytes(pdf.output(dest='S'))
|
|
||||||
return Response(
|
|
||||||
pdf_bytes,
|
|
||||||
mimetype='application/pdf',
|
|
||||||
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.pdf'}
|
|
||||||
)
|
|
||||||
|
|
||||||
# CSV fallback
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output)
|
|
||||||
writer.writerow(['Fecha', 'Cliente', 'Subtotal', 'IVA', 'Total', 'Saldo'])
|
|
||||||
for row in rows:
|
|
||||||
writer.writerow([row['date'], row['customer'], row['subtotal'], row['tax'], row['total'], row['balance']])
|
|
||||||
csv_data = output.getvalue()
|
|
||||||
output.close()
|
|
||||||
return Response(
|
|
||||||
'\ufeff' + csv_data,
|
|
||||||
mimetype='text/csv; charset=utf-8',
|
|
||||||
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.csv'}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Accounts Payable payments ─────────────────────
|
|
||||||
|
|
||||||
@accounting_bp.route('/supplier-payments', methods=['POST'])
|
|
||||||
@require_auth('accounting.create')
|
|
||||||
def create_supplier_payment():
|
|
||||||
"""Register a payment to a supplier against a purchase order (CxP).
|
|
||||||
|
|
||||||
Body: {po_id: int, amount: float, payment_method: str, reference: str}
|
|
||||||
"""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
if not data.get('po_id'):
|
|
||||||
return jsonify({'error': 'po_id is required'}), 400
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
result = record_supplier_payment_entry(conn, data)
|
|
||||||
|
|
||||||
log_action(conn, 'SUPPLIER_PAYMENT', 'purchase_order', result['po_id'],
|
|
||||||
new_value={'amount': float(data.get('amount', 0)),
|
|
||||||
'payment_id': result['payment_id'],
|
|
||||||
'status': result['status']})
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
return jsonify(result), 201
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
conn.rollback()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'error': str(e)}), 400
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/supplier-payments', methods=['GET'])
|
|
||||||
@require_auth('accounting.view')
|
|
||||||
def list_supplier_payments():
|
|
||||||
"""List supplier payments. Query params: po_id, supplier_id, page, per_page."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute("SELECT to_regclass('public.supplier_payments')")
|
|
||||||
if not cur.fetchone()[0]:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'data': [], 'pagination': {'page': 1, 'per_page': 50, 'total': 0, 'total_pages': 0}})
|
|
||||||
|
|
||||||
page = int(request.args.get('page', 1))
|
|
||||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
|
||||||
|
|
||||||
where_clauses = ["1=1"]
|
|
||||||
params = []
|
|
||||||
if request.args.get('po_id'):
|
|
||||||
where_clauses.append("sp.po_id = %s")
|
|
||||||
params.append(int(request.args.get('po_id')))
|
|
||||||
if request.args.get('supplier_id'):
|
|
||||||
where_clauses.append("sp.supplier_id = %s")
|
|
||||||
params.append(int(request.args.get('supplier_id')))
|
|
||||||
where = " AND ".join(where_clauses)
|
|
||||||
|
|
||||||
cur.execute(f"SELECT count(*) FROM supplier_payments sp WHERE {where}", params)
|
|
||||||
total = cur.fetchone()[0]
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT sp.id, sp.po_id, sp.supplier_id, sp.amount, sp.payment_method,
|
|
||||||
sp.reference, sp.created_at, s.name as supplier_name
|
|
||||||
FROM supplier_payments sp
|
|
||||||
LEFT JOIN suppliers s ON sp.supplier_id = s.id
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY sp.created_at DESC
|
|
||||||
LIMIT %s OFFSET %s
|
|
||||||
""", params + [per_page, (page - 1) * per_page])
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
rows.append({
|
|
||||||
'id': r[0], 'po_id': r[1], 'supplier_id': r[2],
|
|
||||||
'amount': float(r[3]) if r[3] else 0,
|
|
||||||
'payment_method': r[4], 'reference': r[5],
|
|
||||||
'created_at': str(r[6]) if r[6] else None,
|
|
||||||
'supplier_name': r[7],
|
|
||||||
})
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
total_pages = (total + per_page - 1) // per_page
|
|
||||||
return jsonify({
|
|
||||||
'data': rows,
|
|
||||||
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Fiscal Periods ────────────────────────────────
|
# ─── Fiscal Periods ────────────────────────────────
|
||||||
|
|
||||||
@accounting_bp.route('/periods', methods=['GET'])
|
@accounting_bp.route('/periods', methods=['GET'])
|
||||||
@@ -1374,7 +744,7 @@ def close_period():
|
|||||||
|
|
||||||
|
|
||||||
@accounting_bp.route('/stats', methods=['GET'])
|
@accounting_bp.route('/stats', methods=['GET'])
|
||||||
@require_auth('accounting.view')
|
@require_auth('accounting.read')
|
||||||
def api_accounting_stats():
|
def api_accounting_stats():
|
||||||
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import jwt
|
|||||||
import bcrypt
|
import bcrypt
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from flask import Blueprint, request, jsonify, g, make_response
|
from flask import Blueprint, request, jsonify, g
|
||||||
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
||||||
from tenant_db import get_tenant_conn, get_master_conn
|
from tenant_db import get_tenant_conn, get_master_conn
|
||||||
from middleware import require_auth
|
|
||||||
|
|
||||||
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
||||||
|
|
||||||
@@ -60,7 +59,6 @@ def login_pin():
|
|||||||
# Subdomain-resolved tenant takes priority over body param
|
# Subdomain-resolved tenant takes priority over body param
|
||||||
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
|
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
|
||||||
pin = data.get('pin', '')
|
pin = data.get('pin', '')
|
||||||
employee_id = data.get('employee_id')
|
|
||||||
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
|
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
|
||||||
# Optional: branch_id from the device for PIN search optimization
|
# Optional: branch_id from the device for PIN search optimization
|
||||||
device_branch_id = data.get('branch_id')
|
device_branch_id = data.get('branch_id')
|
||||||
@@ -91,26 +89,39 @@ def login_pin():
|
|||||||
|
|
||||||
matched_employee = None
|
matched_employee = None
|
||||||
|
|
||||||
if not employee_id:
|
if device_branch_id:
|
||||||
_record_attempt(device_id, False)
|
# Try branch employees first (fast path for known devices)
|
||||||
cur.close()
|
cur.execute("""
|
||||||
conn.close()
|
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
|
||||||
return jsonify({'error': 'Empleado no seleccionado'}), 400
|
FROM employees e
|
||||||
|
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
|
||||||
|
|
||||||
# Verify the PIN only for the selected employee.
|
if not matched_employee:
|
||||||
cur.execute("""
|
# Fallback: check ALL active employees (covers owners, admins, roaming staff)
|
||||||
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
|
cur.execute("""
|
||||||
FROM employees e
|
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
|
||||||
WHERE e.id = %s AND e.is_active = true AND e.pin IS NOT NULL
|
FROM employees e
|
||||||
""", (employee_id,))
|
WHERE e.is_active = true AND e.pin IS NOT NULL
|
||||||
emp = cur.fetchone()
|
""")
|
||||||
if emp:
|
employees = 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()):
|
for emp in employees:
|
||||||
matched_employee = {
|
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
|
||||||
'id': emp_id, 'name': emp_name, 'role': emp_role,
|
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
|
||||||
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
|
matched_employee = {
|
||||||
}
|
'id': emp_id, 'name': emp_name, 'role': emp_role,
|
||||||
|
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
if not matched_employee:
|
if not matched_employee:
|
||||||
_record_attempt(device_id, False)
|
_record_attempt(device_id, False)
|
||||||
@@ -146,77 +157,9 @@ def login_pin():
|
|||||||
}
|
}
|
||||||
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||||
|
|
||||||
response = make_response(jsonify({
|
|
||||||
'token': token,
|
|
||||||
'employee': matched_employee,
|
|
||||||
'permissions': permissions
|
|
||||||
}))
|
|
||||||
# Cookie used by server-side route guards; JS can also read it for quick checks.
|
|
||||||
response.set_cookie(
|
|
||||||
'pos_role', matched_employee['role'],
|
|
||||||
path='/pos', samesite='Lax', httponly=False
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/refresh', methods=['POST'])
|
|
||||||
@require_auth()
|
|
||||||
def refresh_token():
|
|
||||||
"""Reissue the JWT with the employee's current permissions from the DB.
|
|
||||||
|
|
||||||
This lets permission changes take effect without forcing a full re-login.
|
|
||||||
The original expiration time is preserved.
|
|
||||||
"""
|
|
||||||
auth_header = request.headers.get('Authorization', '')
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return jsonify({'error': 'Invalid token'}), 401
|
|
||||||
|
|
||||||
tenant_id = payload.get('tenant_id')
|
|
||||||
employee_id = payload.get('employee_id')
|
|
||||||
conn = get_tenant_conn(tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
|
|
||||||
FROM employees e
|
|
||||||
WHERE e.id = %s AND e.is_active = true
|
|
||||||
""",
|
|
||||||
(employee_id,)
|
|
||||||
)
|
|
||||||
emp = cur.fetchone()
|
|
||||||
if not emp:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Employee not found or inactive'}), 404
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
|
|
||||||
(emp[0],)
|
|
||||||
)
|
|
||||||
permissions = [r[0] for r in cur.fetchall()]
|
|
||||||
cur.close(); conn.close()
|
|
||||||
|
|
||||||
new_payload = {
|
|
||||||
'tenant_id': tenant_id,
|
|
||||||
'employee_id': emp[0],
|
|
||||||
'name': emp[1],
|
|
||||||
'role': emp[2],
|
|
||||||
'branch_id': emp[3],
|
|
||||||
'max_discount_pct': float(emp[4]) if emp[4] else 0,
|
|
||||||
'permissions': permissions,
|
|
||||||
'device_id': payload.get('device_id', 'unknown'),
|
|
||||||
'type': 'pos_access',
|
|
||||||
'exp': payload.get('exp'),
|
|
||||||
'iat': datetime.now(timezone.utc),
|
|
||||||
}
|
|
||||||
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'token': token,
|
'token': token,
|
||||||
'employee': {
|
'employee': matched_employee,
|
||||||
'id': emp[0], 'name': emp[1], 'role': emp[2],
|
|
||||||
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
|
|
||||||
},
|
|
||||||
'permissions': permissions
|
'permissions': permissions
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -254,7 +197,7 @@ def list_login_employees(tenant_id=None):
|
|||||||
name = row[1]
|
name = row[1]
|
||||||
parts = name.split()
|
parts = name.split()
|
||||||
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
||||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico', 'driver': 'Repartidor'}
|
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador'}
|
||||||
employees.append({
|
employees.append({
|
||||||
'id': row[0],
|
'id': row[0],
|
||||||
'name': name,
|
'name': name,
|
||||||
|
|||||||
@@ -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
|
# /home/Autopartes/pos/blueprints/cashregister_bp.py
|
||||||
"""Cash register blueprint: open/close register, cash movements, X/Z cuts."""
|
"""Cash register blueprint: open/close register, cash movements, X/Z cuts."""
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
|
|
||||||
logger = logging.getLogger('pos')
|
|
||||||
|
|
||||||
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
||||||
|
|
||||||
|
|
||||||
# Roles expected to operate a cash register even without the explicit pos.sell permission.
|
|
||||||
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
|
|
||||||
|
|
||||||
|
|
||||||
def _can_operate_register():
|
|
||||||
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
|
|
||||||
|
|
||||||
|
|
||||||
def _int_arg(name, default=None, min_value=None, max_value=None):
|
|
||||||
"""Parse an integer query-string argument safely.
|
|
||||||
|
|
||||||
Bad input (e.g. ?per_page=abc) falls back to the default instead of
|
|
||||||
raising ValueError and returning a 500.
|
|
||||||
"""
|
|
||||||
raw = request.args.get(name)
|
|
||||||
if raw is None or raw == '':
|
|
||||||
value = default
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
value = int(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
value = default
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if min_value is not None and value < min_value:
|
|
||||||
value = min_value
|
|
||||||
if max_value is not None and value > max_value:
|
|
||||||
value = max_value
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/open', methods=['POST'])
|
@cashregister_bp.route('/open', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def open_register():
|
def open_register():
|
||||||
if not _can_operate_register():
|
|
||||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
|
||||||
"""Open a cash register session.
|
"""Open a cash register session.
|
||||||
|
|
||||||
Body: {register_number: int, opening_amount: float}
|
Body: {register_number: int, opening_amount: float}
|
||||||
@@ -120,7 +84,7 @@ def open_register():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/current', methods=['GET'])
|
@cashregister_bp.route('/current', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def current_register():
|
def current_register():
|
||||||
"""Get the current open register for this employee."""
|
"""Get the current open register for this employee."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -151,10 +115,8 @@ def current_register():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/movement', methods=['POST'])
|
@cashregister_bp.route('/movement', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def cash_movement():
|
def cash_movement():
|
||||||
if not _can_operate_register():
|
|
||||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
|
||||||
"""Record a cash in/out movement with mandatory reason.
|
"""Record a cash in/out movement with mandatory reason.
|
||||||
|
|
||||||
Body: {type: 'in'|'out', amount: float, reason: str}
|
Body: {type: 'in'|'out', amount: float, reason: str}
|
||||||
@@ -246,35 +208,8 @@ def _compute_register_summary(conn, register_id):
|
|||||||
total_sales += amount
|
total_sales += amount
|
||||||
total_sales_count += count
|
total_sales_count += count
|
||||||
|
|
||||||
# Cash received for the expected-cash calculation:
|
# Cash sales specifically (for expected cash calculation)
|
||||||
# - credit sales collect nothing at sale time (their abonos arrive as
|
cash_from_sales = sales_by_method.get('efectivo', {}).get('amount', 0)
|
||||||
# cash movements), so they contribute 0
|
|
||||||
# - cash sales contribute their total (change is discounted below)
|
|
||||||
# - mixed/other sales contribute only their cash portion from sale_payments
|
|
||||||
# Layaways already log every deposit as a cash movement, so their
|
|
||||||
# conversion sale is excluded here to avoid counting that cash twice.
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COALESCE(SUM(
|
|
||||||
CASE
|
|
||||||
WHEN s.sale_type = 'credit' THEN 0
|
|
||||||
WHEN s.payment_method = 'efectivo' THEN s.total
|
|
||||||
ELSE COALESCE(cash.cash_amount, 0)
|
|
||||||
END
|
|
||||||
), 0)
|
|
||||||
FROM sales s
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT sale_id, SUM(amount) AS cash_amount
|
|
||||||
FROM sale_payments
|
|
||||||
WHERE method = 'efectivo'
|
|
||||||
GROUP BY sale_id
|
|
||||||
) cash ON cash.sale_id = s.id
|
|
||||||
WHERE s.register_id = %s AND s.status = 'completed'
|
|
||||||
AND s.id NOT IN (
|
|
||||||
SELECT converted_sale_id FROM layaways
|
|
||||||
WHERE converted_sale_id IS NOT NULL
|
|
||||||
)
|
|
||||||
""", (register_id,))
|
|
||||||
cash_from_sales = float(cur.fetchone()[0])
|
|
||||||
|
|
||||||
# Change given (cash out)
|
# Change given (cash out)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -283,26 +218,15 @@ def _compute_register_summary(conn, register_id):
|
|||||||
""", (register_id,))
|
""", (register_id,))
|
||||||
change_given = float(cur.fetchone()[0])
|
change_given = float(cur.fetchone()[0])
|
||||||
|
|
||||||
# Cash movements. Refunds from devoluciones (reason 'Devolucion #...') are
|
# Cash movements
|
||||||
# kept out of the generic 'out' bucket and subtracted separately below, so
|
|
||||||
# a refund lowers the expected cash exactly once.
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
|
SELECT type, COALESCE(SUM(amount), 0) FROM cash_movements
|
||||||
WHERE register_id = %s
|
WHERE register_id = %s GROUP BY type
|
||||||
AND NOT (type = 'out' AND reason LIKE 'Devolucion #%%')
|
|
||||||
GROUP BY type
|
|
||||||
""", (register_id,))
|
""", (register_id,))
|
||||||
movements = {'in': 0.0, 'out': 0.0}
|
movements = {'in': 0.0, 'out': 0.0}
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
movements[r[0]] = float(r[1])
|
movements[r[0]] = float(r[1])
|
||||||
|
|
||||||
# Cash refunds handed back at this register (devoluciones in cash)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COALESCE(SUM(amount), 0) FROM cash_movements
|
|
||||||
WHERE register_id = %s AND type = 'out' AND reason LIKE 'Devolucion #%%'
|
|
||||||
""", (register_id,))
|
|
||||||
refunds_out = float(cur.fetchone()[0])
|
|
||||||
|
|
||||||
# Cancelled sales
|
# Cancelled sales
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales
|
SELECT COUNT(*), COALESCE(SUM(total), 0) FROM sales
|
||||||
@@ -312,10 +236,9 @@ def _compute_register_summary(conn, register_id):
|
|||||||
cancelled_count = cancelled[0]
|
cancelled_count = cancelled[0]
|
||||||
cancelled_amount = float(cancelled[1])
|
cancelled_amount = float(cancelled[1])
|
||||||
|
|
||||||
# Expected cash = opening + cash sales - change + cash_in - cash_out - refunds
|
# Expected cash = opening + cash sales - change + cash_in - cash_out
|
||||||
expected_cash = round(
|
expected_cash = round(
|
||||||
opening_amount + cash_from_sales - change_given
|
opening_amount + cash_from_sales - change_given + movements['in'] - movements['out'],
|
||||||
+ movements['in'] - movements['out'] - refunds_out,
|
|
||||||
2
|
2
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -342,7 +265,6 @@ def _compute_register_summary(conn, register_id):
|
|||||||
'change_given': round(change_given, 2),
|
'change_given': round(change_given, 2),
|
||||||
'cash_movements_in': round(movements['in'], 2),
|
'cash_movements_in': round(movements['in'], 2),
|
||||||
'cash_movements_out': round(movements['out'], 2),
|
'cash_movements_out': round(movements['out'], 2),
|
||||||
'refunds_out': round(refunds_out, 2),
|
|
||||||
'movement_detail': movement_detail,
|
'movement_detail': movement_detail,
|
||||||
'cancelled_count': cancelled_count,
|
'cancelled_count': cancelled_count,
|
||||||
'cancelled_amount': round(cancelled_amount, 2),
|
'cancelled_amount': round(cancelled_amount, 2),
|
||||||
@@ -351,10 +273,8 @@ def _compute_register_summary(conn, register_id):
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/cut-x', methods=['GET'])
|
@cashregister_bp.route('/cut-x', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def cut_x():
|
def cut_x():
|
||||||
if not _can_operate_register():
|
|
||||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
|
||||||
"""Partial cut (corte X): read-only summary without closing the register."""
|
"""Partial cut (corte X): read-only summary without closing the register."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -381,10 +301,8 @@ def cut_x():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/cut-z', methods=['POST'])
|
@cashregister_bp.route('/cut-z', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth('pos.sell')
|
||||||
def cut_z():
|
def cut_z():
|
||||||
if not _can_operate_register():
|
|
||||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
|
||||||
"""Final cut (corte Z): close the register.
|
"""Final cut (corte Z): close the register.
|
||||||
|
|
||||||
Body: {closing_amount: float} (the amount physically counted in the register)
|
Body: {closing_amount: float} (the amount physically counted in the register)
|
||||||
@@ -396,7 +314,7 @@ def cut_z():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, register_number FROM cash_registers
|
SELECT id FROM cash_registers
|
||||||
WHERE employee_id = %s AND status = 'open'
|
WHERE employee_id = %s AND status = 'open'
|
||||||
""", (g.employee_id,))
|
""", (g.employee_id,))
|
||||||
reg = cur.fetchone()
|
reg = cur.fetchone()
|
||||||
@@ -404,7 +322,7 @@ def cut_z():
|
|||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
return jsonify({'error': 'No open register'}), 400
|
return jsonify({'error': 'No open register'}), 400
|
||||||
|
|
||||||
register_id, register_number = reg
|
register_id = reg[0]
|
||||||
summary = _compute_register_summary(conn, register_id)
|
summary = _compute_register_summary(conn, register_id)
|
||||||
|
|
||||||
expected = summary['expected_cash']
|
expected = summary['expected_cash']
|
||||||
@@ -428,22 +346,6 @@ def cut_z():
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Accounting entry (non-blocking): move the counted cash to Bancos.
|
|
||||||
# The cut is already committed, so a failure here never loses the corte.
|
|
||||||
try:
|
|
||||||
from services.accounting_engine import record_cash_cut_entry
|
|
||||||
record_cash_cut_entry(conn, {
|
|
||||||
'id': register_id,
|
|
||||||
'register_number': register_number,
|
|
||||||
'closing_amount': closing_amount,
|
|
||||||
})
|
|
||||||
conn.commit()
|
|
||||||
except Exception as acc_err:
|
|
||||||
conn.rollback()
|
|
||||||
logger.error(
|
|
||||||
f'Accounting entry for cash cut #{register_id} failed: {acc_err}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Push notification to owner if cash difference > $500
|
# Push notification to owner if cash difference > $500
|
||||||
if abs(difference) > 500:
|
if abs(difference) > 500:
|
||||||
try:
|
try:
|
||||||
@@ -477,23 +379,17 @@ def cut_z():
|
|||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/history', methods=['GET'])
|
@cashregister_bp.route('/history', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('pos.view')
|
||||||
def register_history():
|
def register_history():
|
||||||
"""List closed registers with summary.
|
"""List closed registers with summary.
|
||||||
|
|
||||||
Query params: date_from, date_to, employee_id, page, per_page
|
Query params: date_from, date_to, employee_id, page, per_page
|
||||||
|
|
||||||
Permission rules:
|
|
||||||
- owner/admin and users with pos.view can query any employee.
|
|
||||||
- Cashiers/counters without pos.view can only query their own registers.
|
|
||||||
"""
|
"""
|
||||||
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
page = _int_arg('page', 1, min_value=1)
|
page = int(request.args.get('page', 1))
|
||||||
per_page = _int_arg('per_page', 50, min_value=1, max_value=200)
|
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||||
|
|
||||||
where_clauses = ["cr.status = 'closed'"]
|
where_clauses = ["cr.status = 'closed'"]
|
||||||
params = []
|
params = []
|
||||||
@@ -504,10 +400,7 @@ def register_history():
|
|||||||
|
|
||||||
date_from = request.args.get('date_from')
|
date_from = request.args.get('date_from')
|
||||||
date_to = request.args.get('date_to')
|
date_to = request.args.get('date_to')
|
||||||
employee_id = _int_arg('employee_id')
|
employee_id = request.args.get('employee_id')
|
||||||
|
|
||||||
if not can_view_all:
|
|
||||||
employee_id = g.employee_id
|
|
||||||
|
|
||||||
if date_from:
|
if date_from:
|
||||||
where_clauses.append("cr.closed_at >= %s")
|
where_clauses.append("cr.closed_at >= %s")
|
||||||
@@ -517,7 +410,7 @@ def register_history():
|
|||||||
params.append(date_to)
|
params.append(date_to)
|
||||||
if employee_id:
|
if employee_id:
|
||||||
where_clauses.append("cr.employee_id = %s")
|
where_clauses.append("cr.employee_id = %s")
|
||||||
params.append(employee_id)
|
params.append(int(employee_id))
|
||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
@@ -683,89 +576,3 @@ def daily_summary():
|
|||||||
'movements_out': movements['out'],
|
'movements_out': movements['out'],
|
||||||
'registers': registers,
|
'registers': registers,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
|
|
||||||
@require_auth()
|
|
||||||
def register_sales(register_id):
|
|
||||||
"""List the sales associated with a specific cash register (cash cut).
|
|
||||||
|
|
||||||
Returns sales details and a summary by payment method. Accessible to
|
|
||||||
owners/admins, users with pos.view, or the employee who operated the register.
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
|
|
||||||
(register_id,)
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Register not found'}), 404
|
|
||||||
|
|
||||||
register_employee_id, register_branch_id = row
|
|
||||||
|
|
||||||
can_view = (
|
|
||||||
g.employee_role in ('owner', 'admin') or
|
|
||||||
has_permission('pos.view') or
|
|
||||||
register_employee_id == g.employee_id
|
|
||||||
)
|
|
||||||
if not can_view:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
|
||||||
|
|
||||||
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Register belongs to another branch'}), 403
|
|
||||||
|
|
||||||
where = "s.register_id = %s"
|
|
||||||
params = [register_id]
|
|
||||||
if g.branch_id:
|
|
||||||
where += " AND s.branch_id = %s"
|
|
||||||
params.append(g.branch_id)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
|
||||||
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
|
|
||||||
s.created_at, c.name as customer_name, e.name as employee_name
|
|
||||||
FROM sales s
|
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
|
||||||
LEFT JOIN employees e ON s.employee_id = e.id
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY s.created_at DESC
|
|
||||||
""", params)
|
|
||||||
|
|
||||||
sales = []
|
|
||||||
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
|
|
||||||
for r in cur.fetchall():
|
|
||||||
sale = {
|
|
||||||
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
|
|
||||||
'subtotal': float(r[3]) if r[3] else 0,
|
|
||||||
'discount_total': float(r[4]) if r[4] else 0,
|
|
||||||
'tax_total': float(r[5]) if r[5] else 0,
|
|
||||||
'total': float(r[6]) if r[6] else 0,
|
|
||||||
'amount_paid': float(r[7]) if r[7] else 0,
|
|
||||||
'change_given': float(r[8]) if r[8] else 0,
|
|
||||||
'status': r[9], 'created_at': str(r[10]),
|
|
||||||
'customer_name': r[11], 'employee_name': r[12]
|
|
||||||
}
|
|
||||||
sales.append(sale)
|
|
||||||
if sale['status'] == 'completed':
|
|
||||||
summary['total'] += sale['total']
|
|
||||||
summary['count'] += 1
|
|
||||||
m = sale['payment_method'] or 'Otro'
|
|
||||||
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
|
|
||||||
|
|
||||||
cur.close(); conn.close()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'register_id': register_id,
|
|
||||||
'sales': sales,
|
|
||||||
'summary': {
|
|
||||||
'total': round(summary['total'], 2),
|
|
||||||
'count': summary['count'],
|
|
||||||
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -19,32 +19,11 @@ from tenant_db import get_master_conn, get_tenant_conn
|
|||||||
from services import catalog_service
|
from services import catalog_service
|
||||||
from services.vin_decoder import decode_vin
|
from services.vin_decoder import decode_vin
|
||||||
from services.plate_lookup import search_plate, register_plate, is_valid_mexican_plate, normalize_plate
|
from services.plate_lookup import search_plate, register_plate, is_valid_mexican_plate, normalize_plate
|
||||||
from services.inventory_vehicle_compat import get_inventory_by_vehicle
|
|
||||||
from config import CATALOG_OEM_ENABLED
|
from config import CATALOG_OEM_ENABLED
|
||||||
|
|
||||||
catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog')
|
catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog')
|
||||||
|
|
||||||
|
|
||||||
# ─── Guard: tablas TecDoc retiradas (junio 2026) ──────────────────────────
|
|
||||||
_TECDOC_TABLES_CACHE = {'checked': False, 'exists': False}
|
|
||||||
|
|
||||||
|
|
||||||
def _tecdoc_available(conn):
|
|
||||||
"""True si las tablas TecDoc del catálogo OEM existen en la maestra."""
|
|
||||||
if _TECDOC_TABLES_CACHE['checked']:
|
|
||||||
return _TECDOC_TABLES_CACHE['exists']
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='parts' LIMIT 1")
|
|
||||||
exists = cur.fetchone() is not None
|
|
||||||
cur.close()
|
|
||||||
except Exception:
|
|
||||||
exists = False
|
|
||||||
_TECDOC_TABLES_CACHE['checked'] = True
|
|
||||||
_TECDOC_TABLES_CACHE['exists'] = exists
|
|
||||||
return exists
|
|
||||||
|
|
||||||
|
|
||||||
def _oem_blocked():
|
def _oem_blocked():
|
||||||
"""Return a 403 response if OEM catalog is disabled."""
|
"""Return a 403 response if OEM catalog is disabled."""
|
||||||
if not CATALOG_OEM_ENABLED:
|
if not CATALOG_OEM_ENABLED:
|
||||||
@@ -87,9 +66,6 @@ def _with_conns(fn):
|
|||||||
branch_id = request.args.get('branch_id', g.branch_id)
|
branch_id = request.args.get('branch_id', g.branch_id)
|
||||||
return fn(master, tenant, branch_id)
|
return fn(master, tenant, branch_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_retired_schema_error(e):
|
|
||||||
return jsonify({'data': [], 'oem_disabled': True,
|
|
||||||
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
|
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
if master:
|
if master:
|
||||||
@@ -100,15 +76,6 @@ def _with_conns(fn):
|
|||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
def _is_retired_schema_error(e):
|
|
||||||
"""True si el error es por tablas TecDoc retiradas del esquema."""
|
|
||||||
try:
|
|
||||||
from psycopg2.errors import UndefinedTable, UndefinedColumn
|
|
||||||
return isinstance(e, (UndefinedTable, UndefinedColumn))
|
|
||||||
except Exception:
|
|
||||||
return 'does not exist' in str(e)
|
|
||||||
|
|
||||||
|
|
||||||
def _master_only(fn):
|
def _master_only(fn):
|
||||||
"""Helper: open only master connection for hierarchy endpoints."""
|
"""Helper: open only master connection for hierarchy endpoints."""
|
||||||
master = None
|
master = None
|
||||||
@@ -116,9 +83,6 @@ def _master_only(fn):
|
|||||||
master = get_master_conn()
|
master = get_master_conn()
|
||||||
return fn(master)
|
return fn(master)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_retired_schema_error(e):
|
|
||||||
return jsonify({'data': [], 'oem_disabled': True,
|
|
||||||
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
|
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
if master:
|
if master:
|
||||||
@@ -523,33 +487,6 @@ def decode_vin_route(vin):
|
|||||||
|
|
||||||
# ─── Plate Lookup ───
|
# ─── Plate Lookup ───
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/vehicle-inventory', methods=['GET'])
|
|
||||||
@require_auth('catalog.view')
|
|
||||||
def vehicle_inventory():
|
|
||||||
"""Local inventory items compatible with a vehicle (model_year_engine_id)."""
|
|
||||||
mye_id = request.args.get('mye_id', type=int)
|
|
||||||
if not mye_id:
|
|
||||||
return jsonify({'error': 'mye_id requerido'}), 400
|
|
||||||
tenant = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
rows = get_inventory_by_vehicle(tenant, None, mye_id,
|
|
||||||
branch_id=getattr(g, 'branch_id', None))
|
|
||||||
items = [{
|
|
||||||
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3] or '',
|
|
||||||
'price_1': float(r[4] or 0), 'price_2': float(r[5] or 0),
|
|
||||||
'price_3': float(r[6] or 0), 'image_url': r[7] or '',
|
|
||||||
'stock': int(r[9] or 0),
|
|
||||||
} for r in rows]
|
|
||||||
return jsonify({'data': items})
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
tenant.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@catalog_bp.route('/plate/<plate>', methods=['GET'])
|
@catalog_bp.route('/plate/<plate>', methods=['GET'])
|
||||||
@require_auth('catalog.view')
|
@require_auth('catalog.view')
|
||||||
def plate_lookup(plate):
|
def plate_lookup(plate):
|
||||||
@@ -604,9 +541,6 @@ def plate_lookup(plate):
|
|||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_retired_schema_error(e):
|
|
||||||
return jsonify({'data': [], 'oem_disabled': True,
|
|
||||||
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
|
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
if tenant:
|
if tenant:
|
||||||
@@ -642,9 +576,6 @@ def plate_register():
|
|||||||
)
|
)
|
||||||
return jsonify({'id': rec_id, 'message': 'Placa registrada.'})
|
return jsonify({'id': rec_id, 'message': 'Placa registrada.'})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_retired_schema_error(e):
|
|
||||||
return jsonify({'data': [], 'oem_disabled': True,
|
|
||||||
'error': 'Función no disponible (esquema TecDoc retirado)'}, 200), 200
|
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
if tenant:
|
if tenant:
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
# /home/Autopartes/pos/blueprints/config_bp.py
|
# /home/Autopartes/pos/blueprints/config_bp.py
|
||||||
"""Config blueprint: tenant configuration, branches, theming."""
|
"""Config blueprint: tenant configuration, branches, theming."""
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth, has_permission
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
@@ -10,194 +8,6 @@ from tenant_db import get_tenant_conn
|
|||||||
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
||||||
|
|
||||||
|
|
||||||
# Default permission set per role. Can be overridden per tenant via role_permissions config.
|
|
||||||
_DEFAULT_ROLE_PERMISSIONS = {
|
|
||||||
'owner': [], # owner bypasses permission checks
|
|
||||||
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
|
||||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
|
||||||
'yonke.view', 'yonke.manage',
|
|
||||||
'catalog.view', 'catalog.edit',
|
|
||||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
|
||||||
'invoicing.view', 'invoicing.create',
|
|
||||||
'reports.view', 'reports.financial',
|
|
||||||
'config.view', 'config.edit', 'config.edit_prices',
|
|
||||||
'workshop.view', 'workshop.edit',
|
|
||||||
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
|
|
||||||
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view', 'pos.remission',
|
|
||||||
'catalog.view',
|
|
||||||
'inventory.view', 'inventory.create',
|
|
||||||
'customers.view', 'customers.create',
|
|
||||||
'workshop.view', 'workshop.edit', 'workshop.add_items',
|
|
||||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel'],
|
|
||||||
'counter': ['pos.remission', 'pos.view',
|
|
||||||
'catalog.view',
|
|
||||||
'inventory.view', 'inventory.create',
|
|
||||||
'customers.view', 'customers.create'],
|
|
||||||
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
|
||||||
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
|
||||||
'accountant': ['accounting.view', 'accounting.create',
|
|
||||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
|
||||||
'reports.view', 'reports.financial',
|
|
||||||
'customers.view',
|
|
||||||
'fleet.view'],
|
|
||||||
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
|
|
||||||
'mechanic': ['workshop.view'],
|
|
||||||
'driver': ['pos.remission'],
|
|
||||||
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
|
|
||||||
'customers.view', 'customers.create'],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Statuses used in the workshop kanban. Must stay in sync with workshop.js COLUMNS.
|
|
||||||
_WORKSHOP_STATUSES = [
|
|
||||||
'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar',
|
|
||||||
'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada',
|
|
||||||
'por_entregar', 'entregado', 'por_enviar', 'enviado',
|
|
||||||
'por_facturar', 'facturada', 'por_recolectar', 'cancelada'
|
|
||||||
]
|
|
||||||
|
|
||||||
# Workshop-specific actions configurable per role.
|
|
||||||
_WORKSHOP_ACTIONS = [
|
|
||||||
{'key': 'create_order', 'label': 'Crear órdenes'},
|
|
||||||
{'key': 'edit_order', 'label': 'Editar órdenes'},
|
|
||||||
{'key': 'delete_order', 'label': 'Eliminar órdenes'},
|
|
||||||
{'key': 'assign_mechanic', 'label': 'Asignar mecánico'},
|
|
||||||
{'key': 'add_items', 'label': 'Agregar artículos'},
|
|
||||||
{'key': 'add_labor', 'label': 'Agregar mano de obra'},
|
|
||||||
{'key': 'change_status', 'label': 'Cambiar estatus'},
|
|
||||||
{'key': 'convert_to_sale', 'label': 'Convertir a venta'},
|
|
||||||
{'key': 'convert_to_remission', 'label': 'Generar nota de remisión'},
|
|
||||||
{'key': 'view_customer_data', 'label': 'Ver datos del cliente/vehículo'},
|
|
||||||
{'key': 'view_prices', 'label': 'Ver precios/costos'},
|
|
||||||
{'key': 'view_notes', 'label': 'Ver bitácora'},
|
|
||||||
]
|
|
||||||
|
|
||||||
# Default workshop permissions per role. Admins see everything; restricted roles only work statuses.
|
|
||||||
_DEFAULT_WORKSHOP_PERMISSIONS = {
|
|
||||||
'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
|
|
||||||
'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
|
|
||||||
'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
|
|
||||||
'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
|
|
||||||
'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] not in ('delete_order', 'convert_to_sale')]},
|
|
||||||
'workshop': {
|
|
||||||
'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')],
|
|
||||||
'actions': ['change_status', 'add_labor', 'view_notes']
|
|
||||||
},
|
|
||||||
'mechanic': {
|
|
||||||
'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'],
|
|
||||||
'actions': ['change_status', 'add_labor', 'view_notes']
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_AVAILABLE_PERMISSIONS = [
|
|
||||||
{'module': 'Dashboard', 'permissions': [
|
|
||||||
{'key': 'dashboard.view', 'label': 'Ver Dashboard'},
|
|
||||||
]},
|
|
||||||
{'module': 'Punto de Venta', 'permissions': [
|
|
||||||
{'key': 'pos.sell', 'label': 'Realizar ventas'},
|
|
||||||
{'key': 'pos.view', 'label': 'Ver ventas/cotizaciones'},
|
|
||||||
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
|
|
||||||
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
|
|
||||||
{'key': 'pos.remission', 'label': 'Notas de remisión'},
|
|
||||||
{'key': 'pos.view_cost', 'label': 'Ver costos en POS'},
|
|
||||||
]},
|
|
||||||
{'module': 'Inventario', 'permissions': [
|
|
||||||
{'key': 'inventory.view', 'label': 'Ver inventario'},
|
|
||||||
{'key': 'inventory.create', 'label': 'Crear artículos'},
|
|
||||||
{'key': 'inventory.edit', 'label': 'Editar artículos'},
|
|
||||||
{'key': 'inventory.delete', 'label': 'Eliminar artículos'},
|
|
||||||
{'key': 'inventory.adjust', 'label': 'Ajustar stock'},
|
|
||||||
{'key': 'inventory.transfer', 'label': 'Transferir entre sucursales'},
|
|
||||||
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
|
|
||||||
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
|
|
||||||
]},
|
|
||||||
{'module': 'Yonke', 'permissions': [
|
|
||||||
{'key': 'yonke.view', 'label': 'Ver Yonke (partes usadas)'},
|
|
||||||
{'key': 'yonke.manage', 'label': 'Gestionar Yonke (piezas y consignaciones)'},
|
|
||||||
]},
|
|
||||||
{'module': 'Catálogo', 'permissions': [
|
|
||||||
{'key': 'catalog.view', 'label': 'Ver catálogo'},
|
|
||||||
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
|
|
||||||
]},
|
|
||||||
{'module': 'Clientes', 'permissions': [
|
|
||||||
{'key': 'customers.view', 'label': 'Ver clientes'},
|
|
||||||
{'key': 'customers.create', 'label': 'Crear clientes'},
|
|
||||||
{'key': 'customers.edit', 'label': 'Editar clientes'},
|
|
||||||
{'key': 'customers.delete', 'label': 'Eliminar clientes'},
|
|
||||||
{'key': 'customers.edit_credit', 'label': 'Editar límite de crédito'},
|
|
||||||
]},
|
|
||||||
{'module': 'Taller', 'permissions': [
|
|
||||||
{'key': 'workshop.view', 'label': 'Ver módulo Taller'},
|
|
||||||
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes (deprecado, usar matriz Taller)'},
|
|
||||||
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra (deprecado, usar matriz Taller)'},
|
|
||||||
]},
|
|
||||||
{'module': 'Facturación', 'permissions': [
|
|
||||||
{'key': 'invoicing.view', 'label': 'Ver facturas'},
|
|
||||||
{'key': 'invoicing.create', 'label': 'Crear facturas'},
|
|
||||||
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
|
|
||||||
]},
|
|
||||||
{'module': 'Configuración', 'permissions': [
|
|
||||||
{'key': 'config.view', 'label': 'Ver configuración'},
|
|
||||||
{'key': 'config.edit', 'label': 'Editar configuración'},
|
|
||||||
{'key': 'config.edit_prices', 'label': 'Modificar precios globales'},
|
|
||||||
]},
|
|
||||||
{'module': 'Contabilidad', 'permissions': [
|
|
||||||
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
|
|
||||||
{'key': 'accounting.create', 'label': 'Crear movimientos contables'},
|
|
||||||
]},
|
|
||||||
{'module': 'Reportes', 'permissions': [
|
|
||||||
{'key': 'reports.view', 'label': 'Ver reportes'},
|
|
||||||
{'key': 'reports.financial', 'label': 'Reportes financieros'},
|
|
||||||
]},
|
|
||||||
{'module': 'Flotillas', 'permissions': [
|
|
||||||
{'key': 'fleet.view', 'label': 'Ver flotillas'},
|
|
||||||
{'key': 'fleet.create', 'label': 'Crear flotillas'},
|
|
||||||
{'key': 'fleet.edit', 'label': 'Editar flotillas'},
|
|
||||||
{'key': 'fleet.delete', 'label': 'Eliminar flotillas'},
|
|
||||||
]},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_role_permissions(conn, role):
|
|
||||||
"""Return configured permissions for a role, falling back to defaults."""
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'role_permissions'")
|
|
||||||
row = cur.fetchone()
|
|
||||||
cur.close()
|
|
||||||
if row and row[0]:
|
|
||||||
try:
|
|
||||||
configured = json.loads(row[0])
|
|
||||||
if isinstance(configured, dict) and role in configured:
|
|
||||||
return list(configured.get(role, []))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
|
|
||||||
|
|
||||||
|
|
||||||
def _get_workshop_permissions(conn, role):
|
|
||||||
"""Return configured workshop permissions for a role, falling back to defaults."""
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'workshop_permissions'")
|
|
||||||
row = cur.fetchone()
|
|
||||||
cur.close()
|
|
||||||
if row and row[0]:
|
|
||||||
try:
|
|
||||||
configured = json.loads(row[0])
|
|
||||||
if isinstance(configured, dict) and role in configured:
|
|
||||||
return configured[role]
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
return _DEFAULT_WORKSHOP_PERMISSIONS.get(role, {'statuses': [], 'actions': []})
|
|
||||||
|
|
||||||
|
|
||||||
def _get_all_workshop_permissions(conn):
|
|
||||||
"""Return effective workshop permissions for every known role."""
|
|
||||||
result = {}
|
|
||||||
for role in _DEFAULT_WORKSHOP_PERMISSIONS:
|
|
||||||
result[role] = _get_workshop_permissions(conn, role)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/branches', methods=['GET'])
|
@config_bp.route('/branches', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def list_branches():
|
def list_branches():
|
||||||
@@ -340,58 +150,8 @@ def update_branch(branch_id):
|
|||||||
return jsonify({'ok': True, 'message': 'Branch updated'})
|
return jsonify({'ok': True, 'message': 'Branch updated'})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/branches/<int:branch_id>', methods=['DELETE'])
|
|
||||||
@require_auth('config.edit')
|
|
||||||
def delete_branch(branch_id):
|
|
||||||
"""Hard-delete a branch. Only owner/admin can delete; main branch cannot be deleted.
|
|
||||||
|
|
||||||
Related records keep their data but lose the branch reference; stock and count
|
|
||||||
rows tied exclusively to the branch are removed.
|
|
||||||
"""
|
|
||||||
if g.employee_role not in ('owner', 'admin'):
|
|
||||||
return jsonify({'error': 'Solo administradores pueden eliminar sucursales'}), 403
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute("SELECT is_main FROM branches WHERE id = %s", (branch_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Branch not found'}), 404
|
|
||||||
|
|
||||||
if row[0]:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'No se puede eliminar la sucursal principal'}), 403
|
|
||||||
|
|
||||||
# Remove branch-specific stock and count rows first.
|
|
||||||
cur.execute("DELETE FROM inventory_stock WHERE branch_id = %s", (branch_id,))
|
|
||||||
cur.execute("DELETE FROM inventory_stock_summary WHERE branch_id = %s", (branch_id,))
|
|
||||||
cur.execute("DELETE FROM physical_counts WHERE branch_id = %s", (branch_id,))
|
|
||||||
|
|
||||||
# Nullify every other FK reference back to branches.
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c.relname::text AS tbl, a.attname::text AS col
|
|
||||||
FROM pg_constraint con
|
|
||||||
JOIN pg_class c ON c.oid = con.conrelid
|
|
||||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
|
|
||||||
WHERE con.confrelid = 'public.branches'::regclass
|
|
||||||
AND con.contype = 'f'
|
|
||||||
""")
|
|
||||||
for tbl, col in cur.fetchall():
|
|
||||||
if tbl in ('inventory_stock', 'inventory_stock_summary', 'physical_counts'):
|
|
||||||
continue
|
|
||||||
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (branch_id,))
|
|
||||||
|
|
||||||
cur.execute("DELETE FROM branches WHERE id = %s", (branch_id,))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'ok': True, 'message': 'Sucursal eliminada'})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/employees', methods=['GET'])
|
@config_bp.route('/employees', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('config.view')
|
||||||
def list_employees():
|
def list_employees():
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -440,7 +200,7 @@ def create_employee():
|
|||||||
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||||
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
||||||
|
|
||||||
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic', 'driver']
|
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
|
||||||
if data['role'] not in valid_roles:
|
if data['role'] not in valid_roles:
|
||||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||||
|
|
||||||
@@ -455,21 +215,31 @@ def create_employee():
|
|||||||
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
||||||
emp_id = cur.fetchone()[0]
|
emp_id = cur.fetchone()[0]
|
||||||
|
|
||||||
# Set default permissions by role (configurable per tenant)
|
# Set default permissions by role
|
||||||
for perm in _get_role_permissions(conn, data['role']):
|
role_permissions = {
|
||||||
|
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||||
|
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||||
|
'catalog.view', 'catalog.edit',
|
||||||
|
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||||
|
'invoicing.view', 'invoicing.create',
|
||||||
|
'reports.view', 'reports.financial',
|
||||||
|
'config.view', 'config.edit', 'config.edit_prices'],
|
||||||
|
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
|
||||||
|
'catalog.view', 'customers.view', 'customers.create'],
|
||||||
|
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
||||||
|
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
||||||
|
'accountant': ['accounting.view', 'accounting.create',
|
||||||
|
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||||
|
'reports.view', 'reports.financial',
|
||||||
|
'customers.view'],
|
||||||
|
}
|
||||||
|
|
||||||
|
for perm in role_permissions.get(data['role'], []):
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||||
(emp_id, perm)
|
(emp_id, perm)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Drivers also get a courier record so they can be assigned to deliveries
|
|
||||||
if data['role'] == 'driver':
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO couriers (tenant_id, name, code, is_active)
|
|
||||||
VALUES (%s, %s, %s, true)
|
|
||||||
ON CONFLICT (tenant_id, code) DO NOTHING
|
|
||||||
""", (g.tenant_id, data['name'], f'emp_{emp_id}'))
|
|
||||||
|
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id,
|
log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id,
|
||||||
new_value={'name': data['name'], 'role': data['role']})
|
new_value={'name': data['name'], 'role': data['role']})
|
||||||
@@ -480,105 +250,6 @@ def create_employee():
|
|||||||
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/role-permissions', methods=['GET'])
|
|
||||||
@require_auth()
|
|
||||||
def get_role_permissions_config():
|
|
||||||
"""Return the configured permissions for each role and the available permission list."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
configured = {}
|
|
||||||
for role in _DEFAULT_ROLE_PERMISSIONS:
|
|
||||||
configured[role] = _get_role_permissions(conn, role)
|
|
||||||
return jsonify({
|
|
||||||
'roles': configured,
|
|
||||||
'available': _AVAILABLE_PERMISSIONS,
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/role-permissions', methods=['PUT'])
|
|
||||||
@require_auth('config.edit')
|
|
||||||
def save_role_permissions_config():
|
|
||||||
"""Save the permission mapping per role. Only owner/admin can edit."""
|
|
||||||
if g.employee_role not in ('owner', 'admin'):
|
|
||||||
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
|
|
||||||
data = request.get_json() or {}
|
|
||||||
if 'roles' not in data:
|
|
||||||
return jsonify({'error': 'roles object required'}), 400
|
|
||||||
for role, perms in data['roles'].items():
|
|
||||||
if role not in _DEFAULT_ROLE_PERMISSIONS:
|
|
||||||
return jsonify({'error': f'Invalid role: {role}'}), 400
|
|
||||||
if not isinstance(perms, list):
|
|
||||||
return jsonify({'error': f'permissions for {role} must be a list'}), 400
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO tenant_config (key, value) VALUES ('role_permissions', %s)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
||||||
""", (json.dumps(data['roles']),))
|
|
||||||
|
|
||||||
# Apply the new permissions to existing employees of each affected role
|
|
||||||
for role, perms in data['roles'].items():
|
|
||||||
cur.execute("DELETE FROM employee_permissions WHERE employee_id IN (SELECT id FROM employees WHERE role = %s)", (role,))
|
|
||||||
cur.execute("SELECT id FROM employees WHERE role = %s", (role,))
|
|
||||||
emp_ids = [r[0] for r in cur.fetchall()]
|
|
||||||
for emp_id in emp_ids:
|
|
||||||
for perm in perms:
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
|
||||||
(emp_id, perm)
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/role-permissions/workshop', methods=['GET'])
|
|
||||||
@require_auth('config.view')
|
|
||||||
def get_workshop_permissions_config():
|
|
||||||
"""Return the configured workshop permissions (statuses + actions) per role."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
return jsonify({
|
|
||||||
'roles': _get_all_workshop_permissions(conn),
|
|
||||||
'statuses': [{'key': s, 'label': s.replace('_', ' ').title()} for s in _WORKSHOP_STATUSES],
|
|
||||||
'actions': _WORKSHOP_ACTIONS,
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/role-permissions/workshop', methods=['PUT'])
|
|
||||||
@require_auth('config.edit')
|
|
||||||
def save_workshop_permissions_config():
|
|
||||||
"""Save the workshop permission mapping per role."""
|
|
||||||
if g.employee_role not in ('owner', 'admin'):
|
|
||||||
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
|
|
||||||
data = request.get_json() or {}
|
|
||||||
if 'roles' not in data:
|
|
||||||
return jsonify({'error': 'roles object required'}), 400
|
|
||||||
|
|
||||||
for role, cfg in data['roles'].items():
|
|
||||||
if role not in _DEFAULT_WORKSHOP_PERMISSIONS:
|
|
||||||
return jsonify({'error': f'Invalid role: {role}'}), 400
|
|
||||||
if not isinstance(cfg, dict):
|
|
||||||
return jsonify({'error': f'config for {role} must be an object'}), 400
|
|
||||||
if not isinstance(cfg.get('statuses', []), list) or not isinstance(cfg.get('actions', []), list):
|
|
||||||
return jsonify({'error': f'statuses/actions for {role} must be lists'}), 400
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
||||||
""", (json.dumps(data['roles']),))
|
|
||||||
conn.commit()
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
|
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
|
||||||
@require_auth('config.edit')
|
@require_auth('config.edit')
|
||||||
def update_employee(emp_id):
|
def update_employee(emp_id):
|
||||||
@@ -624,24 +295,6 @@ def update_employee(emp_id):
|
|||||||
params.append(emp_id)
|
params.append(emp_id)
|
||||||
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
|
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
|
||||||
|
|
||||||
# If the role changed, re-sync permissions to match the new role defaults/config.
|
|
||||||
if 'role' in data:
|
|
||||||
cur.execute("DELETE FROM employee_permissions WHERE employee_id = %s", (emp_id,))
|
|
||||||
for perm in _get_role_permissions(conn, data['role']):
|
|
||||||
cur.execute(
|
|
||||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
|
||||||
(emp_id, perm)
|
|
||||||
)
|
|
||||||
# Drivers also get a courier record so they can be assigned to deliveries
|
|
||||||
if data['role'] == 'driver':
|
|
||||||
cur.execute("SELECT name FROM employees WHERE id = %s", (emp_id,))
|
|
||||||
emp_name = cur.fetchone()[0]
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO couriers (tenant_id, name, code, is_active)
|
|
||||||
VALUES (%s, %s, %s, true)
|
|
||||||
ON CONFLICT (tenant_id, code) DO NOTHING
|
|
||||||
""", (g.tenant_id, emp_name, f'emp_{emp_id}'))
|
|
||||||
|
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
|
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
|
||||||
new_value={k: v for k, v in data.items() if k != 'pin'})
|
new_value={k: v for k, v in data.items() if k != 'pin'})
|
||||||
@@ -652,53 +305,6 @@ def update_employee(emp_id):
|
|||||||
return jsonify({'ok': True, 'message': 'Employee updated'})
|
return jsonify({'ok': True, 'message': 'Employee updated'})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/employees/<int:emp_id>', methods=['DELETE'])
|
|
||||||
@require_auth('config.edit')
|
|
||||||
def delete_employee(emp_id):
|
|
||||||
"""Hard-delete an employee. Foreign-key references are nulled and
|
|
||||||
dependent rows (permissions/sessions) are removed. Owners cannot be
|
|
||||||
deleted via UI to prevent locking out the tenant."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute("SELECT role FROM employees WHERE id = %s", (emp_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Employee not found'}), 404
|
|
||||||
|
|
||||||
if row[0] == 'owner':
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'No se puede eliminar una cuenta de dueno'}), 403
|
|
||||||
|
|
||||||
# Resolve every foreign-key column that points back to employees.
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c.relname::text AS tbl, a.attname::text AS col
|
|
||||||
FROM pg_constraint con
|
|
||||||
JOIN pg_class c ON c.oid = con.conrelid
|
|
||||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
|
|
||||||
WHERE con.confrelid = 'public.employees'::regclass
|
|
||||||
AND con.contype = 'f'
|
|
||||||
""")
|
|
||||||
refs = cur.fetchall()
|
|
||||||
|
|
||||||
for tbl, col in refs:
|
|
||||||
if tbl in ('employee_permissions', 'employee_sessions', 'notification_preferences'):
|
|
||||||
cur.execute(f"DELETE FROM \"{tbl}\" WHERE \"{col}\" = %s", (emp_id,))
|
|
||||||
else:
|
|
||||||
cur.execute(f"UPDATE \"{tbl}\" SET \"{col}\" = NULL WHERE \"{col}\" = %s", (emp_id,))
|
|
||||||
|
|
||||||
cur.execute("DELETE FROM employees WHERE id = %s", (emp_id,))
|
|
||||||
|
|
||||||
from services.audit import log_action
|
|
||||||
log_action(conn, 'EMPLOYEE_DELETE', 'employee', emp_id)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'ok': True, 'message': 'Empleado eliminado'})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/currency', methods=['GET'])
|
@config_bp.route('/currency', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def get_currency():
|
def get_currency():
|
||||||
@@ -783,14 +389,7 @@ def get_business():
|
|||||||
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
|
'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')),
|
||||||
'rfc': cfg.get('tenant_rfc', ''),
|
'rfc': cfg.get('tenant_rfc', ''),
|
||||||
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
|
'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''),
|
||||||
'cp': cfg.get('tenant_cp', ''),
|
|
||||||
'direccion': cfg.get('tenant_direccion', ''),
|
'direccion': cfg.get('tenant_direccion', ''),
|
||||||
'numero_exterior': cfg.get('tenant_numero_exterior', ''),
|
|
||||||
'numero_interior': cfg.get('tenant_numero_interior', ''),
|
|
||||||
'colonia': cfg.get('tenant_colonia', ''),
|
|
||||||
'ciudad': cfg.get('tenant_ciudad', ''),
|
|
||||||
'municipio': cfg.get('tenant_municipio', ''),
|
|
||||||
'estado': cfg.get('tenant_estado', ''),
|
|
||||||
'telefono': cfg.get('tenant_telefono', ''),
|
'telefono': cfg.get('tenant_telefono', ''),
|
||||||
'email': cfg.get('tenant_email', ''),
|
'email': cfg.get('tenant_email', ''),
|
||||||
})
|
})
|
||||||
@@ -806,22 +405,14 @@ def update_business():
|
|||||||
'nombre': 'tenant_nombre',
|
'nombre': 'tenant_nombre',
|
||||||
'rfc': 'tenant_rfc',
|
'rfc': 'tenant_rfc',
|
||||||
'regimen_fiscal': 'tenant_regimen_fiscal',
|
'regimen_fiscal': 'tenant_regimen_fiscal',
|
||||||
'cp': 'tenant_cp',
|
|
||||||
'direccion': 'tenant_direccion',
|
'direccion': 'tenant_direccion',
|
||||||
'numero_exterior': 'tenant_numero_exterior',
|
|
||||||
'numero_interior': 'tenant_numero_interior',
|
|
||||||
'colonia': 'tenant_colonia',
|
|
||||||
'ciudad': 'tenant_ciudad',
|
|
||||||
'municipio': 'tenant_municipio',
|
|
||||||
'estado': 'tenant_estado',
|
|
||||||
'telefono': 'tenant_telefono',
|
'telefono': 'tenant_telefono',
|
||||||
'email': 'tenant_email',
|
'email': 'tenant_email',
|
||||||
# Tax params (also keep cfdi_* aliases in sync)
|
# Tax params
|
||||||
'tax_iva': 'tax_iva',
|
'tax_iva': 'tax_iva',
|
||||||
'tax_ieps': 'tax_ieps',
|
'tax_ieps': 'tax_ieps',
|
||||||
'invoice_serie': 'invoice_serie',
|
'invoice_serie': 'invoice_serie',
|
||||||
'invoice_folio': 'invoice_folio',
|
'invoice_folio': 'invoice_folio',
|
||||||
'cfdi_serie': 'cfdi_serie',
|
|
||||||
'default_currency': 'default_currency',
|
'default_currency': 'default_currency',
|
||||||
'default_payment_method': 'default_payment_method',
|
'default_payment_method': 'default_payment_method',
|
||||||
}
|
}
|
||||||
@@ -900,15 +491,16 @@ def get_billing():
|
|||||||
@config_bp.route('/billing/upgrade', methods=['POST'])
|
@config_bp.route('/billing/upgrade', methods=['POST'])
|
||||||
@require_auth('config.edit')
|
@require_auth('config.edit')
|
||||||
def upgrade_billing():
|
def upgrade_billing():
|
||||||
"""Plan changes are handled by Nexus support, never self-service.
|
"""Upgrade tenant plan."""
|
||||||
|
from services.billing import upgrade_plan
|
||||||
This endpoint previously let any tenant owner upgrade themselves to
|
data = request.get_json() or {}
|
||||||
`enterprise` (unlimited) for free — a direct revenue leak. It now
|
new_plan = data.get('plan')
|
||||||
refuses every change; plans are managed from the master DB / admin panel.
|
if not new_plan:
|
||||||
"""
|
return jsonify({'error': 'plan required'}), 400
|
||||||
return jsonify({
|
result = upgrade_plan(g.tenant_id, new_plan)
|
||||||
'error': 'Los cambios de plan los gestiona Nexus. Contacta a soporte para actualizar tu plan.'
|
if 'error' in result:
|
||||||
}), 403
|
return jsonify(result), 400
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
# ─── Vehicle Compatibility Source ────────────────────
|
# ─── Vehicle Compatibility Source ────────────────────
|
||||||
@@ -1023,49 +615,6 @@ def update_allowed_brands():
|
|||||||
|
|
||||||
# ─── WhatsApp Configuration ────────────────────────────────────────────────
|
# ─── WhatsApp Configuration ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
INVOICE_TOGGLE_KEYS = {
|
|
||||||
'invoice_auto': 'invoice_auto',
|
|
||||||
'invoice_email': 'invoice_email',
|
|
||||||
'invoice_global': 'invoice_global',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/invoice-settings', methods=['GET'])
|
|
||||||
@require_auth('config.view')
|
|
||||||
def get_invoice_settings():
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
|
|
||||||
(list(INVOICE_TOGGLE_KEYS.values()),))
|
|
||||||
rows = {r[0]: r[1] for r in cur.fetchall()}
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({
|
|
||||||
'invoice_auto': rows.get('invoice_auto', 'false').lower() == 'true',
|
|
||||||
'invoice_email': rows.get('invoice_email', 'false').lower() == 'true',
|
|
||||||
'invoice_global': rows.get('invoice_global', 'false').lower() == 'true',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/invoice-settings', methods=['PUT'])
|
|
||||||
@require_auth('config.edit')
|
|
||||||
def update_invoice_settings():
|
|
||||||
data = request.get_json() or {}
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
for json_key, cfg_key in INVOICE_TOGGLE_KEYS.items():
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
||||||
""", (cfg_key, 'true' if data.get(json_key) else 'false'))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'ok': True})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/whatsapp', methods=['GET'])
|
@config_bp.route('/whatsapp', methods=['GET'])
|
||||||
@require_auth('config.view')
|
@require_auth('config.view')
|
||||||
def get_whatsapp_config():
|
def get_whatsapp_config():
|
||||||
@@ -1082,14 +631,6 @@ def get_whatsapp_config():
|
|||||||
'bridge_key': rows.get('whatsapp_bridge_key', ''),
|
'bridge_key': rows.get('whatsapp_bridge_key', ''),
|
||||||
'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true',
|
'enabled': rows.get('whatsapp_enabled', 'false').lower() == 'true',
|
||||||
'phone_number': rows.get('whatsapp_phone_number', ''),
|
'phone_number': rows.get('whatsapp_phone_number', ''),
|
||||||
'provider': rows.get('whatsapp_provider', 'baileys'),
|
|
||||||
'phone_number_id': rows.get('wa_phone_number_id', ''),
|
|
||||||
'has_access_token': bool(rows.get('wa_access_token')),
|
|
||||||
'has_app_secret': bool(rows.get('wa_app_secret')),
|
|
||||||
'verify_token': rows.get('wa_verify_token', ''),
|
|
||||||
'api_base': rows.get('wa_api_base', ''),
|
|
||||||
'has_api_key': bool(rows.get('wa_api_key')),
|
|
||||||
'public_base': rows.get('wa_public_base', ''),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1106,18 +647,7 @@ def update_whatsapp_config():
|
|||||||
'whatsapp_bridge_key': data.get('bridge_key', ''),
|
'whatsapp_bridge_key': data.get('bridge_key', ''),
|
||||||
'whatsapp_enabled': 'true' if data.get('enabled') else 'false',
|
'whatsapp_enabled': 'true' if data.get('enabled') else 'false',
|
||||||
'whatsapp_phone_number': data.get('phone_number', ''),
|
'whatsapp_phone_number': data.get('phone_number', ''),
|
||||||
'whatsapp_provider': data.get('provider', 'baileys'),
|
|
||||||
'wa_phone_number_id': data.get('phone_number_id', ''),
|
|
||||||
'wa_verify_token': data.get('verify_token', ''),
|
|
||||||
'wa_api_base': data.get('api_base', ''),
|
|
||||||
'wa_public_base': data.get('public_base', ''),
|
|
||||||
}
|
}
|
||||||
if data.get('api_key'):
|
|
||||||
settings['wa_api_key'] = data['api_key']
|
|
||||||
if data.get('access_token'):
|
|
||||||
settings['wa_access_token'] = data['access_token']
|
|
||||||
if data.get('app_secret'):
|
|
||||||
settings['wa_app_secret'] = data['app_secret']
|
|
||||||
|
|
||||||
for key, value in settings.items():
|
for key, value in settings.items():
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -1132,151 +662,25 @@ def update_whatsapp_config():
|
|||||||
return jsonify({'message': 'WhatsApp configuration updated'})
|
return jsonify({'message': 'WhatsApp configuration updated'})
|
||||||
|
|
||||||
|
|
||||||
# ─── Contactos de alerta (POR NEGOCIO) ─────────────────────────────────────
|
|
||||||
# Los contactos que reciben alertas por WhatsApp son POR NEGOCIO: cada tenant
|
|
||||||
# configura sus propios números en su `tenant_config` (`alert_phones`,
|
|
||||||
# `alert_whatsapp_enabled`). No hay ningún teléfono global obligatorio; si un
|
|
||||||
# negocio no configura nada, sus alertas simplemente no se envían (no-op, sin
|
|
||||||
# error). El webhook de Alertmanager (`pos/app.py`) lee estas mismas claves.
|
|
||||||
|
|
||||||
# `+` opcional seguido de 10-15 dígitos (formato internacional).
|
|
||||||
_ALERT_PHONE_RE = re.compile(r'^\+?\d{10,15}$')
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_alert_phones(raw):
|
|
||||||
"""Normaliza contactos separados por comas.
|
|
||||||
|
|
||||||
Acepta una cadena ("+5215512345678, 5215587654321") o una lista/tupla.
|
|
||||||
Recorta espacios, valida cada número (`+` opcional + 10-15 dígitos) y
|
|
||||||
descarta el resto. Devuelve la tupla (validos, descartados).
|
|
||||||
"""
|
|
||||||
if isinstance(raw, (list, tuple)):
|
|
||||||
chunks = []
|
|
||||||
for item in raw:
|
|
||||||
chunks.extend(str(item).split(','))
|
|
||||||
else:
|
|
||||||
chunks = str(raw or '').split(',')
|
|
||||||
|
|
||||||
valid, dropped = [], []
|
|
||||||
for chunk in chunks:
|
|
||||||
phone = chunk.strip()
|
|
||||||
if not phone:
|
|
||||||
continue
|
|
||||||
if _ALERT_PHONE_RE.match(phone):
|
|
||||||
if phone not in valid:
|
|
||||||
valid.append(phone)
|
|
||||||
else:
|
|
||||||
dropped.append(phone)
|
|
||||||
return valid, dropped
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/alert-contacts', methods=['GET'])
|
|
||||||
@require_auth('config.view')
|
|
||||||
def get_alert_contacts():
|
|
||||||
"""Contactos de alerta por WhatsApp de ESTE negocio (tenant).
|
|
||||||
|
|
||||||
Son por negocio, nunca globales. `alert_phones` se devuelve como lista
|
|
||||||
normalizada; `alert_whatsapp_enabled` indica si el reenvío está activado.
|
|
||||||
Si el negocio no ha configurado nada, devuelve lista vacía y `false`
|
|
||||||
(las alertas serán no-op). `inherited_global` siempre es `false`: no se
|
|
||||||
hereda ningún teléfono global.
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT key, value FROM tenant_config "
|
|
||||||
"WHERE key IN ('alert_phones', 'alert_whatsapp_enabled')"
|
|
||||||
)
|
|
||||||
rows = {r[0]: r[1] for r in cur.fetchall()}
|
|
||||||
cur.close()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
phones, _ = _normalize_alert_phones(rows.get('alert_phones'))
|
|
||||||
return jsonify({
|
|
||||||
'alert_phones': phones,
|
|
||||||
'alert_whatsapp_enabled': str(rows.get('alert_whatsapp_enabled', 'false')).lower() == 'true',
|
|
||||||
'inherited_global': False,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/alert-contacts', methods=['PUT'])
|
|
||||||
@require_auth('config.edit')
|
|
||||||
def update_alert_contacts():
|
|
||||||
"""Guarda los contactos de alerta por WhatsApp de ESTE negocio.
|
|
||||||
|
|
||||||
Normaliza `alert_phones` (separa por comas, recorta y descarta basura:
|
|
||||||
solo se aceptan `+` opcional + 10-15 dígitos) y guarda
|
|
||||||
`alert_whatsapp_enabled` como booleano. Un payload vacío es válido y deja
|
|
||||||
al negocio sin contactos (alertas no-op). Devuelve lo guardado.
|
|
||||||
"""
|
|
||||||
data = request.get_json(silent=True)
|
|
||||||
if data is None:
|
|
||||||
data = {}
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return jsonify({'error': 'JSON object required'}), 400
|
|
||||||
|
|
||||||
phones, dropped = _normalize_alert_phones(data.get('alert_phones'))
|
|
||||||
enabled = bool(data.get('alert_whatsapp_enabled'))
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO tenant_config (key, value) VALUES ('alert_phones', %s)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
||||||
""", (','.join(phones),))
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO tenant_config (key, value) VALUES ('alert_whatsapp_enabled', %s)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
||||||
""", ('true' if enabled else 'false',))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if dropped:
|
|
||||||
import logging
|
|
||||||
logging.getLogger(__name__).warning(
|
|
||||||
"alert-contacts: %d contacto(s) descartado(s) para tenant %s",
|
|
||||||
len(dropped), g.tenant_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'alert_phones': phones,
|
|
||||||
'alert_whatsapp_enabled': enabled,
|
|
||||||
'inherited_global': False,
|
|
||||||
'dropped': dropped,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/modules', methods=['GET'])
|
@config_bp.route('/modules', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('config.view')
|
||||||
def get_modules():
|
def get_modules():
|
||||||
"""Get enabled modules for this tenant."""
|
"""Get enabled modules for this tenant."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%' OR key LIKE '%enabled'")
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'module_%'")
|
||||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
rows = {row[0]: row[1] for row in cur.fetchall()}
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def _bool(name):
|
def _bool(key):
|
||||||
# Unificado 2026-10-11: el POS escribe 'module_X' y el dashboard 'X_enabled';
|
return rows.get(key, 'true').lower() == 'true'
|
||||||
# aceptamos ambos para que el toggle se refleje en las dos capas.
|
|
||||||
v = rows.get('module_' + name)
|
|
||||||
if v is None:
|
|
||||||
v = rows.get(name + '_enabled')
|
|
||||||
return (v if v is not None else 'true').lower() == 'true'
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'whatsapp': _bool('whatsapp'),
|
'whatsapp': _bool('module_whatsapp'),
|
||||||
'marketplace': _bool('marketplace'),
|
'marketplace': _bool('module_marketplace'),
|
||||||
'meli': _bool('meli'),
|
'meli': _bool('module_meli'),
|
||||||
'catalog': _bool('catalog'),
|
'catalog': _bool('module_catalog'),
|
||||||
'workshop': _bool('workshop'),
|
|
||||||
'yonke': _bool('yonke'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1288,14 +692,12 @@ def update_modules():
|
|||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
# Doble escritura: 'module_X' (POS) + 'X_enabled' (dashboard/consumidores).
|
settings = {
|
||||||
# Solo se tocan las claves presentes en el payload (no se pisan las demás).
|
'module_whatsapp': 'true' if data.get('whatsapp') else 'false',
|
||||||
settings = {}
|
'module_marketplace': 'true' if data.get('marketplace') else 'false',
|
||||||
for name in ('whatsapp', 'marketplace', 'meli', 'catalog', 'workshop', 'yonke'):
|
'module_meli': 'true' if data.get('meli') else 'false',
|
||||||
if name in data:
|
'module_catalog': 'true' if data.get('catalog') else 'false',
|
||||||
val = 'true' if data.get(name) else 'false'
|
}
|
||||||
settings['module_' + name] = val
|
|
||||||
settings[name + '_enabled'] = val
|
|
||||||
|
|
||||||
for key, value in settings.items():
|
for key, value in settings.items():
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -1311,78 +713,9 @@ def update_modules():
|
|||||||
'whatsapp': data.get('whatsapp'),
|
'whatsapp': data.get('whatsapp'),
|
||||||
'marketplace': data.get('marketplace'),
|
'marketplace': data.get('marketplace'),
|
||||||
'meli': data.get('meli'),
|
'meli': data.get('meli'),
|
||||||
'catalog': data.get('catalog'),
|
|
||||||
'workshop': data.get('workshop'),
|
|
||||||
}})
|
}})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/counter-remission', methods=['GET'])
|
|
||||||
@require_auth()
|
|
||||||
def get_counter_remission_config():
|
|
||||||
"""Get counter remission note feature flag for this tenant."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'counter_remission_enabled'")
|
|
||||||
row = cur.fetchone()
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'enabled': str(row[0]).lower() == 'true' if row else False})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/counter-remission', methods=['PUT'])
|
|
||||||
@require_auth('config.edit')
|
|
||||||
def update_counter_remission_config():
|
|
||||||
"""Enable/disable counter remission notes for this tenant."""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
enabled = 'true' if data.get('enabled') else 'false'
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
||||||
""", ('counter_remission_enabled', enabled))
|
|
||||||
conn.commit()
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'enabled': enabled == 'true'})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/sales-settings', methods=['GET'])
|
|
||||||
@require_auth('pos.view')
|
|
||||||
def get_sales_settings():
|
|
||||||
"""Get sales-related settings (zero-price sales, negative stock)."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('allow_zero_price_sales', 'allow_negative_stock')")
|
|
||||||
rows = {k: v for k, v in cur.fetchall()}
|
|
||||||
cur.close(); conn.close()
|
|
||||||
# Default allow_zero_price_sales to true to avoid breaking existing tenants.
|
|
||||||
allow = str(rows.get('allow_zero_price_sales', 'true')).lower() in ('true', '1', 'yes')
|
|
||||||
# Default allow_negative_stock to false (safer).
|
|
||||||
neg = str(rows.get('allow_negative_stock', 'false')).lower() in ('true', '1', 'yes')
|
|
||||||
return jsonify({'allow_zero_price_sales': allow, 'allow_negative_stock': neg})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/sales-settings', methods=['PUT'])
|
|
||||||
@require_auth('config.edit')
|
|
||||||
def update_sales_settings():
|
|
||||||
"""Update sales-related settings."""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
allow = 'true' if data.get('allow_zero_price_sales') else 'false'
|
|
||||||
neg = 'true' if data.get('allow_negative_stock') else 'false'
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
||||||
""", ('allow_zero_price_sales', allow))
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
||||||
""", ('allow_negative_stock', neg))
|
|
||||||
conn.commit()
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'allow_zero_price_sales': allow == 'true', 'allow_negative_stock': neg == 'true'})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/onboarding-status', methods=['GET'])
|
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||||
@require_auth('pos.view')
|
@require_auth('pos.view')
|
||||||
def get_onboarding_status():
|
def get_onboarding_status():
|
||||||
@@ -1412,104 +745,3 @@ def set_onboarding_status():
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'completed': completed == 'true'})
|
return jsonify({'completed': completed == 'true'})
|
||||||
|
|
||||||
|
|
||||||
# ─── Receipt / Ticket Customization ────────────────────────────────────────
|
|
||||||
|
|
||||||
RECEIPT_CONFIG_KEYS = [
|
|
||||||
'receipt_logo',
|
|
||||||
'receipt_store_name',
|
|
||||||
'receipt_tagline',
|
|
||||||
'receipt_rfc',
|
|
||||||
'receipt_address',
|
|
||||||
'receipt_phone',
|
|
||||||
'receipt_footer',
|
|
||||||
'receipt_thanks_message',
|
|
||||||
'receipt_show_logo',
|
|
||||||
'receipt_show_rfc',
|
|
||||||
'receipt_show_address',
|
|
||||||
'receipt_show_phone',
|
|
||||||
'receipt_show_iva_breakdown',
|
|
||||||
'receipt_show_payment_details',
|
|
||||||
'receipt_show_employee',
|
|
||||||
'receipt_paper_width',
|
|
||||||
'receipt_auto_print',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/receipt', methods=['GET'])
|
|
||||||
@require_auth('pos.view')
|
|
||||||
def get_receipt_config():
|
|
||||||
"""Get receipt customization settings."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT key, value FROM tenant_config WHERE key = ANY(%s)",
|
|
||||||
(RECEIPT_CONFIG_KEYS,)
|
|
||||||
)
|
|
||||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def _bool(key, default=False):
|
|
||||||
v = rows.get(key, 'true' if default else 'false')
|
|
||||||
return str(v).lower() == 'true'
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'logo': rows.get('receipt_logo', ''),
|
|
||||||
'store_name': rows.get('receipt_store_name', ''),
|
|
||||||
'tagline': rows.get('receipt_tagline', ''),
|
|
||||||
'rfc': rows.get('receipt_rfc', ''),
|
|
||||||
'address': rows.get('receipt_address', ''),
|
|
||||||
'phone': rows.get('receipt_phone', ''),
|
|
||||||
'footer': rows.get('receipt_footer', ''),
|
|
||||||
'thanks_message': rows.get('receipt_thanks_message', 'Gracias por su compra!'),
|
|
||||||
'show_logo': _bool('receipt_show_logo', True),
|
|
||||||
'show_rfc': _bool('receipt_show_rfc', True),
|
|
||||||
'show_address': _bool('receipt_show_address', False),
|
|
||||||
'show_phone': _bool('receipt_show_phone', False),
|
|
||||||
'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True),
|
|
||||||
'show_payment_details': _bool('receipt_show_payment_details', True),
|
|
||||||
'auto_print': _bool('receipt_auto_print', False),
|
|
||||||
'show_employee': _bool('receipt_show_employee', False),
|
|
||||||
'paper_width': rows.get('receipt_paper_width', '80') or '80',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/receipt', methods=['PUT'])
|
|
||||||
@require_auth('config.edit')
|
|
||||||
def update_receipt_config():
|
|
||||||
"""Update receipt customization settings."""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
settings = {
|
|
||||||
'receipt_logo': data.get('logo', ''),
|
|
||||||
'receipt_store_name': data.get('store_name', ''),
|
|
||||||
'receipt_tagline': data.get('tagline', ''),
|
|
||||||
'receipt_rfc': data.get('rfc', ''),
|
|
||||||
'receipt_address': data.get('address', ''),
|
|
||||||
'receipt_phone': data.get('phone', ''),
|
|
||||||
'receipt_footer': data.get('footer', ''),
|
|
||||||
'receipt_thanks_message': data.get('thanks_message', 'Gracias por su compra!'),
|
|
||||||
'receipt_show_logo': 'true' if data.get('show_logo') else 'false',
|
|
||||||
'receipt_show_rfc': 'true' if data.get('show_rfc') else 'false',
|
|
||||||
'receipt_show_address': 'true' if data.get('show_address') else 'false',
|
|
||||||
'receipt_show_phone': 'true' if data.get('show_phone') else 'false',
|
|
||||||
'receipt_show_iva_breakdown': 'true' if data.get('show_iva_breakdown') else 'false',
|
|
||||||
'receipt_show_payment_details': 'true' if data.get('show_payment_details') else 'false',
|
|
||||||
'receipt_show_employee': 'true' if data.get('show_employee') else 'false',
|
|
||||||
'receipt_paper_width': str(data.get('paper_width', '80') or '80'),
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, value in settings.items():
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
|
||||||
""", (key, value))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'message': 'Receipt configuration updated'})
|
|
||||||
|
|||||||
@@ -2,31 +2,18 @@
|
|||||||
"""Customers blueprint: CRUD, credit management, vehicles, account statements."""
|
"""Customers blueprint: CRUD, credit management, vehicles, account statements."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth, has_permission
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
|
|
||||||
logger = logging.getLogger('pos')
|
|
||||||
|
|
||||||
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
||||||
|
|
||||||
|
|
||||||
def _can_view_customers():
|
|
||||||
"""Cashiers, counter and workshop employees need customer access for POS/service flows."""
|
|
||||||
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
|
|
||||||
|
|
||||||
|
|
||||||
def _can_create_customer():
|
|
||||||
"""Cashiers, counter and workshop employees can create customers on the fly."""
|
|
||||||
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('cashier', 'counter', 'workshop', 'mechanic')
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Customer CRUD ─────────────────────────────
|
# ─── Customer CRUD ─────────────────────────────
|
||||||
|
|
||||||
@customers_bp.route('', methods=['GET'])
|
@customers_bp.route('', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('customers.view')
|
||||||
def list_customers():
|
def list_customers():
|
||||||
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
||||||
|
|
||||||
@@ -36,8 +23,6 @@ def list_customers():
|
|||||||
per_page: items per page (default 50, max 200)
|
per_page: items per page (default 50, max 200)
|
||||||
branch_id: filter by branch (default: current user's branch)
|
branch_id: filter by branch (default: current user's branch)
|
||||||
"""
|
"""
|
||||||
if not _can_view_customers():
|
|
||||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
@@ -45,24 +30,10 @@ def list_customers():
|
|||||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||||
search = request.args.get('q', '').strip()
|
search = request.args.get('q', '').strip()
|
||||||
branch_id = request.args.get('branch_id')
|
branch_id = request.args.get('branch_id')
|
||||||
price_tier = request.args.get('price_tier', '').strip()
|
|
||||||
status = request.args.get('status', '').strip().lower()
|
|
||||||
|
|
||||||
where_clauses = []
|
where_clauses = ["c.is_active = true"]
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if status == 'inactive':
|
|
||||||
where_clauses.append("c.is_active = false")
|
|
||||||
elif status == 'overdue':
|
|
||||||
where_clauses.append(
|
|
||||||
"c.is_active = true AND c.credit_limit > 0 AND c.credit_balance > c.credit_limit"
|
|
||||||
)
|
|
||||||
elif status == 'all':
|
|
||||||
pass # no is_active filter
|
|
||||||
else:
|
|
||||||
# Default to active customers for backwards compatibility
|
|
||||||
where_clauses.append("c.is_active = true")
|
|
||||||
|
|
||||||
if branch_id:
|
if branch_id:
|
||||||
where_clauses.append("c.branch_id = %s")
|
where_clauses.append("c.branch_id = %s")
|
||||||
params.append(int(branch_id))
|
params.append(int(branch_id))
|
||||||
@@ -71,20 +42,8 @@ def list_customers():
|
|||||||
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
|
"(c.name ILIKE %s OR c.rfc ILIKE %s OR c.phone ILIKE %s OR c.razon_social ILIKE %s)"
|
||||||
)
|
)
|
||||||
params.extend([f'%{search}%'] * 4)
|
params.extend([f'%{search}%'] * 4)
|
||||||
if price_tier:
|
|
||||||
# Support numeric tier or Spanish labels
|
|
||||||
tier_map = {'taller': 2, 'mostrador': 1, 'mayoreo': 3}
|
|
||||||
tier_val = tier_map.get(price_tier.lower())
|
|
||||||
if tier_val is None:
|
|
||||||
try:
|
|
||||||
tier_val = int(price_tier)
|
|
||||||
except ValueError:
|
|
||||||
tier_val = None
|
|
||||||
if tier_val in (1, 2, 3):
|
|
||||||
where_clauses.append("c.price_tier = %s")
|
|
||||||
params.append(tier_val)
|
|
||||||
|
|
||||||
where = " AND ".join(where_clauses) if where_clauses else "true"
|
where = " AND ".join(where_clauses)
|
||||||
|
|
||||||
# Count
|
# Count
|
||||||
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
|
cur.execute(f"SELECT count(*) FROM customers c WHERE {where}", params)
|
||||||
@@ -95,8 +54,7 @@ def list_customers():
|
|||||||
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
SELECT c.id, c.name, c.rfc, c.razon_social, c.phone, c.email,
|
||||||
c.address, c.cp,
|
c.address, c.cp,
|
||||||
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
c.price_tier, c.credit_limit, c.credit_balance, c.vehicle_info,
|
||||||
c.branch_id, c.is_active, c.created_at,
|
c.branch_id
|
||||||
(SELECT MAX(s.created_at) FROM sales s WHERE s.customer_id = c.id) AS last_purchase
|
|
||||||
FROM customers c
|
FROM customers c
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY c.name
|
ORDER BY c.name
|
||||||
@@ -113,9 +71,6 @@ def list_customers():
|
|||||||
'credit_balance': float(r[10]) if r[10] else 0,
|
'credit_balance': float(r[10]) if r[10] else 0,
|
||||||
'vehicle_info': r[11],
|
'vehicle_info': r[11],
|
||||||
'branch_id': r[12],
|
'branch_id': r[12],
|
||||||
'is_active': r[13],
|
|
||||||
'created_at': str(r[14]) if r[14] else None,
|
|
||||||
'last_purchase': str(r[15]) if r[15] else None,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -129,10 +84,8 @@ def list_customers():
|
|||||||
|
|
||||||
|
|
||||||
@customers_bp.route('/<int:customer_id>', methods=['GET'])
|
@customers_bp.route('/<int:customer_id>', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('customers.view')
|
||||||
def get_customer(customer_id):
|
def get_customer(customer_id):
|
||||||
if not _can_view_customers():
|
|
||||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
||||||
"""Get customer details with credit info, vehicle history, and recent purchases."""
|
"""Get customer details with credit info, vehicle history, and recent purchases."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -158,14 +111,6 @@ def get_customer(customer_id):
|
|||||||
|
|
||||||
customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None
|
customer['created_at'] = str(customer['created_at']) if customer['created_at'] else None
|
||||||
|
|
||||||
# Credit risk score (A9). Never block customer detail on scoring errors.
|
|
||||||
try:
|
|
||||||
from services.credit_scoring import score_customer
|
|
||||||
customer['credit_score'] = score_customer(conn, customer_id)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning('credit score for customer %s failed: %s', customer_id, exc)
|
|
||||||
customer['credit_score'] = None
|
|
||||||
|
|
||||||
# Recent purchases (last 20)
|
# Recent purchases (last 20)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
|
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
|
||||||
@@ -190,93 +135,19 @@ def get_customer(customer_id):
|
|||||||
float(customer['credit_limit']) - float(customer['credit_balance']), 2
|
float(customer['credit_limit']) - float(customer['credit_balance']), 2
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fleet vehicles assigned to this customer
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, plate, vin, make, model, year, current_mileage, color, owner_name, is_active, created_at
|
|
||||||
FROM fleet_vehicles
|
|
||||||
WHERE customer_id = %s
|
|
||||||
ORDER BY is_active DESC, created_at DESC
|
|
||||||
""", (customer_id,))
|
|
||||||
customer['fleet_vehicles'] = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
customer['fleet_vehicles'].append({
|
|
||||||
'id': r[0], 'plate': r[1], 'vin': r[2], 'make': r[3], 'model': r[4],
|
|
||||||
'year': r[5], 'current_mileage': r[6], 'color': r[7], 'owner_name': r[8],
|
|
||||||
'is_active': r[9], 'created_at': str(r[10]) if r[10] else None,
|
|
||||||
})
|
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify(customer)
|
return jsonify(customer)
|
||||||
|
|
||||||
|
|
||||||
@customers_bp.route('/<int:customer_id>/credit-score', methods=['GET'])
|
|
||||||
@require_auth()
|
|
||||||
def get_customer_credit_score(customer_id):
|
|
||||||
"""Credit risk score for a customer (A9).
|
|
||||||
|
|
||||||
Returns {score, tier, recommended_limit, reasons, data_points, ...}.
|
|
||||||
"""
|
|
||||||
if not _can_view_customers():
|
|
||||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
from services.credit_scoring import score_customer
|
|
||||||
return jsonify(score_customer(conn, customer_id))
|
|
||||||
except ValueError as e:
|
|
||||||
return jsonify({'error': str(e)}), 404
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('credit-score for customer %s failed: %s', customer_id, e)
|
|
||||||
return jsonify({'error': 'Could not compute credit score'}), 500
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@customers_bp.route('/<int:customer_id>/purchases', methods=['GET'])
|
|
||||||
@require_auth()
|
|
||||||
def get_customer_purchases(customer_id):
|
|
||||||
if not _can_view_customers():
|
|
||||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
||||||
"""Return full purchase history for a customer."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT s.id, s.total, s.payment_method, s.sale_type, s.status, s.created_at,
|
|
||||||
e.name as employee_name
|
|
||||||
FROM sales s
|
|
||||||
LEFT JOIN employees e ON s.employee_id = e.id
|
|
||||||
WHERE s.customer_id = %s
|
|
||||||
ORDER BY s.created_at DESC
|
|
||||||
""", (customer_id,))
|
|
||||||
|
|
||||||
purchases = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
purchases.append({
|
|
||||||
'id': r[0],
|
|
||||||
'total': float(r[1]) if r[1] else 0,
|
|
||||||
'payment_method': r[2],
|
|
||||||
'sale_type': r[3],
|
|
||||||
'status': r[4],
|
|
||||||
'created_at': str(r[5]),
|
|
||||||
'employee_name': r[6],
|
|
||||||
})
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'data': purchases})
|
|
||||||
|
|
||||||
|
|
||||||
@customers_bp.route('', methods=['POST'])
|
@customers_bp.route('', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth('customers.create')
|
||||||
def create_customer():
|
def create_customer():
|
||||||
"""Create a new customer.
|
"""Create a new customer.
|
||||||
|
|
||||||
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
||||||
phone, address, price_tier, credit_limit, vehicle_info}
|
phone, address, price_tier, credit_limit, vehicle_info}
|
||||||
"""
|
"""
|
||||||
if not _can_create_customer():
|
|
||||||
return jsonify({'error': 'Missing permissions: customers.create'}), 403
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not data.get('name'):
|
if not data.get('name'):
|
||||||
return jsonify({'error': 'name is required'}), 400
|
return jsonify({'error': 'name is required'}), 400
|
||||||
@@ -369,57 +240,9 @@ def update_customer(customer_id):
|
|||||||
return jsonify({'message': 'Customer updated'})
|
return jsonify({'message': 'Customer updated'})
|
||||||
|
|
||||||
|
|
||||||
@customers_bp.route('/<int:customer_id>', methods=['DELETE'])
|
|
||||||
@require_auth('customers.delete')
|
|
||||||
def delete_customer(customer_id):
|
|
||||||
"""Hard-delete a customer.
|
|
||||||
|
|
||||||
Related records (sales, service orders, vehicles, etc.) keep their data but
|
|
||||||
lose the customer reference. Layaways for this customer are removed because
|
|
||||||
they require a customer_id.
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute("SELECT id FROM customers WHERE id = %s", (customer_id,))
|
|
||||||
if not cur.fetchone():
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Customer not found'}), 404
|
|
||||||
|
|
||||||
# Remove layaway dependencies first, then the layaways themselves
|
|
||||||
cur.execute("DELETE FROM layaway_items WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
|
|
||||||
cur.execute("DELETE FROM layaway_payments WHERE layaway_id IN (SELECT id FROM layaways WHERE customer_id = %s)", (customer_id,))
|
|
||||||
cur.execute("DELETE FROM layaways WHERE customer_id = %s", (customer_id,))
|
|
||||||
|
|
||||||
# Nullify every other FK reference back to customers.
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c.relname::text AS tbl, a.attname::text AS col
|
|
||||||
FROM pg_constraint con
|
|
||||||
JOIN pg_class c ON c.oid = con.conrelid
|
|
||||||
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey)
|
|
||||||
WHERE con.confrelid = 'public.customers'::regclass
|
|
||||||
AND con.contype = 'f'
|
|
||||||
""")
|
|
||||||
refs = cur.fetchall()
|
|
||||||
for tbl, col in refs:
|
|
||||||
# Cascade-delete tables and already-handled layaways are skipped.
|
|
||||||
if tbl in ('customer_activities', 'customer_tag_assignments', 'loyalty_points', 'loyalty_redemptions', 'layaways'):
|
|
||||||
continue
|
|
||||||
cur.execute(f'UPDATE "{tbl}" SET "{col}" = NULL WHERE "{col}" = %s', (customer_id,))
|
|
||||||
|
|
||||||
cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,))
|
|
||||||
log_action(conn, 'CUSTOMER_DELETE', 'customer', customer_id)
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'message': 'Cliente eliminado'})
|
|
||||||
|
|
||||||
|
|
||||||
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
|
@customers_bp.route('/<int:customer_id>/statement', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('customers.view')
|
||||||
def customer_statement(customer_id):
|
def customer_statement(customer_id):
|
||||||
if not _can_view_customers():
|
|
||||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
||||||
"""Account statement: sales (invoices), payments, running balance.
|
"""Account statement: sales (invoices), payments, running balance.
|
||||||
|
|
||||||
Query params:
|
Query params:
|
||||||
@@ -518,10 +341,8 @@ def customer_statement(customer_id):
|
|||||||
|
|
||||||
|
|
||||||
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
|
@customers_bp.route('/<int:customer_id>/vehicles', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth('customers.view')
|
||||||
def customer_vehicles(customer_id):
|
def customer_vehicles(customer_id):
|
||||||
if not _can_view_customers():
|
|
||||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
|
||||||
"""Get customer's vehicle list with last purchases per vehicle.
|
"""Get customer's vehicle list with last purchases per vehicle.
|
||||||
|
|
||||||
Vehicle info is stored as JSONB in customers.vehicle_info:
|
Vehicle info is stored as JSONB in customers.vehicle_info:
|
||||||
@@ -618,32 +439,6 @@ def record_customer_payment(customer_id):
|
|||||||
UPDATE customers SET credit_balance = %s WHERE id = %s
|
UPDATE customers SET credit_balance = %s WHERE id = %s
|
||||||
""", (new_balance, customer_id))
|
""", (new_balance, customer_id))
|
||||||
|
|
||||||
# Allocate the customer payment to oldest unpaid credit sales so the
|
|
||||||
# aging report and per-sale balances reflect the remaining debt.
|
|
||||||
remaining = amount
|
|
||||||
cur.execute("""
|
|
||||||
SELECT s.id, s.total - COALESCE(SUM(sp.amount), 0) as balance
|
|
||||||
FROM sales s
|
|
||||||
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
|
|
||||||
WHERE s.customer_id = %s
|
|
||||||
AND s.sale_type = 'credit'
|
|
||||||
AND s.status = 'completed'
|
|
||||||
GROUP BY s.id, s.total, s.created_at
|
|
||||||
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
|
|
||||||
ORDER BY s.created_at
|
|
||||||
""", (customer_id,))
|
|
||||||
for sale_id, balance in cur.fetchall():
|
|
||||||
if remaining <= 0:
|
|
||||||
break
|
|
||||||
pay = round(min(remaining, float(balance)), 2)
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO sale_payments
|
|
||||||
(sale_id, register_id, method, amount, reference)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
""", (sale_id, register_id, payment_method, pay,
|
|
||||||
f'Abono cliente #{customer_id}'))
|
|
||||||
remaining = round(remaining - pay, 2)
|
|
||||||
|
|
||||||
# Record cash movement on register if cash payment
|
# Record cash movement on register if cash payment
|
||||||
if register_id and payment_method == 'efectivo':
|
if register_id and payment_method == 'efectivo':
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -657,24 +452,6 @@ def record_customer_payment(customer_id):
|
|||||||
new_value={'credit_balance': new_balance, 'payment': amount})
|
new_value={'credit_balance': new_balance, 'payment': amount})
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Accounting entry (non-blocking): Cobro de credito
|
|
||||||
# Debit Caja/Bancos, Credit Clientes
|
|
||||||
try:
|
|
||||||
from services.accounting_engine import record_credit_payment_entry
|
|
||||||
record_credit_payment_entry(conn, {
|
|
||||||
'customer_id': customer_id,
|
|
||||||
'customer_name': cust[0],
|
|
||||||
'amount': amount,
|
|
||||||
'payment_method': payment_method,
|
|
||||||
})
|
|
||||||
conn.commit()
|
|
||||||
except Exception as acc_err:
|
|
||||||
conn.rollback()
|
|
||||||
logger.error(
|
|
||||||
f'Accounting entry for customer payment #{payment_id} failed: {acc_err}'
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.close(); conn.close()
|
cur.close(); conn.close()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
@@ -118,70 +118,3 @@ def get_employee_stats():
|
|||||||
finally:
|
finally:
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@dashboard_stats_bp.route('/credit-alerts', methods=['GET'])
|
|
||||||
@require_auth()
|
|
||||||
def credit_alerts():
|
|
||||||
"""Credit sales that are overdue or due within the next 7 days."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
try:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT s.id,
|
|
||||||
c.name as customer_name,
|
|
||||||
s.total,
|
|
||||||
s.created_at,
|
|
||||||
s.created_at + INTERVAL '30 days' as due_date,
|
|
||||||
COALESCE(SUM(sp.amount), 0) as paid
|
|
||||||
FROM sales s
|
|
||||||
JOIN customers c ON c.id = s.customer_id
|
|
||||||
LEFT JOIN sale_payments sp ON sp.sale_id = s.id
|
|
||||||
WHERE s.sale_type = 'credit'
|
|
||||||
AND s.status = 'completed'
|
|
||||||
GROUP BY s.id, c.name, s.total, s.created_at
|
|
||||||
HAVING s.total - COALESCE(SUM(sp.amount), 0) > 0
|
|
||||||
AND s.created_at + INTERVAL '30 days' <= NOW() + INTERVAL '30 days'
|
|
||||||
ORDER BY due_date
|
|
||||||
LIMIT 50
|
|
||||||
""")
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
now = datetime.utcnow()
|
|
||||||
for r in cur.fetchall():
|
|
||||||
sale_id = r[0]
|
|
||||||
customer_name = r[1]
|
|
||||||
total = float(r[2]) if r[2] else 0
|
|
||||||
paid = float(r[5]) if r[5] else 0
|
|
||||||
balance = round(total - paid, 2)
|
|
||||||
created_at = r[3]
|
|
||||||
due_date = r[4]
|
|
||||||
days_until_due = (due_date.replace(tzinfo=None) - now).days if due_date else None
|
|
||||||
|
|
||||||
if days_until_due is None:
|
|
||||||
continue
|
|
||||||
status = 'overdue' if days_until_due < 0 else ('due_soon' if days_until_due <= 7 else 'current')
|
|
||||||
label = 'Vencida' if status == 'overdue' else ('Por vencer' if status == 'due_soon' else 'Al corriente')
|
|
||||||
|
|
||||||
rows.append({
|
|
||||||
'sale_id': sale_id,
|
|
||||||
'folio': f'VTA-{sale_id}',
|
|
||||||
'customer_name': customer_name,
|
|
||||||
'issue_date': created_at.isoformat() if created_at else None,
|
|
||||||
'due_date': due_date.isoformat() if due_date else None,
|
|
||||||
'days_until_due': days_until_due,
|
|
||||||
'total': total,
|
|
||||||
'paid': paid,
|
|
||||||
'balance': balance,
|
|
||||||
'status': status,
|
|
||||||
'status_label': label,
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'data': rows,
|
|
||||||
'overdue_count': sum(1 for r in rows if r['status'] == 'overdue'),
|
|
||||||
'due_soon_count': sum(1 for r in rows if r['status'] == 'due_soon'),
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ fleet_bp = Blueprint('fleet', __name__, url_prefix='/pos/api/fleet')
|
|||||||
# ─── Vehicles CRUD ─────────────────────────────
|
# ─── Vehicles CRUD ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles', methods=['GET'])
|
@fleet_bp.route('/vehicles', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def list_vehicles():
|
def list_vehicles():
|
||||||
"""List fleet vehicles with pagination and search.
|
"""List fleet vehicles with pagination and search.
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ def list_vehicles():
|
|||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def get_vehicle(vehicle_id):
|
def get_vehicle(vehicle_id):
|
||||||
"""Vehicle detail with maintenance schedules and recent logs."""
|
"""Vehicle detail with maintenance schedules and recent logs."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -155,128 +155,16 @@ def get_vehicle(vehicle_id):
|
|||||||
return jsonify(vehicle)
|
return jsonify(vehicle)
|
||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/schedules', methods=['GET'])
|
|
||||||
@require_auth('fleet.view')
|
|
||||||
def list_all_schedules():
|
|
||||||
"""Return all active maintenance schedules joined with vehicle data.
|
|
||||||
|
|
||||||
Replaces the N+1 pattern of fetching schedules per vehicle.
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
where_clauses = ["v.is_active = true", "s.is_active = true"]
|
|
||||||
params = []
|
|
||||||
if g.branch_id:
|
|
||||||
where_clauses.append("v.branch_id = %s")
|
|
||||||
params.append(g.branch_id)
|
|
||||||
|
|
||||||
vehicle_ids = request.args.get('vehicle_ids', '').strip()
|
|
||||||
if vehicle_ids:
|
|
||||||
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
|
|
||||||
if ids:
|
|
||||||
where_clauses.append("v.id = ANY(%s)")
|
|
||||||
params.append(ids)
|
|
||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT s.id, s.vehicle_id, s.maintenance_type, s.interval_km,
|
|
||||||
s.interval_months, s.last_done_at, s.last_done_km,
|
|
||||||
s.next_due_at, s.next_due_km, s.notes,
|
|
||||||
v.plate, v.make, v.model, v.current_mileage, v.color
|
|
||||||
FROM fleet_maintenance_schedules s
|
|
||||||
JOIN fleet_vehicles v ON v.id = s.vehicle_id
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY v.plate, s.next_due_at NULLS LAST, s.next_due_km NULLS LAST
|
|
||||||
""", params)
|
|
||||||
|
|
||||||
schedules = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
schedules.append({
|
|
||||||
'id': r[0], 'vehicle_id': r[1], 'maintenance_type': r[2],
|
|
||||||
'interval_km': r[3], 'interval_months': r[4],
|
|
||||||
'last_done_at': str(r[5]) if r[5] else None, 'last_done_km': r[6],
|
|
||||||
'next_due_at': str(r[7]) if r[7] else None, 'next_due_km': r[8],
|
|
||||||
'notes': r[9],
|
|
||||||
'vehicle': {
|
|
||||||
'plate': r[10], 'make': r[11], 'model': r[12],
|
|
||||||
'current_mileage': r[13], 'color': r[14]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'data': schedules})
|
|
||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/history', methods=['GET'])
|
|
||||||
@require_auth('fleet.view')
|
|
||||||
def list_all_history():
|
|
||||||
"""Return recent maintenance logs for all vehicles joined with vehicle data.
|
|
||||||
|
|
||||||
Replaces the N+1 pattern of fetching logs per vehicle.
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
limit = min(int(request.args.get('limit', 200)), 500)
|
|
||||||
|
|
||||||
where_clauses = ["v.is_active = true"]
|
|
||||||
params = [limit]
|
|
||||||
if g.branch_id:
|
|
||||||
where_clauses.append("v.branch_id = %s")
|
|
||||||
params.append(g.branch_id)
|
|
||||||
|
|
||||||
vehicle_ids = request.args.get('vehicle_ids', '').strip()
|
|
||||||
if vehicle_ids:
|
|
||||||
ids = [int(x) for x in vehicle_ids.split(',') if x.strip().isdigit()]
|
|
||||||
if ids:
|
|
||||||
where_clauses.append("v.id = ANY(%s)")
|
|
||||||
params.append(ids)
|
|
||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
|
||||||
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT l.id, l.vehicle_id, l.schedule_id, l.maintenance_type,
|
|
||||||
l.mileage_at, l.cost, l.parts_used, l.notes, l.created_at,
|
|
||||||
e.name as employee_name,
|
|
||||||
v.plate, v.make, v.model, v.color
|
|
||||||
FROM fleet_maintenance_logs l
|
|
||||||
JOIN fleet_vehicles v ON v.id = l.vehicle_id
|
|
||||||
LEFT JOIN employees e ON l.employee_id = e.id
|
|
||||||
WHERE {where}
|
|
||||||
ORDER BY l.created_at DESC
|
|
||||||
LIMIT %s
|
|
||||||
""", params)
|
|
||||||
|
|
||||||
logs = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
logs.append({
|
|
||||||
'id': r[0], 'vehicle_id': r[1], 'schedule_id': r[2],
|
|
||||||
'maintenance_type': r[3], 'mileage_at': r[4],
|
|
||||||
'cost': float(r[5]) if r[5] else 0, 'parts_used': r[6],
|
|
||||||
'notes': r[7], 'created_at': str(r[8]) if r[8] else None,
|
|
||||||
'employee_name': r[9],
|
|
||||||
'vehicle': {'plate': r[10], 'make': r[11], 'model': r[12], 'color': r[13]}
|
|
||||||
})
|
|
||||||
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'data': logs})
|
|
||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles', methods=['POST'])
|
@fleet_bp.route('/vehicles', methods=['POST'])
|
||||||
@require_auth('fleet.create')
|
@require_auth()
|
||||||
def create_vehicle():
|
def create_vehicle():
|
||||||
"""Create a fleet vehicle.
|
"""Create a fleet vehicle.
|
||||||
|
|
||||||
Body: {customer_id, plate, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
|
Body: {plate, vin, make, model, year, current_mileage, fuel_type, color, owner_name, notes}
|
||||||
Brand and model are required; plate is optional; VIN is not used.
|
|
||||||
"""
|
"""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not data.get('make') or not data.get('model'):
|
if not data.get('plate') and not data.get('vin'):
|
||||||
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
|
return jsonify({'error': 'plate or vin is required'}), 400
|
||||||
if not data.get('customer_id'):
|
|
||||||
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
|
|
||||||
|
|
||||||
branch_id = data.get('branch_id', g.branch_id)
|
branch_id = data.get('branch_id', g.branch_id)
|
||||||
|
|
||||||
@@ -284,23 +172,17 @@ def create_vehicle():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
|
|
||||||
cust = cur.fetchone()
|
|
||||||
if not cust:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Cliente no encontrado'}), 404
|
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO fleet_vehicles
|
INSERT INTO fleet_vehicles
|
||||||
(branch_id, customer_id, plate, make, model, year,
|
(branch_id, plate, vin, make, model, year,
|
||||||
current_mileage, fuel_type, color, owner_name, notes)
|
current_mileage, fuel_type, color, owner_name, notes)
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""", (
|
""", (
|
||||||
branch_id, data['customer_id'], data.get('plate'),
|
branch_id, data.get('plate'), data.get('vin'),
|
||||||
data.get('make'), data.get('model'), data.get('year'),
|
data.get('make'), data.get('model'), data.get('year'),
|
||||||
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
||||||
data.get('color'), data.get('owner_name') or cust[0], data.get('notes'),
|
data.get('color'), data.get('owner_name'), data.get('notes'),
|
||||||
))
|
))
|
||||||
vehicle_id = cur.fetchone()[0]
|
vehicle_id = cur.fetchone()[0]
|
||||||
|
|
||||||
@@ -319,7 +201,7 @@ def create_vehicle():
|
|||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['PUT'])
|
||||||
@require_auth('fleet.edit')
|
@require_auth()
|
||||||
def update_vehicle(vehicle_id):
|
def update_vehicle(vehicle_id):
|
||||||
"""Update vehicle fields including mileage.
|
"""Update vehicle fields including mileage.
|
||||||
|
|
||||||
@@ -363,7 +245,7 @@ def update_vehicle(vehicle_id):
|
|||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>', methods=['DELETE'])
|
||||||
@require_auth('fleet.delete')
|
@require_auth()
|
||||||
def deactivate_vehicle(vehicle_id):
|
def deactivate_vehicle(vehicle_id):
|
||||||
"""Soft-delete: set is_active = false."""
|
"""Soft-delete: set is_active = false."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -384,7 +266,7 @@ def deactivate_vehicle(vehicle_id):
|
|||||||
# ─── Maintenance Schedules ─────────────────────────────
|
# ─── Maintenance Schedules ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def list_schedules(vehicle_id):
|
def list_schedules(vehicle_id):
|
||||||
"""Maintenance schedules for a vehicle."""
|
"""Maintenance schedules for a vehicle."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -417,7 +299,7 @@ def list_schedules(vehicle_id):
|
|||||||
|
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>/schedules', methods=['POST'])
|
||||||
@require_auth('fleet.create')
|
@require_auth()
|
||||||
def create_schedule(vehicle_id):
|
def create_schedule(vehicle_id):
|
||||||
"""Create maintenance schedule for a vehicle.
|
"""Create maintenance schedule for a vehicle.
|
||||||
|
|
||||||
@@ -462,7 +344,7 @@ def create_schedule(vehicle_id):
|
|||||||
# ─── Maintenance Logs ─────────────────────────────
|
# ─── Maintenance Logs ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
|
@fleet_bp.route('/vehicles/<int:vehicle_id>/log', methods=['POST'])
|
||||||
@require_auth('fleet.create')
|
@require_auth()
|
||||||
def record_maintenance(vehicle_id):
|
def record_maintenance(vehicle_id):
|
||||||
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
|
"""Record maintenance done. Updates schedule next_due if schedule_id provided.
|
||||||
|
|
||||||
@@ -545,7 +427,7 @@ def record_maintenance(vehicle_id):
|
|||||||
# ─── Alerts ─────────────────────────────
|
# ─── Alerts ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/alerts', methods=['GET'])
|
@fleet_bp.route('/alerts', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def fleet_alerts():
|
def fleet_alerts():
|
||||||
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
|
"""Vehicles with overdue maintenance (next_due_at < NOW() or next_due_km < current_mileage)."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -590,7 +472,7 @@ def fleet_alerts():
|
|||||||
# ─── Stats ─────────────────────────────
|
# ─── Stats ─────────────────────────────
|
||||||
|
|
||||||
@fleet_bp.route('/stats', methods=['GET'])
|
@fleet_bp.route('/stats', methods=['GET'])
|
||||||
@require_auth('fleet.view')
|
@require_auth()
|
||||||
def fleet_stats():
|
def fleet_stats():
|
||||||
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
|
"""Fleet summary: total vehicles, overdue count, upcoming this month, total cost this month."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import subprocess
|
|||||||
import socket
|
import socket
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from config import INTERNAL_API_KEY
|
from config import INTERNAL_API_KEY
|
||||||
from tenant_db import get_master_conn, get_tenant_conn, get_tenant_conn_by_dbname
|
from tenant_db import get_master_conn, get_tenant_conn
|
||||||
|
|
||||||
internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal')
|
internal_bp = Blueprint('internal', __name__, url_prefix='/pos/api/internal')
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,9 @@ import json
|
|||||||
import os
|
import os
|
||||||
import csv
|
import csv
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from flask import Blueprint, request, jsonify, g, Response
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth, has_permission
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.reorder_engine import (
|
|
||||||
generate_alerts, list_alerts, acknowledge_alert, resolve_alert,
|
|
||||||
suggest_po_from_alerts,
|
|
||||||
)
|
|
||||||
from services.inventory_engine import (
|
from services.inventory_engine import (
|
||||||
get_stock, get_stock_bulk, record_purchase, record_return,
|
get_stock, get_stock_bulk, record_purchase, record_return,
|
||||||
record_adjustment, record_transfer, record_initial,
|
record_adjustment, record_transfer, record_initial,
|
||||||
@@ -69,275 +65,14 @@ def _to_int(val, default=0):
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
# ─── Vehicle resolution (tablas de vehículos viven en la MAESTRA) ──────────
|
|
||||||
_MYE_LOOKUP_CACHE = {}
|
|
||||||
_MYE_TABLES_MISSING = {'flag': False}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_mye_master(make, model, year):
|
|
||||||
"""Resuelve model_year_engine_id contra la BD maestra; None si no existe.
|
|
||||||
|
|
||||||
`brands`/`models`/`years`/`model_year_engine` sólo existen en la maestra:
|
|
||||||
consultarlas en la conexión del tenant lanzaba UndefinedTable y devolvía
|
|
||||||
500 en todo el bulk-import cuando el CSV traía make/model/year.
|
|
||||||
"""
|
|
||||||
key = (make.strip().lower(), model.strip().lower(), int(year))
|
|
||||||
if key in _MYE_LOOKUP_CACHE:
|
|
||||||
return _MYE_LOOKUP_CACHE[key]
|
|
||||||
if _MYE_TABLES_MISSING['flag']:
|
|
||||||
return None
|
|
||||||
|
|
||||||
result = None
|
|
||||||
try:
|
|
||||||
mconn = get_master_conn()
|
|
||||||
try:
|
|
||||||
mcur = mconn.cursor()
|
|
||||||
try:
|
|
||||||
mcur.execute("""
|
|
||||||
SELECT mye.id_mye FROM model_year_engine mye
|
|
||||||
JOIN models m ON m.id_model = mye.model_id
|
|
||||||
JOIN brands b ON b.id_brand = m.brand_id
|
|
||||||
JOIN years y ON y.id_year = mye.year_id
|
|
||||||
WHERE LOWER(b.name_brand) = LOWER(%s)
|
|
||||||
AND LOWER(m.name_model) = LOWER(%s)
|
|
||||||
AND y.year_car = %s
|
|
||||||
LIMIT 1
|
|
||||||
""", (make, model, year))
|
|
||||||
row = mcur.fetchone()
|
|
||||||
result = row[0] if row else None
|
|
||||||
finally:
|
|
||||||
mcur.close()
|
|
||||||
finally:
|
|
||||||
mconn.close()
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
from psycopg2.errors import UndefinedTable, UndefinedColumn
|
|
||||||
missing = isinstance(e, (UndefinedTable, UndefinedColumn))
|
|
||||||
except Exception:
|
|
||||||
missing = 'does not exist' in str(e)
|
|
||||||
if missing:
|
|
||||||
# Esquema ausente: no se reintenta; la compat se guarda como texto.
|
|
||||||
_MYE_TABLES_MISSING['flag'] = True
|
|
||||||
return None
|
|
||||||
|
|
||||||
_MYE_LOOKUP_CACHE[key] = result
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ─── AI Classification ───────────────────────────
|
# ─── AI Classification ───────────────────────────
|
||||||
|
|
||||||
@inventory_bp.route('/classify/<part_number>', methods=['GET', 'POST'])
|
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
|
||||||
@require_auth('inventory.create')
|
@require_auth('inventory.create')
|
||||||
def classify_part_endpoint(part_number):
|
def classify_part_endpoint(part_number):
|
||||||
"""Ask AI to identify a part by its OEM number and persist its category.
|
"""Ask AI to identify a part by its OEM number."""
|
||||||
|
|
||||||
In addition to the raw LLM result ({name, brand, vehicle, category}) this
|
|
||||||
now resolves the returned category to a tenant ``categories.id`` and stores
|
|
||||||
it in ``inventory.category_id`` when the part exists in the tenant catalog.
|
|
||||||
"""
|
|
||||||
from services.ai_chat import classify_part
|
from services.ai_chat import classify_part
|
||||||
from services.product_categorizer import CategoryResolver, map_llm_category
|
|
||||||
|
|
||||||
result = classify_part(part_number)
|
result = classify_part(part_number)
|
||||||
|
|
||||||
category_id = None
|
|
||||||
category_name = None
|
|
||||||
persisted = 0
|
|
||||||
raw_cat = (result or {}).get('category') if isinstance(result, dict) else None
|
|
||||||
if raw_cat:
|
|
||||||
canonical = map_llm_category(raw_cat)
|
|
||||||
if canonical:
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
resolver = CategoryResolver(conn, create=True)
|
|
||||||
category_id, category_name = resolver.resolve(canonical)
|
|
||||||
if category_id is not None:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"UPDATE inventory SET category_id = %s "
|
|
||||||
"WHERE part_number = %s AND is_active = true "
|
|
||||||
"AND (category_id IS NULL OR category_id <> %s)",
|
|
||||||
(category_id, part_number, category_id),
|
|
||||||
)
|
|
||||||
persisted = cur.rowcount
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
conn.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if isinstance(result, dict):
|
|
||||||
result['category_id'] = category_id
|
|
||||||
result['category_name'] = category_name
|
|
||||||
result['persisted'] = persisted
|
|
||||||
return jsonify(result)
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/categorize', methods=['POST'])
|
|
||||||
@require_auth('inventory.edit')
|
|
||||||
def categorize_inventory_endpoint():
|
|
||||||
"""Auto-categorize inventory items and persist ``category_id``.
|
|
||||||
|
|
||||||
Body options:
|
|
||||||
{"items": [{"part_number", "name", "brand"}, ...]} # explicit list
|
|
||||||
{"all": true, "limit": N, "only_uncategorized": true} # batch over catalog
|
|
||||||
Optional: use_llm (LLM fallback per item), create_categories (default true).
|
|
||||||
"""
|
|
||||||
from services.product_categorizer import categorize_items, categorize_inventory
|
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
use_llm = bool(data.get('use_llm'))
|
|
||||||
create_categories = bool(data.get('create_categories', True))
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
if data.get('all'):
|
|
||||||
limit = min(int(data.get('limit', 200) or 200), 5000)
|
|
||||||
summary = categorize_inventory(
|
|
||||||
conn, limit=limit,
|
|
||||||
only_uncategorized=bool(data.get('only_uncategorized', True)),
|
|
||||||
use_llm=use_llm, persist=True,
|
|
||||||
create_categories=create_categories,
|
|
||||||
offset=int(data.get('offset', 0) or 0),
|
|
||||||
after_id=data.get('after_id'),
|
|
||||||
)
|
|
||||||
return jsonify({'mode': 'all', **summary})
|
|
||||||
|
|
||||||
items = data.get('items')
|
|
||||||
if not items or not isinstance(items, list):
|
|
||||||
return jsonify({'error': 'items[] o all:true requerido'}), 400
|
|
||||||
summary = categorize_items(
|
|
||||||
conn, items[:2000], use_llm=use_llm, persist=True,
|
|
||||||
create_categories=create_categories,
|
|
||||||
)
|
|
||||||
return jsonify({'mode': 'items', **summary})
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
conn.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/match-catalog', methods=['POST'])
|
|
||||||
@require_auth('inventory.edit')
|
|
||||||
def match_catalog_endpoint():
|
|
||||||
"""Match inventory SKUs against the supplier catalog.
|
|
||||||
|
|
||||||
Populates ``inventory.catalog_part_id`` and registers matched supplier SKUs
|
|
||||||
as ``inventory_sku_aliases``. Body options: limit, offset, only_unmatched,
|
|
||||||
create_aliases, use_names, name_threshold, apply(false=dry run), after_id.
|
|
||||||
"""
|
|
||||||
from services.sku_matcher import match_inventory, count_links
|
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
|
||||||
limit = data.get('limit')
|
|
||||||
if limit is not None:
|
|
||||||
limit = min(int(limit), 50000)
|
|
||||||
|
|
||||||
tenant_conn = get_tenant_conn(g.tenant_id)
|
|
||||||
master_conn = get_master_conn()
|
|
||||||
try:
|
|
||||||
before = count_links(tenant_conn)
|
|
||||||
summary = match_inventory(
|
|
||||||
tenant_conn, master_conn, limit=limit,
|
|
||||||
offset=int(data.get('offset', 0) or 0),
|
|
||||||
only_unmatched=bool(data.get('only_unmatched', True)),
|
|
||||||
create_aliases=bool(data.get('create_aliases', True)),
|
|
||||||
use_names=bool(data.get('use_names', True)),
|
|
||||||
name_threshold=float(data.get('name_threshold', 0.82)),
|
|
||||||
apply=bool(data.get('apply', True)),
|
|
||||||
supplier=data.get('supplier'),
|
|
||||||
after_id=data.get('after_id'),
|
|
||||||
)
|
|
||||||
after = count_links(tenant_conn)
|
|
||||||
summary['before'] = {'total': before[0], 'linked': before[1]}
|
|
||||||
summary['after'] = {'total': after[0], 'linked': after[1]}
|
|
||||||
return jsonify(summary)
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
tenant_conn.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
finally:
|
|
||||||
tenant_conn.close()
|
|
||||||
master_conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/supplier-invoice/parse', methods=['POST'])
|
|
||||||
@require_auth('inventory.create')
|
|
||||||
def supplier_invoice_parse_endpoint():
|
|
||||||
"""OCR/parse a supplier invoice (image or PDF) into structured lines.
|
|
||||||
|
|
||||||
Accepts multipart ``file`` or JSON ``{file_base64, filename}``. Returns the
|
|
||||||
detected lines plus an inventory/catalog match for each part number. Does
|
|
||||||
NOT create stock — the front-end confirms before a purchase is recorded.
|
|
||||||
"""
|
|
||||||
import base64 as _b64
|
|
||||||
from services.invoice_ocr import parse_invoice
|
|
||||||
|
|
||||||
data = None
|
|
||||||
filename = None
|
|
||||||
content_type = None
|
|
||||||
f = request.files.get('file')
|
|
||||||
if f:
|
|
||||||
data = f.read()
|
|
||||||
filename = f.filename
|
|
||||||
content_type = f.content_type
|
|
||||||
else:
|
|
||||||
body = request.get_json(silent=True) or {}
|
|
||||||
raw = body.get('file_base64')
|
|
||||||
if raw:
|
|
||||||
if raw.startswith('data:'):
|
|
||||||
raw = raw.split(',', 1)[1]
|
|
||||||
try:
|
|
||||||
data = _b64.b64decode(raw)
|
|
||||||
except Exception:
|
|
||||||
return jsonify({'error': 'file_base64 inválido'}), 400
|
|
||||||
filename = body.get('filename')
|
|
||||||
if not data:
|
|
||||||
return jsonify({'error': 'file (multipart) o file_base64 requerido'}), 400
|
|
||||||
|
|
||||||
use_vision = request.args.get('vision', '1').lower() not in ('0', 'false', 'no')
|
|
||||||
result = parse_invoice(data, filename=filename, content_type=content_type,
|
|
||||||
use_vision=use_vision)
|
|
||||||
|
|
||||||
# Enrich each line with the tenant inventory item it points at.
|
|
||||||
norm = {}
|
|
||||||
for ln in result.get('lines', []):
|
|
||||||
pn = (ln.get('part_number') or '').upper().replace(' ', '')
|
|
||||||
if pn:
|
|
||||||
norm[pn] = None
|
|
||||||
if norm:
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id, UPPER(REPLACE(part_number, ' ', '')) AS npn, name, cost "
|
|
||||||
"FROM inventory WHERE is_active = true AND "
|
|
||||||
"UPPER(REPLACE(part_number, ' ', '')) = ANY(%s)",
|
|
||||||
(list(norm.keys()),),
|
|
||||||
)
|
|
||||||
for inv_id, npn, name, cost in cur.fetchall():
|
|
||||||
norm[npn] = {'inventory_id': inv_id, 'name': name,
|
|
||||||
'cost': float(cost) if cost is not None else None}
|
|
||||||
cur.close()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
for ln in result.get('lines', []):
|
|
||||||
pn = (ln.get('part_number') or '').upper().replace(' ', '')
|
|
||||||
hit = norm.get(pn)
|
|
||||||
ln['matched_inventory_id'] = hit['inventory_id'] if hit else None
|
|
||||||
if hit and not ln.get('name'):
|
|
||||||
ln['name'] = hit['name']
|
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@@ -368,13 +103,8 @@ def list_items():
|
|||||||
# branch_id no longer filters inventory rows (shared catalog).
|
# branch_id no longer filters inventory rows (shared catalog).
|
||||||
# It is used only to show per-branch stock.
|
# It is used only to show per-branch stock.
|
||||||
if search:
|
if search:
|
||||||
# Search also matches alternate / alias SKUs.
|
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
|
||||||
where_clauses.append(
|
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||||
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s "
|
|
||||||
"OR EXISTS (SELECT 1 FROM inventory_sku_aliases a "
|
|
||||||
"WHERE a.inventory_id = i.id AND a.is_active = true AND a.sku ILIKE %s))"
|
|
||||||
)
|
|
||||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'])
|
|
||||||
if category:
|
if category:
|
||||||
where_clauses.append("i.category_id = %s")
|
where_clauses.append("i.category_id = %s")
|
||||||
params.append(int(category))
|
params.append(int(category))
|
||||||
@@ -679,119 +409,6 @@ def create_item():
|
|||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/export', methods=['GET'])
|
|
||||||
@require_auth('inventory.view')
|
|
||||||
def export_inventory():
|
|
||||||
"""Export the inventory as CSV, compatible with the bulk importer.
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
branch_id: optional. When given, the stock (and location) columns reflect
|
|
||||||
only that branch, and a branch_id column is appended so the
|
|
||||||
file can be re-imported into the same branch.
|
|
||||||
|
|
||||||
Columns: part_number, sku_secondary, name, brand, category, cost, price,
|
|
||||||
price_2, price_3, stock, location, description, barcode, unit[, branch_id]
|
|
||||||
- without branch_id, stock is the total across all branches.
|
|
||||||
- cost is blank unless the user has the inventory.view_cost permission.
|
|
||||||
Streamed in chunks to support large catalogs.
|
|
||||||
"""
|
|
||||||
include_cost = has_permission('inventory.view_cost')
|
|
||||||
branch_id = request.args.get('branch_id', type=int)
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
branch_name = None
|
|
||||||
if branch_id:
|
|
||||||
cur.execute("SELECT name FROM branches WHERE id = %s", (branch_id,))
|
|
||||||
brow = cur.fetchone()
|
|
||||||
if not brow:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': f'Branch {branch_id} not found'}), 400
|
|
||||||
branch_name = brow[0]
|
|
||||||
cur.execute("""
|
|
||||||
SELECT i.part_number,
|
|
||||||
(SELECT a.sku FROM inventory_sku_aliases a
|
|
||||||
WHERE a.inventory_id = i.id ORDER BY a.id LIMIT 1) AS sku_secondary,
|
|
||||||
i.name, i.brand, c.name AS category, i.cost,
|
|
||||||
i.price_1, i.price_2, i.price_3,
|
|
||||||
COALESCE(ist.stock, 0) AS stock,
|
|
||||||
COALESCE(ist.location, i.location) AS location,
|
|
||||||
i.description, i.barcode, i.unit
|
|
||||||
FROM inventory i
|
|
||||||
LEFT JOIN categories c ON c.id = i.category_id
|
|
||||||
LEFT JOIN inventory_stock ist
|
|
||||||
ON ist.inventory_id = i.id AND ist.branch_id = %s
|
|
||||||
WHERE i.is_active = true
|
|
||||||
ORDER BY i.part_number
|
|
||||||
""", (branch_id,))
|
|
||||||
else:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT i.part_number,
|
|
||||||
(SELECT a.sku FROM inventory_sku_aliases a
|
|
||||||
WHERE a.inventory_id = i.id ORDER BY a.id LIMIT 1) AS sku_secondary,
|
|
||||||
i.name, i.brand, c.name AS category, i.cost,
|
|
||||||
i.price_1, i.price_2, i.price_3,
|
|
||||||
COALESCE(st.total_stock, 0) AS stock,
|
|
||||||
i.location, i.description, i.barcode, i.unit
|
|
||||||
FROM inventory i
|
|
||||||
LEFT JOIN categories c ON c.id = i.category_id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT inventory_id, SUM(stock) AS total_stock
|
|
||||||
FROM inventory_stock GROUP BY inventory_id
|
|
||||||
) st ON st.inventory_id = i.id
|
|
||||||
WHERE i.is_active = true
|
|
||||||
ORDER BY i.part_number
|
|
||||||
""")
|
|
||||||
|
|
||||||
def generate():
|
|
||||||
output = io.StringIO()
|
|
||||||
writer = csv.writer(output)
|
|
||||||
try:
|
|
||||||
header = ['part_number', 'sku_secondary', 'name', 'brand',
|
|
||||||
'category', 'cost', 'price', 'price_2', 'price_3',
|
|
||||||
'stock', 'location', 'description', 'barcode', 'unit']
|
|
||||||
if branch_id:
|
|
||||||
header.append('branch_id')
|
|
||||||
writer.writerow(header)
|
|
||||||
# BOM so Excel opens UTF-8 correctly
|
|
||||||
yield '\ufeff' + output.getvalue()
|
|
||||||
output.seek(0); output.truncate(0)
|
|
||||||
while True:
|
|
||||||
rows = cur.fetchmany(1000)
|
|
||||||
if not rows:
|
|
||||||
break
|
|
||||||
for r in rows:
|
|
||||||
row = [
|
|
||||||
r[0] or '', r[1] or '', r[2] or '', r[3] or '', r[4] or '',
|
|
||||||
f'{float(r[5]):.2f}' if include_cost and r[5] is not None else '',
|
|
||||||
f'{float(r[6]):.2f}' if r[6] is not None else '0.00',
|
|
||||||
f'{float(r[7]):.2f}' if r[7] is not None else '',
|
|
||||||
f'{float(r[8]):.2f}' if r[8] is not None else '',
|
|
||||||
int(r[9]) if r[9] is not None else 0,
|
|
||||||
r[10] or '', r[11] or '', r[12] or '', r[13] or '',
|
|
||||||
]
|
|
||||||
if branch_id:
|
|
||||||
row.append(branch_id)
|
|
||||||
writer.writerow(row)
|
|
||||||
yield output.getvalue()
|
|
||||||
output.seek(0); output.truncate(0)
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if branch_id:
|
|
||||||
safe_branch = ''.join(ch if ch.isalnum() else '_' for ch in (branch_name or str(branch_id)))
|
|
||||||
filename = f"inventario_{safe_branch}_{datetime.now().strftime('%Y%m%d_%H%M')}.csv"
|
|
||||||
else:
|
|
||||||
filename = f"inventario_completo_{datetime.now().strftime('%Y%m%d_%H%M')}.csv"
|
|
||||||
return Response(
|
|
||||||
generate(),
|
|
||||||
mimetype='text/csv; charset=utf-8',
|
|
||||||
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/items/bulk-import', methods=['POST'])
|
@inventory_bp.route('/items/bulk-import', methods=['POST'])
|
||||||
@require_auth('inventory.edit')
|
@require_auth('inventory.edit')
|
||||||
def bulk_import_items():
|
def bulk_import_items():
|
||||||
@@ -800,15 +417,11 @@ def bulk_import_items():
|
|||||||
Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body.
|
Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body.
|
||||||
Headers:
|
Headers:
|
||||||
X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows.
|
X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows.
|
||||||
X-Import-Strategy: 'skip' (default) ignores missing compat and never
|
X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN;
|
||||||
calls QWEN; 'qwen' auto-generates missing compat via
|
'skip' ignores missing compat; 'reject' requires all compat.
|
||||||
QWEN (opt-in: 20.7M noisy fitment rows so far);
|
|
||||||
'reject' requires all compat.
|
|
||||||
X-Import-Stock: 'add' (default) sums stock to existing items;
|
|
||||||
'skip' only updates data/prices of existing items without touching stock.
|
|
||||||
Expected CSV columns (case-insensitive):
|
Expected CSV columns (case-insensitive):
|
||||||
sku/part_number, name, brand, price, stock, cost,
|
sku/part_number, name, brand, price, stock, cost,
|
||||||
sku_secondary, description, category, make, model, year, engine, engine_code
|
location, description, category, make, model, year, engine, engine_code
|
||||||
Optional compat columns: make, model, year, engine, engine_code
|
Optional compat columns: make, model, year, engine, engine_code
|
||||||
"""
|
"""
|
||||||
from services.qwen_fitment import get_vehicle_fitment
|
from services.qwen_fitment import get_vehicle_fitment
|
||||||
@@ -816,8 +429,7 @@ def bulk_import_items():
|
|||||||
import services.inventory_vehicle_compat as ivc_service
|
import services.inventory_vehicle_compat as ivc_service
|
||||||
|
|
||||||
mode = request.headers.get('X-Import-Mode', 'strict').lower()
|
mode = request.headers.get('X-Import-Mode', 'strict').lower()
|
||||||
strategy = request.headers.get('X-Import-Strategy', 'skip').lower()
|
strategy = request.headers.get('X-Import-Strategy', 'qwen').lower()
|
||||||
stock_mode = request.headers.get('X-Import-Stock', 'add').lower()
|
|
||||||
errors = []
|
errors = []
|
||||||
warnings = []
|
warnings = []
|
||||||
created_ids = []
|
created_ids = []
|
||||||
@@ -833,17 +445,8 @@ def bulk_import_items():
|
|||||||
try:
|
try:
|
||||||
ext = os.path.splitext(file.filename)[1].lower()
|
ext = os.path.splitext(file.filename)[1].lower()
|
||||||
if ext == '.csv':
|
if ext == '.csv':
|
||||||
raw = file.stream.read()
|
stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
|
||||||
decoded = None
|
reader = csv.DictReader(stream)
|
||||||
for enc in ('utf-8-sig', 'cp1252', 'latin-1'):
|
|
||||||
try:
|
|
||||||
decoded = raw.decode(enc)
|
|
||||||
break
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
continue
|
|
||||||
if decoded is None:
|
|
||||||
return jsonify({'error': 'Unable to detect CSV encoding. Please save the file as UTF-8.'}), 400
|
|
||||||
reader = csv.DictReader(io.StringIO(decoded))
|
|
||||||
rows = list(reader)
|
rows = list(reader)
|
||||||
elif ext in ('.xls', '.xlsx', '.xlsm'):
|
elif ext in ('.xls', '.xlsx', '.xlsm'):
|
||||||
try:
|
try:
|
||||||
@@ -887,7 +490,6 @@ def bulk_import_items():
|
|||||||
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
||||||
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
||||||
'ubicacion': 'location', 'categoria': 'category',
|
'ubicacion': 'location', 'categoria': 'category',
|
||||||
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
|
|
||||||
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
||||||
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
||||||
'motor': 'engine', 'codigo_motor': 'engine_code',
|
'motor': 'engine', 'codigo_motor': 'engine_code',
|
||||||
@@ -918,10 +520,6 @@ def bulk_import_items():
|
|||||||
db_name = db_name_row[0] if db_name_row else None
|
db_name = db_name_row[0] if db_name_row else None
|
||||||
mcur.close(); mconn.close()
|
mcur.close(); mconn.close()
|
||||||
|
|
||||||
# Pre-fetch category name -> id mapping
|
|
||||||
cur.execute("SELECT id, name FROM categories")
|
|
||||||
category_map = {str(r[1]).strip().lower(): r[0] for r in cur.fetchall()}
|
|
||||||
|
|
||||||
for row_num, row in enumerate(rows, start=1):
|
for row_num, row in enumerate(rows, start=1):
|
||||||
part_number = str(row.get('part_number', '')).strip()
|
part_number = str(row.get('part_number', '')).strip()
|
||||||
name = str(row.get('name', '')).strip()
|
name = str(row.get('name', '')).strip()
|
||||||
@@ -950,10 +548,7 @@ def bulk_import_items():
|
|||||||
cost = _to_decimal(row.get('cost'), 0)
|
cost = _to_decimal(row.get('cost'), 0)
|
||||||
location = str(row.get('location', '')).strip()
|
location = str(row.get('location', '')).strip()
|
||||||
description = str(row.get('description', '')).strip()
|
description = str(row.get('description', '')).strip()
|
||||||
category_name = str(row.get('category', '')).strip()
|
category = str(row.get('category', '')).strip()
|
||||||
category_id = category_map.get(category_name.lower()) if category_name else None
|
|
||||||
if category_name and category_id is None:
|
|
||||||
warnings.append(f'Row {row_num}: categoria "{category_name}" no encontrada')
|
|
||||||
|
|
||||||
# Check if item already exists (catalog is shared across branches)
|
# Check if item already exists (catalog is shared across branches)
|
||||||
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
|
cur.execute("SELECT id FROM inventory WHERE part_number = %s", (part_number,))
|
||||||
@@ -971,14 +566,14 @@ def bulk_import_items():
|
|||||||
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
|
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
|
||||||
location = COALESCE(NULLIF(%s,''), location),
|
location = COALESCE(NULLIF(%s,''), location),
|
||||||
description = COALESCE(NULLIF(%s,''), description),
|
description = COALESCE(NULLIF(%s,''), description),
|
||||||
category_id = COALESCE(%s, category_id)
|
category = COALESCE(NULLIF(%s,''), category)
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""",
|
""",
|
||||||
(name, brand, cost, cost, price_1, price_1, location, description, category_id, item_id)
|
(name, brand, cost, cost, price_1, price_1, location, description, category, item_id)
|
||||||
)
|
)
|
||||||
was_inserted = False
|
was_inserted = False
|
||||||
# Record stock adjustment for existing item if stock > 0 (unless skipped)
|
# Record stock adjustment for existing item if stock > 0
|
||||||
if stock > 0 and stock_mode != 'skip':
|
if stock > 0:
|
||||||
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
||||||
else:
|
else:
|
||||||
# Generate barcode for new item
|
# Generate barcode for new item
|
||||||
@@ -987,11 +582,11 @@ def bulk_import_items():
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO inventory
|
INSERT INTO inventory
|
||||||
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, unit)
|
(part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(part_number, barcode, name, brand, cost, price_1, location, description, category_id, 'PZA')
|
(part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
|
||||||
)
|
)
|
||||||
item_id = cur.fetchone()[0]
|
item_id = cur.fetchone()[0]
|
||||||
was_inserted = True
|
was_inserted = True
|
||||||
@@ -1004,20 +599,7 @@ def bulk_import_items():
|
|||||||
created_ids.append(item_id)
|
created_ids.append(item_id)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
# ---------- 2. Secondary SKU alias ----------
|
# ---------- 2. Vehicle compatibility ----------
|
||||||
sku_secondary = str(row.get('sku_secondary', '')).strip()
|
|
||||||
if sku_secondary:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
""",
|
|
||||||
(item_id, sku_secondary, 'Secundario')
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# ---------- 3. Vehicle compatibility ----------
|
|
||||||
make = str(row.get('make', '')).strip()
|
make = str(row.get('make', '')).strip()
|
||||||
model = str(row.get('model', '')).strip()
|
model = str(row.get('model', '')).strip()
|
||||||
year_str = str(row.get('year', '')).strip()
|
year_str = str(row.get('year', '')).strip()
|
||||||
@@ -1031,9 +613,24 @@ def bulk_import_items():
|
|||||||
year = _to_int(year_str, None)
|
year = _to_int(year_str, None)
|
||||||
mye_id = None
|
mye_id = None
|
||||||
if make and model and year:
|
if make and model and year:
|
||||||
# Resolver contra la BD maestra (no la del tenant).
|
# Try exact match against model_year_engine
|
||||||
mye_id = _resolve_mye_master(make, model, year)
|
cur.execute(
|
||||||
if mye_id is None:
|
"""
|
||||||
|
SELECT mye.id FROM model_year_engine mye
|
||||||
|
JOIN models m ON m.id = mye.model_id
|
||||||
|
JOIN brands b ON b.id = m.brand_id
|
||||||
|
JOIN years y ON y.id = mye.year_id
|
||||||
|
WHERE LOWER(b.name) = LOWER(%s)
|
||||||
|
AND LOWER(m.name) = LOWER(%s)
|
||||||
|
AND y.year = %s
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(make, model, year)
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if r:
|
||||||
|
mye_id = r[0]
|
||||||
|
else:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
|
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
|
||||||
'saving as text-only compatibility.'
|
'saving as text-only compatibility.'
|
||||||
@@ -1446,251 +1043,6 @@ def api_adjustment():
|
|||||||
return jsonify({'error': str(e)}), 400
|
return jsonify({'error': str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/items/lookup', methods=['GET'])
|
|
||||||
@require_auth('inventory.view')
|
|
||||||
def lookup_item_by_code():
|
|
||||||
"""Exact lookup by barcode / part_number / SKU alias (for scanner flows)."""
|
|
||||||
code = (request.args.get('code') or '').strip()
|
|
||||||
if not code:
|
|
||||||
return jsonify({'error': 'code requerido'}), 400
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
norm = code.upper().replace(' ', '')
|
|
||||||
cur.execute("""
|
|
||||||
SELECT i.id, i.part_number, i.name, i.barcode,
|
|
||||||
COALESCE(s.stock, 0) AS stock, i.price_1
|
|
||||||
FROM inventory i
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
|
||||||
GROUP BY inventory_id
|
|
||||||
) s ON s.inventory_id = i.id
|
|
||||||
WHERE i.is_active = true AND (
|
|
||||||
UPPER(i.barcode) = %s
|
|
||||||
OR UPPER(REPLACE(i.part_number, ' ', '')) = %s
|
|
||||||
OR i.id IN (
|
|
||||||
SELECT a.inventory_id FROM inventory_sku_aliases a
|
|
||||||
WHERE UPPER(REPLACE(a.sku, ' ', '')) = %s))
|
|
||||||
LIMIT 1
|
|
||||||
""", (code, norm, norm))
|
|
||||||
row = cur.fetchone()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
if not row:
|
|
||||||
return jsonify({'found': False}), 404
|
|
||||||
return jsonify({'found': True, 'item': {
|
|
||||||
'id': row[0], 'part_number': row[1], 'name': row[2],
|
|
||||||
'barcode': row[3], 'stock': int(row[4] or 0),
|
|
||||||
'price_1': float(row[5] or 0),
|
|
||||||
}})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Transfer documents (in-transit tracking) ──────────────────────────────
|
|
||||||
|
|
||||||
@inventory_bp.route('/transfer-docs', methods=['GET'])
|
|
||||||
@require_auth('inventory.view')
|
|
||||||
def list_transfer_docs():
|
|
||||||
"""List transfer documents. Query: status, page, per_page."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
status = request.args.get('status')
|
|
||||||
where = "WHERE t.status = %s" if status else ""
|
|
||||||
params = [status] if status else []
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT t.id, t.from_branch_id, b1.name, t.to_branch_id, b2.name,
|
|
||||||
t.status, t.notes, t.created_at, t.sent_at, t.received_at,
|
|
||||||
COALESCE(SUM(l.quantity), 0) AS total_qty
|
|
||||||
FROM inventory_transfers t
|
|
||||||
JOIN branches b1 ON b1.id = t.from_branch_id
|
|
||||||
JOIN branches b2 ON b2.id = t.to_branch_id
|
|
||||||
LEFT JOIN inventory_transfer_lines l ON l.transfer_id = t.id
|
|
||||||
{where}
|
|
||||||
GROUP BY t.id, b1.name, b2.name
|
|
||||||
ORDER BY t.id DESC
|
|
||||||
LIMIT 100
|
|
||||||
""", params)
|
|
||||||
docs = [{
|
|
||||||
'id': r[0], 'from_branch_id': r[1], 'from_name': r[2],
|
|
||||||
'to_branch_id': r[3], 'to_name': r[4], 'status': r[5],
|
|
||||||
'notes': r[6], 'created_at': str(r[7]), 'sent_at': str(r[8]),
|
|
||||||
'received_at': str(r[9]), 'total_qty': int(r[10] or 0),
|
|
||||||
} for r in cur.fetchall()]
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'data': docs})
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/transfer-docs', methods=['POST'])
|
|
||||||
@require_auth('inventory.transfer')
|
|
||||||
def create_transfer_doc():
|
|
||||||
"""Create a draft transfer document.
|
|
||||||
|
|
||||||
Body: { to_branch_id, items: [{inventory_id, quantity}], notes }
|
|
||||||
"""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
to_branch = data.get('to_branch_id')
|
|
||||||
items = data.get('items', [])
|
|
||||||
if not to_branch or not items:
|
|
||||||
return jsonify({'error': 'to_branch_id e items son requeridos'}), 400
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
from_branch = data.get('from_branch_id') or g.branch_id
|
|
||||||
if not from_branch:
|
|
||||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
|
||||||
from_branch = cur.fetchone()[0]
|
|
||||||
if int(to_branch) == int(from_branch):
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Origen y destino no pueden ser la misma sucursal'}), 400
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO inventory_transfers (from_branch_id, to_branch_id, status, notes, employee_id)
|
|
||||||
VALUES (%s, %s, 'draft', %s, %s) RETURNING id
|
|
||||||
""", (from_branch, to_branch, data.get('notes'), getattr(g, 'employee_id', None)))
|
|
||||||
doc_id = cur.fetchone()[0]
|
|
||||||
for it in items:
|
|
||||||
qty = int(it.get('quantity', 0) or 0)
|
|
||||||
if qty <= 0 or not it.get('inventory_id'):
|
|
||||||
continue
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO inventory_transfer_lines (transfer_id, inventory_id, quantity)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
""", (doc_id, it['inventory_id'], qty))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'id': doc_id, 'status': 'draft'}), 201
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/transfer-docs/<int:doc_id>', methods=['GET'])
|
|
||||||
@require_auth('inventory.view')
|
|
||||||
def get_transfer_doc(doc_id):
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
SELECT t.id, t.from_branch_id, b1.name, t.to_branch_id, b2.name, t.status,
|
|
||||||
t.notes, t.created_at, t.sent_at, t.received_at
|
|
||||||
FROM inventory_transfers t
|
|
||||||
JOIN branches b1 ON b1.id = t.from_branch_id
|
|
||||||
JOIN branches b2 ON b2.id = t.to_branch_id
|
|
||||||
WHERE t.id = %s
|
|
||||||
""", (doc_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'not found'}), 404
|
|
||||||
doc = {
|
|
||||||
'id': row[0], 'from_branch_id': row[1], 'from_name': row[2],
|
|
||||||
'to_branch_id': row[3], 'to_name': row[4], 'status': row[5],
|
|
||||||
'notes': row[6], 'created_at': str(row[7]), 'sent_at': str(row[8]),
|
|
||||||
'received_at': str(row[9]), 'lines': [],
|
|
||||||
}
|
|
||||||
cur.execute("""
|
|
||||||
SELECT l.id, l.inventory_id, i.part_number, i.name, l.quantity,
|
|
||||||
COALESCE(l.qty_received, 0), COALESCE(s.stock, 0)
|
|
||||||
FROM inventory_transfer_lines l
|
|
||||||
JOIN inventory i ON i.id = l.inventory_id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock
|
|
||||||
WHERE branch_id = %s GROUP BY inventory_id
|
|
||||||
) s ON s.inventory_id = l.inventory_id
|
|
||||||
WHERE l.transfer_id = %s ORDER BY l.id
|
|
||||||
""", (doc['to_branch_id'], doc_id))
|
|
||||||
doc['lines'] = [{
|
|
||||||
'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3],
|
|
||||||
'quantity': int(r[4]), 'qty_received': int(r[5] or 0), 'dest_stock': int(r[6] or 0),
|
|
||||||
} for r in cur.fetchall()]
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify(doc)
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/transfer-docs/<int:doc_id>/send', methods=['POST'])
|
|
||||||
@require_auth('inventory.transfer')
|
|
||||||
def send_transfer_doc(doc_id):
|
|
||||||
"""Mark draft as sent (goods leave origin: stock -qty in origin branch)."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT from_branch_id, to_branch_id, status FROM inventory_transfers WHERE id = %s", (doc_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'not found'}), 404
|
|
||||||
if row[2] != 'draft':
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': f'Solo borradores pueden enviarse (estado: {row[2]})'}), 400
|
|
||||||
cur.execute("SELECT inventory_id, quantity FROM inventory_transfer_lines WHERE transfer_id = %s", (doc_id,))
|
|
||||||
lines = cur.fetchall()
|
|
||||||
for inv_id, qty in lines:
|
|
||||||
record_transfer(conn, inv_id, row[0], row[1], qty,
|
|
||||||
notes=f'Transferencia #{doc_id} enviada')
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE inventory_transfers SET status = 'sent', sent_at = NOW() WHERE id = %s
|
|
||||||
""", (doc_id,))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'ok': True, 'status': 'sent'})
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/transfer-docs/<int:doc_id>/receive', methods=['POST'])
|
|
||||||
@require_auth('inventory.transfer')
|
|
||||||
def receive_transfer_doc(doc_id):
|
|
||||||
"""Confirm receipt at destination. Adjusts destination stock to the
|
|
||||||
received quantities (discrepancies become adjustments)."""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
received = {int(k): int(v) for k, v in (data.get('received') or {}).items()}
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT to_branch_id, status FROM inventory_transfers WHERE id = %s", (doc_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'not found'}), 404
|
|
||||||
if row[1] != 'sent':
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': f'Solo transferencias enviadas pueden recibirse (estado: {row[1]})'}), 400
|
|
||||||
to_branch = row[0]
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT l.id, l.inventory_id, l.quantity FROM inventory_transfer_lines l
|
|
||||||
WHERE l.transfer_id = %s
|
|
||||||
""", (doc_id,))
|
|
||||||
lines = cur.fetchall()
|
|
||||||
differences = 0
|
|
||||||
for line_id, inv_id, qty in lines:
|
|
||||||
rec = received.get(line_id)
|
|
||||||
qty_rec = rec if rec is not None else qty
|
|
||||||
cur.execute("UPDATE inventory_transfer_lines SET qty_received = %s WHERE id = %s",
|
|
||||||
(qty_rec, line_id))
|
|
||||||
if qty_rec != qty:
|
|
||||||
differences += 1
|
|
||||||
record_adjustment(conn, inv_id, to_branch, qty_rec - qty,
|
|
||||||
reason=f'Diferencia en recepción de transferencia #{doc_id}')
|
|
||||||
cur.execute("""
|
|
||||||
UPDATE inventory_transfers SET status = 'received', received_at = NOW() WHERE id = %s
|
|
||||||
""", (doc_id,))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'ok': True, 'status': 'received', 'differences': differences})
|
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/transfer-docs/<int:doc_id>/cancel', methods=['POST'])
|
|
||||||
@require_auth('inventory.transfer')
|
|
||||||
def cancel_transfer_doc(doc_id):
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("UPDATE inventory_transfers SET status = 'cancelled' WHERE id = %s AND status = 'draft'",
|
|
||||||
(doc_id,))
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({'ok': True})
|
|
||||||
|
|
||||||
@inventory_bp.route('/transfer', methods=['POST'])
|
@inventory_bp.route('/transfer', methods=['POST'])
|
||||||
@require_auth('inventory.transfer')
|
@require_auth('inventory.transfer')
|
||||||
def api_transfer():
|
def api_transfer():
|
||||||
@@ -2919,22 +2271,6 @@ def list_inventory_subcategories(category_id):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/categories/all', methods=['GET'])
|
|
||||||
@require_auth()
|
|
||||||
def list_all_inventory_categories():
|
|
||||||
"""Return all active categories (flat, with parent_id) for selectors."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT id, name, parent_id FROM categories WHERE is_active = true ORDER BY name"
|
|
||||||
)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
return jsonify({'categories': [{'id': r[0], 'name': r[1], 'parent_id': r[2]} for r in rows]})
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Global Tier Discounts ───────────────────────
|
# ─── Global Tier Discounts ───────────────────────
|
||||||
|
|
||||||
@inventory_bp.route('/tier-discounts', methods=['GET'])
|
@inventory_bp.route('/tier-discounts', methods=['GET'])
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from flask import Blueprint, g, jsonify, request
|
|||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from services import facturapi_service
|
from services import facturapi_service
|
||||||
from services.audit import log_action
|
from services.audit import log_action
|
||||||
from services.facturapi_service import FacturapiError
|
|
||||||
from services.cfdi_facturapi_builder import (
|
from services.cfdi_facturapi_builder import (
|
||||||
build_egreso_payload,
|
build_egreso_payload,
|
||||||
build_ingreso_payload,
|
build_ingreso_payload,
|
||||||
@@ -20,7 +19,6 @@ from services.cfdi_facturapi_builder import (
|
|||||||
from services.cfdi_queue import (
|
from services.cfdi_queue import (
|
||||||
cancel_cfdi,
|
cancel_cfdi,
|
||||||
enqueue_cfdi,
|
enqueue_cfdi,
|
||||||
enqueue_pending_complements,
|
|
||||||
get_queue_status,
|
get_queue_status,
|
||||||
process_queue,
|
process_queue,
|
||||||
retry_failed,
|
retry_failed,
|
||||||
@@ -45,16 +43,9 @@ def _get_issuer_config(cur, branch_id=None):
|
|||||||
result = {
|
result = {
|
||||||
"rfc": config.get("tenant_rfc", ""),
|
"rfc": config.get("tenant_rfc", ""),
|
||||||
"razon_social": config.get("tenant_razon_social", ""),
|
"razon_social": config.get("tenant_razon_social", ""),
|
||||||
"regimen_fiscal": config.get("cfdi_regimen_fiscal") or config.get("tenant_regimen_fiscal", "601"),
|
"regimen_fiscal": config.get("cfdi_regimen_fiscal", "601"),
|
||||||
"cp": config.get("tenant_cp", "00000"),
|
"cp": config.get("tenant_cp", "00000"),
|
||||||
"direccion": config.get("tenant_direccion", ""),
|
"serie": config.get("cfdi_serie", "A"),
|
||||||
"exterior": config.get("tenant_numero_exterior", ""),
|
|
||||||
"interior": config.get("tenant_numero_interior", ""),
|
|
||||||
"colonia": config.get("tenant_colonia", ""),
|
|
||||||
"ciudad": config.get("tenant_ciudad", ""),
|
|
||||||
"municipio": config.get("tenant_municipio", ""),
|
|
||||||
"estado": config.get("tenant_estado", ""),
|
|
||||||
"serie": config.get("cfdi_serie") or config.get("invoice_serie", "A"),
|
|
||||||
"facturapi_key": config.get("cfdi_facturapi_key", ""),
|
"facturapi_key": config.get("cfdi_facturapi_key", ""),
|
||||||
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
|
"facturapi_org_id": config.get("cfdi_facturapi_org_id", ""),
|
||||||
}
|
}
|
||||||
@@ -63,7 +54,7 @@ def _get_issuer_config(cur, branch_id=None):
|
|||||||
if branch_id:
|
if branch_id:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT rfc, razon_social, regimen_fiscal, cp, serie_cfdi
|
SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi
|
||||||
FROM branches WHERE id = %s
|
FROM branches WHERE id = %s
|
||||||
""",
|
""",
|
||||||
(branch_id,),
|
(branch_id,),
|
||||||
@@ -85,8 +76,7 @@ def _get_sale_with_items(cur, sale_id):
|
|||||||
"""
|
"""
|
||||||
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||||
payment_method, subtotal, discount_total, tax_total, total,
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
metodo_pago_sat, forma_pago_sat, status, created_at,
|
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||||
currency, exchange_rate
|
|
||||||
FROM sales WHERE id = %s
|
FROM sales WHERE id = %s
|
||||||
""",
|
""",
|
||||||
(sale_id,),
|
(sale_id,),
|
||||||
@@ -110,8 +100,6 @@ def _get_sale_with_items(cur, sale_id):
|
|||||||
"forma_pago_sat": row[11] or "01",
|
"forma_pago_sat": row[11] or "01",
|
||||||
"status": row[12],
|
"status": row[12],
|
||||||
"created_at": str(row[13]),
|
"created_at": str(row[13]),
|
||||||
"currency": row[14] or "MXN",
|
|
||||||
"exchange_rate": float(row[15]) if row[15] else None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -370,44 +358,6 @@ def trigger_process_queue():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@invoicing_bp.route("/complementos/sync", methods=["POST"])
|
|
||||||
@require_auth("invoicing.create")
|
|
||||||
def sync_complementos():
|
|
||||||
"""Enqueue the missing payment complements (REP) and credit notes (egreso).
|
|
||||||
|
|
||||||
Payments on stamped PPD invoices become CFDI type "P" linked to the
|
|
||||||
original UUID; returns of stamped sales become CFDI type "E". Already
|
|
||||||
enqueued movements are not duplicated.
|
|
||||||
|
|
||||||
Body: {
|
|
||||||
backfill: bool (default false) — reconcile the whole history,
|
|
||||||
since: ISO date (optional) — reconcile movements created after it
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
backfill = bool(data.get("backfill"))
|
|
||||||
since = data.get("since")
|
|
||||||
|
|
||||||
if since:
|
|
||||||
try:
|
|
||||||
datetime.fromisoformat(str(since))
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "since must be an ISO 8601 date/datetime"}), 400
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = enqueue_pending_complements(conn, since=since, backfill=backfill)
|
|
||||||
conn.close()
|
|
||||||
result["enqueued"] = len(result["pagos"]) + len(result["egresos"])
|
|
||||||
return jsonify(result), 201
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Cancel CFDI ────────────────────────────────────
|
# ─── Cancel CFDI ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -535,7 +485,7 @@ def get_sale_pdf(sale_id):
|
|||||||
|
|
||||||
|
|
||||||
@invoicing_bp.route("/stats", methods=["GET"])
|
@invoicing_bp.route("/stats", methods=["GET"])
|
||||||
@require_auth("invoicing.view")
|
@require_auth("invoicing.read")
|
||||||
def api_invoicing_stats():
|
def api_invoicing_stats():
|
||||||
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -717,15 +667,14 @@ def facturapi_setup():
|
|||||||
(result["org_id"],),
|
(result["org_id"],),
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.get("api_key"):
|
cur.execute(
|
||||||
cur.execute(
|
"""
|
||||||
"""
|
INSERT INTO tenant_config (key, value)
|
||||||
INSERT INTO tenant_config (key, value)
|
VALUES ('cfdi_facturapi_key', %s)
|
||||||
VALUES ('cfdi_facturapi_key', %s)
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
""",
|
||||||
""",
|
(result["api_key"],),
|
||||||
(result["api_key"],),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
log_action(conn, "FACTURAPI_SETUP", "tenant_config", None, new_value={"org_id": result["org_id"]})
|
log_action(conn, "FACTURAPI_SETUP", "tenant_config", None, new_value={"org_id": result["org_id"]})
|
||||||
|
|
||||||
@@ -733,30 +682,18 @@ def facturapi_setup():
|
|||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
status_conn = get_tenant_conn(g.tenant_id)
|
return jsonify(
|
||||||
try:
|
{
|
||||||
status_cur = status_conn.cursor()
|
"org_id": result["org_id"],
|
||||||
status = facturapi_service.get_org_status(_get_issuer_config(status_cur))
|
"message": "Facturapi organization created. Complete pending steps in Facturapi dashboard.",
|
||||||
status["org_id"] = result["org_id"]
|
}
|
||||||
if not result.get("legal_updated"):
|
)
|
||||||
status["error"] = status.get("error") or "Datos fiscales no pudieron configurarse automáticamente. Configúralos en el dashboard de Facturapi."
|
|
||||||
status_cur.close()
|
|
||||||
status_conn.close()
|
|
||||||
except Exception:
|
|
||||||
status_conn.close()
|
|
||||||
raise
|
|
||||||
return jsonify(status)
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
except FacturapiError as e:
|
|
||||||
conn.rollback()
|
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
return jsonify({"error": str(e)}), 400
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
@@ -10,19 +10,11 @@ Routes:
|
|||||||
Listings
|
Listings
|
||||||
GET /pos/api/marketplace-ext/listings
|
GET /pos/api/marketplace-ext/listings
|
||||||
POST /pos/api/marketplace-ext/listings
|
POST /pos/api/marketplace-ext/listings
|
||||||
POST /pos/api/marketplace-ext/listings/import-existing
|
|
||||||
POST /pos/api/marketplace-ext/listings/backfill-inventory
|
|
||||||
POST /pos/api/marketplace-ext/listings/<id>/sync
|
POST /pos/api/marketplace-ext/listings/<id>/sync
|
||||||
POST /pos/api/marketplace-ext/listings/<id>/pause
|
POST /pos/api/marketplace-ext/listings/<id>/pause
|
||||||
POST /pos/api/marketplace-ext/listings/<id>/activate
|
POST /pos/api/marketplace-ext/listings/<id>/activate
|
||||||
DELETE /pos/api/marketplace-ext/listings/<id>
|
DELETE /pos/api/marketplace-ext/listings/<id>
|
||||||
|
|
||||||
Questions
|
|
||||||
GET /pos/api/marketplace-ext/questions
|
|
||||||
POST /pos/api/marketplace-ext/questions/sync
|
|
||||||
POST /pos/api/marketplace-ext/questions/<id>/answer
|
|
||||||
GET /pos/api/marketplace-ext/questions/<id>/suggestion (AI, A7)
|
|
||||||
|
|
||||||
Orders
|
Orders
|
||||||
GET /pos/api/marketplace-ext/orders
|
GET /pos/api/marketplace-ext/orders
|
||||||
GET /pos/api/marketplace-ext/orders/<id>
|
GET /pos/api/marketplace-ext/orders/<id>
|
||||||
@@ -32,8 +24,6 @@ Routes:
|
|||||||
POST /pos/api/marketplace-ext/webhook/meli
|
POST /pos/api/marketplace-ext/webhook/meli
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, g
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth, has_permission
|
from middleware import require_auth, has_permission
|
||||||
from tenant_db import get_tenant_conn, get_master_conn
|
from tenant_db import get_tenant_conn, get_master_conn
|
||||||
@@ -70,84 +60,10 @@ def _require_meli_manage():
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _safe_int(value, default):
|
|
||||||
"""Parse an int from a query param, falling back to a default instead of 500."""
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _get_meli_config(tenant_id):
|
|
||||||
"""Return the tenant's ML config dict, or None if the tenant is unknown."""
|
|
||||||
try:
|
|
||||||
conn = get_tenant_conn(tenant_id)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return meli_svc.get_meli_config(conn)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_tenant_by_meli_user(meli_user_id):
|
|
||||||
"""Find the tenant whose tenant_config.meli_user_id matches.
|
|
||||||
|
|
||||||
tenant_config lives in each tenant DB (not in master), so we iterate the
|
|
||||||
active tenants from master and probe each one.
|
|
||||||
"""
|
|
||||||
if not meli_user_id:
|
|
||||||
return None
|
|
||||||
mconn = get_master_conn()
|
|
||||||
try:
|
|
||||||
mcur = mconn.cursor()
|
|
||||||
mcur.execute("SELECT id FROM tenants WHERE is_active = true ORDER BY id")
|
|
||||||
tenant_ids = [r[0] for r in mcur.fetchall()]
|
|
||||||
mcur.close()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
mconn.close()
|
|
||||||
|
|
||||||
target = str(meli_user_id)
|
|
||||||
for tid in tenant_ids:
|
|
||||||
try:
|
|
||||||
conn = get_tenant_conn(tid)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute(
|
|
||||||
"SELECT 1 FROM tenant_config WHERE key = 'meli_user_id' AND value = %s LIMIT 1",
|
|
||||||
(target,),
|
|
||||||
)
|
|
||||||
found = cur.fetchone() is not None
|
|
||||||
cur.close()
|
|
||||||
if found:
|
|
||||||
return tid
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# CONFIG
|
# CONFIG
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
def _meli_auth_error(e):
|
|
||||||
"""Respuesta 401 para MeliAuthError: propaga el detalle accionable
|
|
||||||
(p. ej. credenciales de app rechazadas) en vez del mensaje genérico."""
|
|
||||||
detail = str(e)
|
|
||||||
if ('credenciales' in detail.lower() or 'refresh failed' in detail.lower()
|
|
||||||
or 'invalid_client' in detail.lower()):
|
|
||||||
return jsonify({'error': detail}), 401
|
|
||||||
return jsonify({'error': 'La sesión de MercadoLibre expiró. Reconecta la cuenta en la pestaña de configuración.'}), 401
|
|
||||||
|
|
||||||
|
|
||||||
@marketplace_ext_bp.route("/config", methods=["GET"])
|
@marketplace_ext_bp.route("/config", methods=["GET"])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def get_config():
|
def get_config():
|
||||||
@@ -160,55 +76,11 @@ def get_config():
|
|||||||
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
|
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
|
||||||
}
|
}
|
||||||
safe["connected"] = bool(cfg.get("meli_access_token"))
|
safe["connected"] = bool(cfg.get("meli_access_token"))
|
||||||
safe["has_refresh_token"] = bool(cfg.get("meli_refresh_token"))
|
|
||||||
return jsonify(safe)
|
return jsonify(safe)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@marketplace_ext_bp.route("/connect/init", methods=["POST"])
|
|
||||||
@require_auth()
|
|
||||||
def init_meli_connect():
|
|
||||||
"""Store client credentials server-side and return the MercadoLibre auth URL.
|
|
||||||
|
|
||||||
The frontend no longer keeps the client_secret in localStorage.
|
|
||||||
"""
|
|
||||||
err = _require_meli_manage()
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
client_id = data.get("client_id", "").strip()
|
|
||||||
client_secret = data.get("client_secret", "").strip()
|
|
||||||
category = data.get("category", "").strip()
|
|
||||||
shipping = data.get("shipping", "").strip()
|
|
||||||
|
|
||||||
if not client_id or not client_secret:
|
|
||||||
return jsonify({"error": "client_id and client_secret required"}), 400
|
|
||||||
|
|
||||||
base = _get_public_base_url().rstrip("/")
|
|
||||||
redirect_uri = f"{base}/pos/marketplace-external/callback"
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
meli_svc.save_meli_config(conn, {
|
|
||||||
"meli_client_id": client_id,
|
|
||||||
"meli_client_secret": client_secret,
|
|
||||||
"meli_default_category_id": category,
|
|
||||||
"meli_shipping_mode": shipping,
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
auth_url = (
|
|
||||||
"https://auth.mercadolibre.com.mx/authorization?response_type=code"
|
|
||||||
f"&client_id={urllib.parse.quote(client_id)}"
|
|
||||||
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
|
|
||||||
"&scope=read+write+offline_access"
|
|
||||||
)
|
|
||||||
return jsonify({"auth_url": auth_url, "redirect_uri": redirect_uri})
|
|
||||||
|
|
||||||
|
|
||||||
@marketplace_ext_bp.route("/connect", methods=["POST"])
|
@marketplace_ext_bp.route("/connect", methods=["POST"])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def connect_meli():
|
def connect_meli():
|
||||||
@@ -218,21 +90,12 @@ def connect_meli():
|
|||||||
|
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
code = data.get("code")
|
code = data.get("code")
|
||||||
|
client_id = data.get("client_id")
|
||||||
|
client_secret = data.get("client_secret")
|
||||||
redirect_uri = data.get("redirect_uri", "")
|
redirect_uri = data.get("redirect_uri", "")
|
||||||
|
|
||||||
if not code:
|
if not code or not client_id or not client_secret:
|
||||||
return jsonify({"error": "code required"}), 400
|
return jsonify({"error": "code, client_id and client_secret required"}), 400
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
cfg = meli_svc.get_meli_config(conn)
|
|
||||||
client_id = data.get("client_id") or cfg.get("meli_client_id")
|
|
||||||
client_secret = data.get("client_secret") or cfg.get("meli_client_secret")
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if not client_id or not client_secret:
|
|
||||||
return jsonify({"error": "ML credentials not configured"}), 400
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
|
||||||
@@ -261,17 +124,12 @@ def connect_meli():
|
|||||||
"meli_client_id": client_id,
|
"meli_client_id": client_id,
|
||||||
"meli_client_secret": client_secret,
|
"meli_client_secret": client_secret,
|
||||||
})
|
})
|
||||||
resp = {
|
return jsonify({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"user_id": user_id or user.get("id"),
|
"user_id": user_id or user.get("id"),
|
||||||
"nickname": user.get("nickname"),
|
"nickname": user.get("nickname"),
|
||||||
"site_id": user.get("site_id"),
|
"site_id": user.get("site_id"),
|
||||||
}
|
})
|
||||||
if not refresh_token:
|
|
||||||
# The ML app lacks offline_access scope: the connection will die
|
|
||||||
# in ~6h and cannot be auto-renewed. Warn so it can be fixed.
|
|
||||||
resp["warning"] = "no_refresh_token"
|
|
||||||
return jsonify(resp)
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -302,13 +160,13 @@ def search_categories():
|
|||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
cfg = meli_svc.get_meli_config(conn)
|
cfg = meli_svc.get_meli_config(conn)
|
||||||
svc = meli_svc._get_meli_service(cfg, conn)
|
svc = meli_svc._get_meli_service(cfg)
|
||||||
if not svc:
|
if not svc:
|
||||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||||
result = svc.search_categories(site_id, q)
|
result = svc.search_categories(site_id, q)
|
||||||
return jsonify({"categories": result})
|
return jsonify({"categories": result})
|
||||||
except MeliAuthError as e:
|
except MeliAuthError:
|
||||||
return _meli_auth_error(e)
|
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
@@ -322,16 +180,16 @@ def search_categories():
|
|||||||
@marketplace_ext_bp.route("/listings", methods=["GET"])
|
@marketplace_ext_bp.route("/listings", methods=["GET"])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def list_listings():
|
def list_listings():
|
||||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
page = int(request.args.get("page", 1))
|
||||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||||
status = request.args.get("status")
|
status = request.args.get("status")
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
|
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except MeliAuthError as e:
|
except MeliAuthError:
|
||||||
return _meli_auth_error(e)
|
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
@@ -367,7 +225,6 @@ def create_listings():
|
|||||||
shipping_mode=shipping_mode,
|
shipping_mode=shipping_mode,
|
||||||
custom_data=custom_data,
|
custom_data=custom_data,
|
||||||
base_url=_get_public_base_url(),
|
base_url=_get_public_base_url(),
|
||||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
|
||||||
)
|
)
|
||||||
return jsonify(result), 201
|
return jsonify(result), 201
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -398,36 +255,6 @@ def import_existing_listings():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@marketplace_ext_bp.route("/listings/backfill-inventory", methods=["POST"])
|
|
||||||
@require_auth()
|
|
||||||
def backfill_listing_inventory():
|
|
||||||
"""Map already-imported listings to local inventory (populate inventory_id).
|
|
||||||
|
|
||||||
Body: {"dry_run": bool (default false), "limit": int|null,
|
|
||||||
"only_unmapped": bool (default true)}.
|
|
||||||
"""
|
|
||||||
err = _require_meli_manage()
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
|
|
||||||
data = request.get_json() or {}
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
result = meli_svc.backfill_listing_inventory(
|
|
||||||
conn,
|
|
||||||
limit=_safe_int(data.get("limit"), None) if data.get("limit") else None,
|
|
||||||
only_unmapped=bool(data.get("only_unmapped", True)),
|
|
||||||
dry_run=bool(data.get("dry_run", False)),
|
|
||||||
)
|
|
||||||
return jsonify(result), 200
|
|
||||||
except ValueError as e:
|
|
||||||
return jsonify({"error": str(e)}), 400
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@marketplace_ext_bp.route("/inventory-check", methods=["POST"])
|
@marketplace_ext_bp.route("/inventory-check", methods=["POST"])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def inventory_check():
|
def inventory_check():
|
||||||
@@ -457,8 +284,7 @@ def sync_stock_to_meli():
|
|||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
# Manual run from the UI: bypass the meli_auto_sync_stock_price gate.
|
result = meli_svc.process_meli_sync_queue(conn)
|
||||||
result = meli_svc.process_meli_sync_queue(conn, force=True)
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
@@ -475,41 +301,15 @@ def category_attributes(category_id):
|
|||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
cfg = meli_svc.get_meli_config(conn)
|
cfg = meli_svc.get_meli_config(conn)
|
||||||
svc = meli_svc._get_meli_service(cfg, conn)
|
svc = meli_svc._get_meli_service(cfg)
|
||||||
if not svc:
|
if not svc:
|
||||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||||
attrs = svc.get_category_attributes(category_id)
|
attrs = svc.get_category_attributes(category_id)
|
||||||
# Filter to required attributes only for the UI
|
# Filter to required attributes only for the UI
|
||||||
required = [a for a in attrs if a.get("tags", {}).get("required")]
|
required = [a for a in attrs if a.get("tags", {}).get("required")]
|
||||||
return jsonify({"attributes": required, "all": attrs})
|
return jsonify({"attributes": required, "all": attrs})
|
||||||
except MeliAuthError as e:
|
except MeliAuthError:
|
||||||
return _meli_auth_error(e)
|
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@marketplace_ext_bp.route("/listing-prices", methods=["GET"])
|
|
||||||
@require_auth()
|
|
||||||
def listing_prices():
|
|
||||||
"""Fee estimator: return MeLi fees per listing type for a price + category."""
|
|
||||||
price = request.args.get("price", type=float)
|
|
||||||
category_id = request.args.get("category_id", "")
|
|
||||||
site_id = request.args.get("site_id", "MLM")
|
|
||||||
if not price or price <= 0 or not category_id:
|
|
||||||
return jsonify({"error": "price y category_id son requeridos"}), 400
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
cfg = meli_svc.get_meli_config(conn)
|
|
||||||
svc = meli_svc._get_meli_service(cfg, conn)
|
|
||||||
if not svc:
|
|
||||||
return jsonify({"error": "MercadoLibre no está conectado. Conecta la cuenta en la pestaña de configuración."}), 400
|
|
||||||
result = svc.get_listing_prices(site_id, price, category_id)
|
|
||||||
return jsonify({"listing_prices": result})
|
|
||||||
except MeliAuthError as e:
|
|
||||||
return _meli_auth_error(e)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
finally:
|
finally:
|
||||||
@@ -546,7 +346,6 @@ def validate_listings():
|
|||||||
shipping_mode=shipping_mode,
|
shipping_mode=shipping_mode,
|
||||||
custom_data=custom_data,
|
custom_data=custom_data,
|
||||||
base_url=_get_public_base_url(),
|
base_url=_get_public_base_url(),
|
||||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -587,8 +386,6 @@ def create_listings_async():
|
|||||||
shipping_mode=shipping_mode,
|
shipping_mode=shipping_mode,
|
||||||
custom_data=custom_data,
|
custom_data=custom_data,
|
||||||
base_url=_get_public_base_url(),
|
base_url=_get_public_base_url(),
|
||||||
chunk_size=_safe_int(data.get("chunk_size"), 10),
|
|
||||||
use_llm_description=bool(data.get("use_llm_description", False)),
|
|
||||||
)
|
)
|
||||||
return jsonify({"task_id": task.id, "status": "queued"}), 202
|
return jsonify({"task_id": task.id, "status": "queued"}), 202
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -601,7 +398,7 @@ def get_async_listing_status(task_id):
|
|||||||
"""Get status of an async ML publishing task."""
|
"""Get status of an async ML publishing task."""
|
||||||
try:
|
try:
|
||||||
from celery.result import AsyncResult
|
from celery.result import AsyncResult
|
||||||
from celery_app import celery as celery_app
|
from app import celery as celery_app
|
||||||
result = AsyncResult(task_id, app=celery_app)
|
result = AsyncResult(task_id, app=celery_app)
|
||||||
if result.ready():
|
if result.ready():
|
||||||
return jsonify({"status": "done", "result": result.result or {}})
|
return jsonify({"status": "done", "result": result.result or {}})
|
||||||
@@ -765,34 +562,6 @@ def answer_question(question_id):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@marketplace_ext_bp.route("/questions/<int:question_id>/suggestion", methods=["GET"])
|
|
||||||
@require_auth()
|
|
||||||
def question_suggestion(question_id):
|
|
||||||
"""AI intent classification + suggested answer for a buyer question.
|
|
||||||
|
|
||||||
Query param: ?use_llm=0 to skip the LLM fallback and use templates only.
|
|
||||||
The answer is only a suggestion; auto-answering is opt-in per tenant via
|
|
||||||
tenant_config `meli_auto_answer_questions`.
|
|
||||||
"""
|
|
||||||
err = _require_meli_manage()
|
|
||||||
if err:
|
|
||||||
return err
|
|
||||||
use_llm = request.args.get("use_llm", "1").lower() not in ("0", "false", "no")
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
result = meli_svc.suggest_question_answer(conn, question_id, use_llm=use_llm)
|
|
||||||
result["auto_answer_enabled"] = bool(
|
|
||||||
meli_svc.get_meli_config(conn).get("meli_auto_answer_questions")
|
|
||||||
)
|
|
||||||
return jsonify(result)
|
|
||||||
except ValueError as e:
|
|
||||||
return jsonify({"error": str(e)}), 404
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({"error": str(e)}), 500
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
# ORDERS
|
# ORDERS
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -820,8 +589,8 @@ def sync_orders():
|
|||||||
@marketplace_ext_bp.route("/orders", methods=["GET"])
|
@marketplace_ext_bp.route("/orders", methods=["GET"])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def list_orders():
|
def list_orders():
|
||||||
page = max(1, _safe_int(request.args.get("page"), 1))
|
page = int(request.args.get("page", 1))
|
||||||
per_page = min(max(1, _safe_int(request.args.get("per_page"), 50)), 200)
|
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||||
status = request.args.get("status")
|
status = request.args.get("status")
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -850,15 +619,11 @@ def get_order(order_id):
|
|||||||
def convert_order(order_id):
|
def convert_order(order_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
register_id = data.get("register_id")
|
register_id = data.get("register_id")
|
||||||
# Mirrors the counter-sale "Facturar CFDI" checkbox; when omitted the sale
|
|
||||||
# is only invoiced if the tenant has meli_auto_invoice = true.
|
|
||||||
generate_cfdi = bool(data.get("generate_cfdi", False))
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
result = meli_svc.convert_order_to_sale(
|
result = meli_svc.convert_order_to_sale(
|
||||||
conn, order_id, employee_id=g.employee_id, register_id=register_id,
|
conn, order_id, employee_id=g.employee_id, register_id=register_id
|
||||||
generate_cfdi=generate_cfdi,
|
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -897,35 +662,38 @@ def update_order_status_route(order_id):
|
|||||||
def meli_webhook():
|
def meli_webhook():
|
||||||
"""Receive MercadoLibre notifications.
|
"""Receive MercadoLibre notifications.
|
||||||
|
|
||||||
ML sends a lightweight payload with topic + resource URL. We resolve the
|
ML sends a lightweight payload with topic + resource URL.
|
||||||
tenant from ML's user_id, validate the X-Signature header when present, ack
|
We ack immediately and enqueue Celery for async processing.
|
||||||
immediately and enqueue Celery to process the specific resource.
|
|
||||||
"""
|
"""
|
||||||
raw_body = request.get_data()
|
|
||||||
data = request.get_json(force=True, silent=True) or {}
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
topic = data.get("topic", "")
|
topic = data.get("topic", "")
|
||||||
resource = data.get("resource", "")
|
resource = data.get("resource", "")
|
||||||
user_id = data.get("user_id")
|
user_id = data.get("user_id")
|
||||||
|
|
||||||
# tenant_config lives in each tenant DB, not in master
|
# Resolve tenant by meli_user_id
|
||||||
tenant_id = _resolve_tenant_by_meli_user(user_id)
|
tenant_id = None
|
||||||
if not tenant_id:
|
if user_id:
|
||||||
print(f"[ML Webhook] No tenant found for meli_user_id={user_id}")
|
try:
|
||||||
return jsonify({"error": "unknown tenant"}), 403
|
mconn = get_master_conn()
|
||||||
|
mcur = mconn.cursor()
|
||||||
|
mcur.execute(
|
||||||
|
"""
|
||||||
|
SELECT t.id FROM tenants t
|
||||||
|
JOIN tenant_config c ON c.key = 'meli_user_id' AND c.value = %s
|
||||||
|
WHERE t.is_active = true
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(str(user_id),),
|
||||||
|
)
|
||||||
|
row = mcur.fetchone()
|
||||||
|
if row:
|
||||||
|
tenant_id = row[0]
|
||||||
|
mcur.close()
|
||||||
|
mconn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
cfg = _get_meli_config(tenant_id) or {}
|
if tenant_id and topic:
|
||||||
secret = cfg.get("meli_client_secret")
|
|
||||||
signature = request.headers.get("X-Signature")
|
|
||||||
if signature:
|
|
||||||
if not secret or not MeliService.validate_webhook_signature(secret, raw_body, signature):
|
|
||||||
print(f"[ML Webhook] Invalid X-Signature for tenant {tenant_id}")
|
|
||||||
return jsonify({"error": "invalid signature"}), 401
|
|
||||||
else:
|
|
||||||
# ML only signs notifications when signature validation is enabled on
|
|
||||||
# the app; without the header there is nothing to verify.
|
|
||||||
print(f"[ML Webhook] Notification without X-Signature (tenant {tenant_id}, topic={topic})")
|
|
||||||
|
|
||||||
if topic:
|
|
||||||
try:
|
try:
|
||||||
from tasks import process_meli_webhook_task
|
from tasks import process_meli_webhook_task
|
||||||
process_meli_webhook_task.delay(tenant_id, topic, resource)
|
process_meli_webhook_task.delay(tenant_id, topic, resource)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -123,10 +123,7 @@ def public_catalog_search():
|
|||||||
from services.catalog_service import smart_search
|
from services.catalog_service import smart_search
|
||||||
master = get_master_conn()
|
master = get_master_conn()
|
||||||
try:
|
try:
|
||||||
# Catálogo público: scope 'catalog' → flag SEARCH_RERANK_ENABLED
|
results = smart_search(master, q, conn, branch_id=None, limit=limit)
|
||||||
# (el POS, sensible a latencia, usa el scope 'pos' apagado por default).
|
|
||||||
results = smart_search(master, q, conn, branch_id=None, limit=limit,
|
|
||||||
rerank_scope='catalog')
|
|
||||||
finally:
|
finally:
|
||||||
master.close()
|
master.close()
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,16 @@
|
|||||||
Prefix: /pos/api/service-orders
|
Prefix: /pos/api/service-orders
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from flask import Blueprint, g, jsonify, request
|
from flask import Blueprint, g, jsonify, request
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from services.service_order_engine import (
|
from services.service_order_engine import (
|
||||||
add_item,
|
add_item,
|
||||||
add_labor,
|
add_labor,
|
||||||
assign_mechanic,
|
assign_mechanic,
|
||||||
convert_to_remission,
|
|
||||||
convert_to_sale,
|
convert_to_sale,
|
||||||
create_service_catalog_item,
|
create_service_catalog_item,
|
||||||
create_service_order,
|
create_service_order,
|
||||||
delete_service_catalog_item,
|
delete_service_catalog_item,
|
||||||
delete_service_order,
|
|
||||||
get_kanban_summary,
|
get_kanban_summary,
|
||||||
get_service_order,
|
get_service_order,
|
||||||
list_service_catalog,
|
list_service_catalog,
|
||||||
@@ -33,136 +28,17 @@ from services.service_order_engine import (
|
|||||||
update_status,
|
update_status,
|
||||||
)
|
)
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PERMISSIONS
|
|
||||||
|
|
||||||
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
||||||
|
|
||||||
|
|
||||||
def _tenant_allows_zero_price(conn):
|
|
||||||
"""Return True if the tenant allows selling items at $0. Defaults to True."""
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_zero_price_sales'")
|
|
||||||
row = cur.fetchone()
|
|
||||||
cur.close()
|
|
||||||
return str(row[0]).lower() in ('true', '1', 'yes') if row else True
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_zero_price(conn, items):
|
|
||||||
"""Raise ValueError if any item line would be <= $0 and the tenant forbids it."""
|
|
||||||
if _tenant_allows_zero_price(conn):
|
|
||||||
return
|
|
||||||
for item in items:
|
|
||||||
unit_price = float(item.get('unit_price', 0) or 0)
|
|
||||||
quantity = float(item.get('quantity', 1) or 1)
|
|
||||||
discount_pct = float(item.get('discount_pct', 0) or 0)
|
|
||||||
line_total = unit_price * quantity * (1 - discount_pct / 100)
|
|
||||||
if line_total <= 0:
|
|
||||||
name = item.get('name') or item.get('part_number') or item.get('inventory_id')
|
|
||||||
raise ValueError(f"No está permitido vender artículos en $0 ({name})")
|
|
||||||
|
|
||||||
|
|
||||||
# Roles allowed to access the workshop module at all.
|
|
||||||
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
|
|
||||||
|
|
||||||
|
|
||||||
def _can_view_workshop():
|
|
||||||
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
|
|
||||||
|
|
||||||
|
|
||||||
def _get_ws_perms():
|
|
||||||
"""Return effective workshop permissions for the current user."""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
return _get_workshop_permissions(conn, g.employee_role)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _visible_statuses():
|
|
||||||
if g.employee_role in ('owner', 'admin'):
|
|
||||||
return list(_DEFAULT_WORKSHOP_PERMISSIONS['admin']['statuses'])
|
|
||||||
return _get_ws_perms().get('statuses', [])
|
|
||||||
|
|
||||||
|
|
||||||
def _can_do_action(action):
|
|
||||||
if g.employee_role in ('owner', 'admin'):
|
|
||||||
return True
|
|
||||||
return action in _get_ws_perms().get('actions', [])
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_order_for_restricted(order):
|
|
||||||
"""Remove customer/commercial data when the role cannot view sensitive data."""
|
|
||||||
if g.employee_role in ('owner', 'admin') or _can_do_action('view_customer_data'):
|
|
||||||
return order
|
|
||||||
sensitive = {
|
|
||||||
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
|
|
||||||
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
|
|
||||||
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
|
|
||||||
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
|
|
||||||
'final_cost', 'total_parts', 'total_labor', 'total',
|
|
||||||
}
|
|
||||||
|
|
||||||
def redact_value(v):
|
|
||||||
if isinstance(v, str):
|
|
||||||
return '—'
|
|
||||||
if isinstance(v, (int, float)):
|
|
||||||
return 0
|
|
||||||
return None
|
|
||||||
|
|
||||||
redacted = {}
|
|
||||||
for k, v in order.items():
|
|
||||||
if k in sensitive:
|
|
||||||
redacted[k] = redact_value(v)
|
|
||||||
elif k == 'items' and isinstance(v, list):
|
|
||||||
redacted[k] = [_redact_item_for_restricted(it) for it in v]
|
|
||||||
else:
|
|
||||||
redacted[k] = v
|
|
||||||
return redacted
|
|
||||||
|
|
||||||
|
|
||||||
def _redact_item_for_restricted(item):
|
|
||||||
"""Hide sensitive item rows for restricted viewers."""
|
|
||||||
item = dict(item)
|
|
||||||
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
|
|
||||||
if k in item:
|
|
||||||
item[k] = None if not isinstance(item[k], (int, float)) else 0
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
def require_workshop_view(f):
|
|
||||||
@wraps(f)
|
|
||||||
@require_auth()
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
if not _can_view_workshop():
|
|
||||||
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return decorated
|
|
||||||
|
|
||||||
|
|
||||||
def require_workshop_action(action):
|
|
||||||
def decorator(f):
|
|
||||||
@wraps(f)
|
|
||||||
@require_auth()
|
|
||||||
def decorated(*args, **kwargs):
|
|
||||||
if not _can_view_workshop():
|
|
||||||
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
|
||||||
if not _can_do_action(action):
|
|
||||||
return jsonify({'error': f'Missing permissions: workshop.{action}'}), 403
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return decorated
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('', methods=['GET'])
|
@service_order_bp.route('', methods=['GET'])
|
||||||
@require_workshop_view
|
@require_auth()
|
||||||
def list_orders():
|
def list_orders():
|
||||||
status = request.args.get('status')
|
status = request.args.get('status')
|
||||||
priority = request.args.get('priority')
|
priority = request.args.get('priority')
|
||||||
customer_id = request.args.get('customer_id', type=int)
|
customer_id = request.args.get('customer_id', type=int)
|
||||||
employee_id = request.args.get('employee_id', type=int)
|
employee_id = request.args.get('employee_id', type=int)
|
||||||
delivery_method = request.args.get('delivery_method')
|
|
||||||
is_direct = request.args.get('is_direct', type=lambda v: v.lower() == 'true') if 'is_direct' in request.args else None
|
|
||||||
q = request.args.get('q')
|
|
||||||
page = int(request.args.get('page', 1))
|
page = int(request.args.get('page', 1))
|
||||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||||
|
|
||||||
@@ -171,65 +47,15 @@ def list_orders():
|
|||||||
result = list_service_orders(
|
result = list_service_orders(
|
||||||
conn, status=status, branch_id=g.branch_id,
|
conn, status=status, branch_id=g.branch_id,
|
||||||
customer_id=customer_id, priority=priority,
|
customer_id=customer_id, priority=priority,
|
||||||
employee_id=employee_id, delivery_method=delivery_method,
|
employee_id=employee_id, page=page, per_page=per_page
|
||||||
is_direct=is_direct, q=q, page=page, per_page=per_page
|
|
||||||
)
|
)
|
||||||
if g.employee_role not in ('owner', 'admin'):
|
|
||||||
visible = _visible_statuses()
|
|
||||||
result['data'] = [
|
|
||||||
_redact_order_for_restricted(o) for o in result.get('data', [])
|
|
||||||
if o.get('status') in visible
|
|
||||||
]
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/from-pos', methods=['POST'])
|
|
||||||
@require_auth('pos.sell')
|
|
||||||
def create_order_from_pos():
|
|
||||||
"""Create a service order directly from the POS cart (quotation)."""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
items = data.get('items', [])
|
|
||||||
estimated_cost = sum(
|
|
||||||
(it.get('unit_price') or 0) * (it.get('quantity') or 1)
|
|
||||||
for it in items
|
|
||||||
)
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
_validate_zero_price(conn, items)
|
|
||||||
result = create_service_order(conn, {
|
|
||||||
'tenant_id': g.tenant_id,
|
|
||||||
'branch_id': data.get('branch_id', g.branch_id),
|
|
||||||
'customer_id': data.get('customer_id'),
|
|
||||||
'vehicle_id': data.get('vehicle_id'),
|
|
||||||
'priority': data.get('priority', 'normal'),
|
|
||||||
'reception_notes': data.get('reception_notes'),
|
|
||||||
'estimated_cost': estimated_cost or None,
|
|
||||||
'estimated_completion': data.get('estimated_completion'),
|
|
||||||
'employee_id': data.get('employee_id'),
|
|
||||||
'mileage_in': data.get('mileage_in'),
|
|
||||||
'created_by': getattr(g, 'employee_id', None),
|
|
||||||
'delivery_method': data.get('delivery_method'),
|
|
||||||
'courier_id': data.get('courier_id'),
|
|
||||||
'is_direct': data.get('is_direct', False),
|
|
||||||
'requires_invoice': data.get('requires_invoice', False),
|
|
||||||
'workshop_name': data.get('workshop_name'),
|
|
||||||
'customer_address': data.get('customer_address'),
|
|
||||||
'customer_phone': data.get('customer_phone'),
|
|
||||||
'vehicle_description': data.get('vehicle_description'),
|
|
||||||
})
|
|
||||||
so_id = result['service_order_id']
|
|
||||||
for item in items:
|
|
||||||
add_item(conn, so_id, item)
|
|
||||||
order = get_service_order(conn, so_id)
|
|
||||||
return jsonify(order), 201
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('', methods=['POST'])
|
@service_order_bp.route('', methods=['POST'])
|
||||||
@require_workshop_action('create_order')
|
@require_auth()
|
||||||
def create_order():
|
def create_order():
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -247,14 +73,6 @@ def create_order():
|
|||||||
'mileage_in': data.get('mileage_in'),
|
'mileage_in': data.get('mileage_in'),
|
||||||
'fuel_level': data.get('fuel_level'),
|
'fuel_level': data.get('fuel_level'),
|
||||||
'created_by': getattr(g, 'employee_id', None),
|
'created_by': getattr(g, 'employee_id', None),
|
||||||
'delivery_method': data.get('delivery_method'),
|
|
||||||
'courier_id': data.get('courier_id'),
|
|
||||||
'is_direct': data.get('is_direct', False),
|
|
||||||
'requires_invoice': data.get('requires_invoice', False),
|
|
||||||
'workshop_name': data.get('workshop_name'),
|
|
||||||
'customer_address': data.get('customer_address'),
|
|
||||||
'customer_phone': data.get('customer_phone'),
|
|
||||||
'vehicle_description': data.get('vehicle_description'),
|
|
||||||
})
|
})
|
||||||
return jsonify(result), 201
|
return jsonify(result), 201
|
||||||
finally:
|
finally:
|
||||||
@@ -262,29 +80,22 @@ def create_order():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||||
@require_workshop_view
|
@require_auth()
|
||||||
def get_order(so_id):
|
def get_order(so_id):
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
order = get_service_order(conn, so_id)
|
order = get_service_order(conn, so_id)
|
||||||
if not order:
|
if not order:
|
||||||
return jsonify({'error': 'Service order not found'}), 404
|
return jsonify({'error': 'Service order not found'}), 404
|
||||||
if order.get('status') not in _visible_statuses():
|
return jsonify(order)
|
||||||
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
|
||||||
return jsonify(_redact_order_for_restricted(order))
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def update_order(so_id):
|
def update_order(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
|
|
||||||
if g.employee_role == 'mechanic':
|
|
||||||
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
|
|
||||||
if not data or any(k not in allowed for k in data.keys()):
|
|
||||||
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
ok = update_service_order(conn, so_id, data)
|
ok = update_service_order(conn, so_id, data)
|
||||||
@@ -295,32 +106,13 @@ def update_order(so_id):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
|
|
||||||
@require_workshop_action('delete_order')
|
|
||||||
def delete_order(so_id):
|
|
||||||
"""Soft-delete a service order. Restricted to owner/admin."""
|
|
||||||
if g.employee_role not in ('owner', 'admin'):
|
|
||||||
return jsonify({'error': 'Solo administradores pueden eliminar órdenes'}), 403
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
delete_service_order(conn, so_id)
|
|
||||||
return jsonify({'message': 'Orden eliminada'})
|
|
||||||
except ValueError as e:
|
|
||||||
return jsonify({'error': str(e)}), 404
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||||
@require_workshop_action('change_status')
|
@require_auth()
|
||||||
def change_status(so_id):
|
def change_status(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
new_status = data.get('status')
|
new_status = data.get('status')
|
||||||
if not new_status:
|
if not new_status:
|
||||||
return jsonify({'error': 'status is required'}), 400
|
return jsonify({'error': 'status is required'}), 400
|
||||||
if new_status not in _visible_statuses():
|
|
||||||
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
result = update_status(
|
result = update_status(
|
||||||
@@ -338,7 +130,7 @@ def change_status(so_id):
|
|||||||
# ─── Items (Parts) ─────────────────────────────
|
# ─── Items (Parts) ─────────────────────────────
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def add_order_item(so_id):
|
def add_order_item(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -350,7 +142,7 @@ def add_order_item(so_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def update_order_item(item_id):
|
def update_order_item(item_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -364,7 +156,7 @@ def update_order_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def delete_order_item(item_id):
|
def delete_order_item(item_id):
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
@@ -377,7 +169,7 @@ def delete_order_item(item_id):
|
|||||||
# ─── Labor ─────────────────────────────
|
# ─── Labor ─────────────────────────────
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||||
@require_workshop_action('add_labor')
|
@require_auth()
|
||||||
def add_order_labor(so_id):
|
def add_order_labor(so_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not data.get('description'):
|
if not data.get('description'):
|
||||||
@@ -391,7 +183,7 @@ def add_order_labor(so_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||||
@require_workshop_action('add_labor')
|
@require_auth()
|
||||||
def update_order_labor(labor_id):
|
def update_order_labor(labor_id):
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -405,7 +197,7 @@ def update_order_labor(labor_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||||
@require_workshop_action('add_labor')
|
@require_auth()
|
||||||
def delete_order_labor(labor_id):
|
def delete_order_labor(labor_id):
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
@@ -418,7 +210,7 @@ def delete_order_labor(labor_id):
|
|||||||
# ─── Kanban Summary ─────────────────────────────
|
# ─── Kanban Summary ─────────────────────────────
|
||||||
|
|
||||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||||
@require_workshop_view
|
@require_auth()
|
||||||
def kanban_summary():
|
def kanban_summary():
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
try:
|
try:
|
||||||
@@ -432,7 +224,7 @@ def kanban_summary():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def reserve_order_item(so_id, item_id):
|
def reserve_order_item(so_id, item_id):
|
||||||
"""Reserve inventory for a service order item."""
|
"""Reserve inventory for a service order item."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -446,7 +238,7 @@ def reserve_order_item(so_id, item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||||
@require_workshop_action('add_items')
|
@require_auth()
|
||||||
def release_order_item(so_id, item_id):
|
def release_order_item(so_id, item_id):
|
||||||
"""Release a previous inventory reservation."""
|
"""Release a previous inventory reservation."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -464,7 +256,6 @@ def release_order_item(so_id, item_id):
|
|||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
|
||||||
@require_auth('pos.sell')
|
@require_auth('pos.sell')
|
||||||
@require_workshop_action('convert_to_sale')
|
|
||||||
def convert_order_to_sale(so_id):
|
def convert_order_to_sale(so_id):
|
||||||
"""Convert a service order into a POS sale.
|
"""Convert a service order into a POS sale.
|
||||||
|
|
||||||
@@ -499,43 +290,11 @@ def convert_order_to_sale(so_id):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
# ─── Convert to remission note ────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
|
|
||||||
@require_auth('pos.remission')
|
|
||||||
@require_workshop_action('convert_to_remission')
|
|
||||||
def convert_order_to_remission(so_id):
|
|
||||||
"""Convert a service order into a counter remission note (pending payment).
|
|
||||||
|
|
||||||
Body: {
|
|
||||||
register_id: int (optional),
|
|
||||||
notes: str (optional)
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
sale_payload = {
|
|
||||||
'register_id': data.get('register_id'),
|
|
||||||
'notes': data.get('notes'),
|
|
||||||
}
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
result = convert_to_remission(
|
|
||||||
conn, so_id, sale_payload, employee_id=g.employee_id
|
|
||||||
)
|
|
||||||
return jsonify(result), 201
|
|
||||||
except ValueError as e:
|
|
||||||
return jsonify({'error': str(e)}), 400
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Mechanic assignment ──────────────────────────
|
# ─── Mechanic assignment ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||||
@require_workshop_action('assign_mechanic')
|
@require_auth()
|
||||||
def assign_mechanic_endpoint(so_id):
|
def assign_mechanic_endpoint(so_id):
|
||||||
"""Assign a mechanic/technician to a service order."""
|
"""Assign a mechanic/technician to a service order."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -553,144 +312,11 @@ def assign_mechanic_endpoint(so_id):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/customers', methods=['POST'])
|
|
||||||
@require_workshop_action('edit_order')
|
|
||||||
def create_customer_for_workshop():
|
|
||||||
"""Create a customer directly from the workshop flow."""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
if not data.get('name'):
|
|
||||||
return jsonify({'error': 'El nombre es obligatorio'}), 400
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
try:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO customers
|
|
||||||
(branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
|
|
||||||
cp, email, phone, address, price_tier, credit_limit, max_discount_pct, vehicle_info)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
data.get('branch_id', g.branch_id), data['name'], data.get('rfc'),
|
|
||||||
data.get('razon_social'), data.get('regimen_fiscal'), data.get('uso_cfdi', 'G03'),
|
|
||||||
data.get('cp'), data.get('email'), data.get('phone'), data.get('address'),
|
|
||||||
data.get('price_tier', 1), data.get('credit_limit', 0), data.get('max_discount_pct', 0),
|
|
||||||
json.dumps(data['vehicle_info']) if data.get('vehicle_info') else None
|
|
||||||
))
|
|
||||||
customer_id = cur.fetchone()[0]
|
|
||||||
conn.commit()
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'id': customer_id, 'message': 'Cliente creado'}), 201
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/vehicles', methods=['POST'])
|
|
||||||
@require_workshop_action('edit_order')
|
|
||||||
def create_vehicle_for_workshop():
|
|
||||||
"""Create a fleet vehicle directly from the workshop flow.
|
|
||||||
|
|
||||||
Vehicles must be assigned to a customer. Brand and model are required;
|
|
||||||
plate is optional and VIN is not used.
|
|
||||||
"""
|
|
||||||
data = request.get_json() or {}
|
|
||||||
if not data.get('make') or not data.get('model'):
|
|
||||||
return jsonify({'error': 'Marca y modelo son obligatorios'}), 400
|
|
||||||
if not data.get('customer_id'):
|
|
||||||
return jsonify({'error': 'El vehiculo debe estar asignado a un cliente'}), 400
|
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
cur = conn.cursor()
|
|
||||||
try:
|
|
||||||
cur.execute("SELECT name FROM customers WHERE id = %s", (data['customer_id'],))
|
|
||||||
cust = cur.fetchone()
|
|
||||||
if not cust:
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': 'Cliente no encontrado'}), 404
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO fleet_vehicles
|
|
||||||
(branch_id, customer_id, plate, make, model, year,
|
|
||||||
current_mileage, fuel_type, color, owner_name, notes)
|
|
||||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
data.get('branch_id', g.branch_id), data['customer_id'], data.get('plate'),
|
|
||||||
data.get('make'), data.get('model'), data.get('year'),
|
|
||||||
data.get('current_mileage', 0), data.get('fuel_type', 'gasolina'),
|
|
||||||
data.get('color'), cust[0], data.get('notes'),
|
|
||||||
))
|
|
||||||
vehicle_id = cur.fetchone()[0]
|
|
||||||
conn.commit()
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'id': vehicle_id, 'message': 'Vehiculo creado'}), 201
|
|
||||||
except Exception as e:
|
|
||||||
conn.rollback()
|
|
||||||
cur.close(); conn.close()
|
|
||||||
return jsonify({'error': str(e)}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/inventory-search', methods=['GET'])
|
|
||||||
@require_workshop_view
|
|
||||||
def inventory_search():
|
|
||||||
"""Search active inventory for attaching parts to a service order.
|
|
||||||
|
|
||||||
Does not require inventory.view so workshop users can pick parts.
|
|
||||||
"""
|
|
||||||
q = request.args.get('q', '').strip()
|
|
||||||
if not q:
|
|
||||||
return jsonify({'data': []})
|
|
||||||
|
|
||||||
branch_id = request.args.get('branch_id', g.branch_id)
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
like = f'%{q}%'
|
|
||||||
if branch_id:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
|
|
||||||
COALESCE((SELECT stock FROM inventory_stock
|
|
||||||
WHERE inventory_id = i.id AND branch_id = %s), 0) AS stock,
|
|
||||||
i.cost, i.price_1
|
|
||||||
FROM inventory i
|
|
||||||
WHERE i.is_active = true
|
|
||||||
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
|
|
||||||
ORDER BY i.name
|
|
||||||
LIMIT 20
|
|
||||||
""", (branch_id, like, like, like))
|
|
||||||
else:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT i.id, i.part_number, i.name, i.brand, i.unit,
|
|
||||||
COALESCE((SELECT stock FROM inventory_stock_summary
|
|
||||||
WHERE inventory_id = i.id), 0) AS stock,
|
|
||||||
i.cost, i.price_1
|
|
||||||
FROM inventory i
|
|
||||||
WHERE i.is_active = true
|
|
||||||
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)
|
|
||||||
ORDER BY i.name
|
|
||||||
LIMIT 20
|
|
||||||
""", (like, like, like))
|
|
||||||
items = []
|
|
||||||
for r in cur.fetchall():
|
|
||||||
items.append({
|
|
||||||
'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3], 'unit': r[4],
|
|
||||||
'stock': float(r[5]) if r[5] else 0,
|
|
||||||
'cost': float(r[6]) if r[6] else 0,
|
|
||||||
'price_1': float(r[7]) if r[7] else 0,
|
|
||||||
})
|
|
||||||
cur.close()
|
|
||||||
return jsonify({'data': items})
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Service catalog (reusable labor) ─────────────
|
# ─── Service catalog (reusable labor) ─────────────
|
||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog', methods=['GET'])
|
@service_order_bp.route('/service-catalog', methods=['GET'])
|
||||||
@require_workshop_view
|
@require_auth()
|
||||||
def list_catalog():
|
def list_catalog():
|
||||||
"""List reusable labor/service concepts."""
|
"""List reusable labor/service concepts."""
|
||||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||||
@@ -703,7 +329,7 @@ def list_catalog():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def create_catalog_item():
|
def create_catalog_item():
|
||||||
"""Create a reusable labor concept."""
|
"""Create a reusable labor concept."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -719,7 +345,7 @@ def create_catalog_item():
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def update_catalog_item(item_id):
|
def update_catalog_item(item_id):
|
||||||
"""Update a reusable labor concept."""
|
"""Update a reusable labor concept."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -734,7 +360,7 @@ def update_catalog_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def delete_catalog_item(item_id):
|
def delete_catalog_item(item_id):
|
||||||
"""Soft-delete a reusable labor concept."""
|
"""Soft-delete a reusable labor concept."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
@@ -749,7 +375,7 @@ def delete_catalog_item(item_id):
|
|||||||
|
|
||||||
|
|
||||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||||
@require_workshop_action('edit_order')
|
@require_auth()
|
||||||
def print_service_order_ticket(so_id):
|
def print_service_order_ticket(so_id):
|
||||||
"""Generate a printable ticket for a service order.
|
"""Generate a printable ticket for a service order.
|
||||||
|
|
||||||
@@ -777,9 +403,7 @@ def print_service_order_ticket(so_id):
|
|||||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||||
try:
|
try:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT 'business_name' AS k, value FROM tenant_config WHERE key IN ('tenant_nombre','receipt_store_name')
|
"SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')"
|
||||||
UNION ALL SELECT 'rfc', value FROM tenant_config WHERE key IN ('tenant_rfc','receipt_rfc')
|
|
||||||
UNION ALL SELECT 'address', value FROM tenant_config WHERE key IN ('tenant_direccion','receipt_address')"""
|
|
||||||
)
|
)
|
||||||
for rw in cur.fetchall():
|
for rw in cur.fetchall():
|
||||||
if rw[0] == 'business_name':
|
if rw[0] == 'business_name':
|
||||||
|
|||||||
@@ -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'])
|
@tasks_bp.route('/<task_id>/status', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth
|
||||||
def task_status(task_id):
|
def task_status(task_id):
|
||||||
"""Get status of a background task."""
|
"""Get status of a background task."""
|
||||||
from celery_app import celery
|
from celery_app import celery
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
Endpoints (all under /pos/api):
|
Endpoints (all under /pos/api):
|
||||||
GET/POST /warranties
|
GET/POST /warranties
|
||||||
GET /warranties/<id>
|
GET /warranties/<id>
|
||||||
GET /warranties/<id>/print (HTML)
|
|
||||||
GET /customers/<id>/warranties
|
GET /customers/<id>/warranties
|
||||||
POST /warranty-claims
|
POST /warranty-claims
|
||||||
GET /warranty-claims
|
GET /warranty-claims
|
||||||
@@ -12,7 +11,7 @@ Endpoints (all under /pos/api):
|
|||||||
PUT /warranty-claims/<id>/close
|
PUT /warranty-claims/<id>/close
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify, g, render_template_string
|
from flask import Blueprint, request, jsonify, g
|
||||||
from middleware import require_auth
|
from middleware import require_auth
|
||||||
from tenant_db import get_tenant_conn
|
from tenant_db import get_tenant_conn
|
||||||
from services.warranty_engine import (
|
from services.warranty_engine import (
|
||||||
@@ -44,10 +43,7 @@ def get_warranties():
|
|||||||
def post_warranty():
|
def post_warranty():
|
||||||
"""Register a warranty (usually called at sale time)."""
|
"""Register a warranty (usually called at sale time)."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id']
|
required = ['sale_id', 'sale_item_id', 'inventory_id', 'customer_id', 'warranty_months']
|
||||||
# warranty_months is required unless a day-based coverage is given (used parts)
|
|
||||||
if 'warranty_months' not in data and 'warranty_days' not in data:
|
|
||||||
required.append('warranty_months')
|
|
||||||
missing = [f for f in required if f not in data]
|
missing = [f for f in required if f not in data]
|
||||||
if missing:
|
if missing:
|
||||||
return jsonify({'error': f'Missing fields: {missing}'}), 400
|
return jsonify({'error': f'Missing fields: {missing}'}), 400
|
||||||
@@ -60,12 +56,11 @@ def post_warranty():
|
|||||||
sale_item_id=data['sale_item_id'],
|
sale_item_id=data['sale_item_id'],
|
||||||
inventory_id=data['inventory_id'],
|
inventory_id=data['inventory_id'],
|
||||||
customer_id=data['customer_id'],
|
customer_id=data['customer_id'],
|
||||||
warranty_months=int(data.get('warranty_months') or 0),
|
warranty_months=int(data['warranty_months']),
|
||||||
supplier_id=data.get('supplier_id'),
|
supplier_id=data.get('supplier_id'),
|
||||||
part_number=data.get('part_number'),
|
part_number=data.get('part_number'),
|
||||||
name=data.get('name'),
|
name=data.get('name'),
|
||||||
notes=data.get('notes'),
|
notes=data.get('notes'),
|
||||||
warranty_days=int(data['warranty_days']) if data.get('warranty_days') else None,
|
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -92,49 +87,6 @@ def get_warranty_detail(warranty_id):
|
|||||||
return jsonify(w)
|
return jsonify(w)
|
||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/warranties/<int:warranty_id>/print', methods=['GET'])
|
|
||||||
@require_auth('inventory.view')
|
|
||||||
def print_warranty(warranty_id):
|
|
||||||
"""Printable warranty receipt (HTML).
|
|
||||||
|
|
||||||
Serves both month-based and Yonke day-based coverage so a used-part buyer
|
|
||||||
gets a physical voucher with the coverage window.
|
|
||||||
"""
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
|
||||||
try:
|
|
||||||
w = get_warranty(conn, warranty_id)
|
|
||||||
if not w:
|
|
||||||
return jsonify({'error': 'Warranty not found'}), 404
|
|
||||||
business = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': '', 'phone': ''}
|
|
||||||
try:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
SELECT 'name' AS k, value FROM tenant_config
|
|
||||||
WHERE key IN ('tenant_nombre', 'receipt_store_name')
|
|
||||||
UNION ALL SELECT 'rfc', value FROM tenant_config
|
|
||||||
WHERE key IN ('tenant_rfc', 'receipt_rfc')
|
|
||||||
UNION ALL SELECT 'address', value FROM tenant_config
|
|
||||||
WHERE key IN ('tenant_direccion', 'receipt_address')
|
|
||||||
UNION ALL SELECT 'phone', value FROM tenant_config
|
|
||||||
WHERE key IN ('tenant_telefono', 'receipt_phone')
|
|
||||||
""")
|
|
||||||
for k, v in cur.fetchall():
|
|
||||||
if v:
|
|
||||||
business[k] = v
|
|
||||||
cur.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
coverage = (f"{w['warranty_days']} días" if w.get('warranty_days')
|
|
||||||
else f"{w.get('warranty_months') or 0} meses")
|
|
||||||
html = render_template_string(
|
|
||||||
WARRANTY_PRINT_TEMPLATE, w=w, business=business, coverage=coverage
|
|
||||||
)
|
|
||||||
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
|
||||||
|
|
||||||
|
|
||||||
@warranty_bp.route('/customers/<int:customer_id>/warranties', methods=['GET'])
|
@warranty_bp.route('/customers/<int:customer_id>/warranties', methods=['GET'])
|
||||||
@require_auth('inventory.view')
|
@require_auth('inventory.view')
|
||||||
def get_customer_warranties(customer_id):
|
def get_customer_warranties(customer_id):
|
||||||
@@ -254,68 +206,3 @@ def put_close_claim(claim_id):
|
|||||||
conn.rollback()
|
conn.rollback()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ── Printable warranty receipt ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
WARRANTY_PRINT_TEMPLATE = """<!DOCTYPE html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Garantia #{{ w.id }}</title>
|
|
||||||
<style>
|
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
|
|
||||||
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.08);overflow:hidden}
|
|
||||||
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:24px;text-align:center}
|
|
||||||
.header h1{font-size:20px;font-weight:700;margin-bottom:4px}
|
|
||||||
.header p{font-size:13px;opacity:.85}
|
|
||||||
.body{padding:24px}
|
|
||||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
|
|
||||||
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
|
|
||||||
.meta strong{color:#111;display:block;font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
|
|
||||||
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
|
|
||||||
.badge-active{background:#d1fae5;color:#065f46}
|
|
||||||
.badge-expired,.badge-claimed,.badge-void{background:#fee2e2;color:#991b1b}
|
|
||||||
.terms{font-size:12px;color:#6b7280;border-top:1px solid #e5e7eb;padding-top:14px}
|
|
||||||
.footer{text-align:center;padding:14px;font-size:12px;color:#9ca3af}
|
|
||||||
.actions{padding:0 24px 20px;text-align:center}
|
|
||||||
.btn{display:inline-block;padding:12px 22px;border-radius:10px;border:none;background:#1f2937;color:#fff;font-size:14px;font-weight:700;cursor:pointer}
|
|
||||||
@media print{body{background:#fff;padding:0}.card{box-shadow:none}.actions{display:none}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Comprobante de Garantia</h1>
|
|
||||||
<p>{{ business.name }}{% if business.rfc %} · 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_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
|
||||||
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
|
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
|
||||||
provider = (config.get('whatsapp_provider') or 'baileys').lower()
|
|
||||||
enabled_raw = config.get('whatsapp_enabled', '').lower()
|
enabled_raw = config.get('whatsapp_enabled', '').lower()
|
||||||
if enabled_raw == 'true':
|
if enabled_raw == 'true':
|
||||||
enabled = True
|
enabled = True
|
||||||
@@ -44,17 +43,7 @@ def _get_whatsapp_config(conn):
|
|||||||
# No explicit tenant setting: auto-enable if a bridge URL is configured
|
# No explicit tenant setting: auto-enable if a bridge URL is configured
|
||||||
enabled = bool(bridge_url)
|
enabled = bool(bridge_url)
|
||||||
|
|
||||||
return { 'provider': provider,
|
return {
|
||||||
'phone_number_id': config.get('wa_phone_number_id', ''),
|
|
||||||
'access_token': config.get('wa_access_token', ''),
|
|
||||||
'app_secret': config.get('wa_app_secret', ''),
|
|
||||||
'verify_token': config.get('wa_verify_token', ''),
|
|
||||||
'graph_url': config.get('wa_graph_url', ''),
|
|
||||||
'ai_persona': config.get('wa_ai_persona', ''),
|
|
||||||
'api_base': config.get('wa_api_base', ''),
|
|
||||||
'api_key': config.get('wa_api_key', ''),
|
|
||||||
'public_base': config.get('wa_public_base', ''),
|
|
||||||
|
|
||||||
'bridge_url': bridge_url,
|
'bridge_url': bridge_url,
|
||||||
'bridge_key': bridge_key,
|
'bridge_key': bridge_key,
|
||||||
'enabled': enabled,
|
'enabled': enabled,
|
||||||
@@ -370,11 +359,6 @@ def webhook():
|
|||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
msg = whatsapp_service.process_incoming(data)
|
msg = whatsapp_service.process_incoming(data)
|
||||||
return _process_incoming_whatsapp_msg(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
|
||||||
"""Procesa un mensaje entrante normalizado (Baileys o Meta Cloud API)."""
|
|
||||||
if not msg.get('phone') or msg.get('from_me'):
|
if not msg.get('phone') or msg.get('from_me'):
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
@@ -409,7 +393,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
|||||||
|
|
||||||
|
|
||||||
# Resolve tenant
|
# Resolve tenant
|
||||||
tenant_id = tenant_id_override or request.args.get('tenant_id', type=int)
|
tenant_id = request.args.get('tenant_id', type=int)
|
||||||
if not tenant_id:
|
if not tenant_id:
|
||||||
try:
|
try:
|
||||||
mconn = get_master_conn()
|
mconn = get_master_conn()
|
||||||
@@ -510,7 +494,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
|||||||
from services.part_kits import should_send_followup
|
from services.part_kits import should_send_followup
|
||||||
followup = should_send_followup(phone, tenant_conn)
|
followup = should_send_followup(phone, tenant_conn)
|
||||||
if followup:
|
if followup:
|
||||||
whatsapp_service.wa_send_text(wa_config, reply_to, followup)
|
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
|
||||||
cur_fu = tenant_conn.cursor()
|
cur_fu = tenant_conn.cursor()
|
||||||
cur_fu.execute(
|
cur_fu.execute(
|
||||||
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
|
||||||
@@ -560,7 +544,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
|||||||
|
|
||||||
# 7. Send reply
|
# 7. Send reply
|
||||||
if reply:
|
if reply:
|
||||||
result = whatsapp_service.wa_send_text(wa_config, reply_to, reply)
|
result = whatsapp_service.send_message(reply_to, reply, bridge_url=wa_config.get('bridge_url'))
|
||||||
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
print(f"[WA-SM] Replied to {phone}: {reply[:80]}... result={result}")
|
||||||
|
|
||||||
# Log outgoing
|
# Log outgoing
|
||||||
@@ -584,7 +568,7 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
|||||||
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
|
"Estoy teniendo problemas técnicos en este momento. 😕\n\n"
|
||||||
f"Por favor llámanos directamente al {phone_branch}."
|
f"Por favor llámanos directamente al {phone_branch}."
|
||||||
)
|
)
|
||||||
whatsapp_service.wa_send_text(wa_config, reply_to, fallback)
|
whatsapp_service.send_message(reply_to, fallback, bridge_url=wa_config.get('bridge_url'))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -599,221 +583,10 @@ def _process_incoming_whatsapp_msg(msg, tenant_id_override=None):
|
|||||||
master_conn.close()
|
master_conn.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
# WA CRM / Meta Cloud API webhook (1 número por refaccionaria)
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
import os as _os_wa
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_tenant_by_pnid(phone_number_id):
|
|
||||||
"""Resolve (tenant_id, wa_config) by Meta phone_number_id. Cached per process."""
|
|
||||||
if not phone_number_id:
|
|
||||||
return None, None
|
|
||||||
cache = getattr(_resolve_tenant_by_pnid, '_cache', None)
|
|
||||||
if cache is None:
|
|
||||||
cache = _resolve_tenant_by_pnid._cache = {}
|
|
||||||
if phone_number_id in cache:
|
|
||||||
return cache[phone_number_id]
|
|
||||||
|
|
||||||
try:
|
|
||||||
mconn = get_master_conn()
|
|
||||||
mcur = mconn.cursor()
|
|
||||||
mcur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
|
|
||||||
tenants = mcur.fetchall()
|
|
||||||
mcur.close()
|
|
||||||
mconn.close()
|
|
||||||
except Exception:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
from tenant_db import get_tenant_conn_by_dbname
|
|
||||||
for tid, db_name in tenants:
|
|
||||||
try:
|
|
||||||
tconn = get_tenant_conn_by_dbname(db_name)
|
|
||||||
cfg = _get_whatsapp_config(tconn)
|
|
||||||
tconn.close()
|
|
||||||
if str(cfg.get('phone_number_id') or '') == str(phone_number_id):
|
|
||||||
cache[phone_number_id] = (tid, cfg)
|
|
||||||
return tid, cfg
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
cache[phone_number_id] = (None, None)
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _meta_download_media_b64(cfg, media_id):
|
|
||||||
"""Download Meta media (audio/image/document) → base64 + mimetype."""
|
|
||||||
import requests as _rq
|
|
||||||
import base64 as _b64
|
|
||||||
try:
|
|
||||||
base = (cfg.get('graph_url') or 'https://graph.facebook.com/v21.0').rstrip('/')
|
|
||||||
headers = {'Authorization': f"Bearer {cfg.get('access_token')}"}
|
|
||||||
info = _rq.get(f"{base}/{media_id}", headers=headers, timeout=20).json()
|
|
||||||
url = info.get('url')
|
|
||||||
if not url:
|
|
||||||
return None, None
|
|
||||||
data = _rq.get(url, headers=headers, timeout=60).content
|
|
||||||
return _b64.b64encode(data).decode(), info.get('mime_type', 'application/octet-stream')
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[WA-CRM] media download failed: {e}")
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _meta_parse_msg(payload, cfg):
|
|
||||||
"""Parse a Meta Cloud API webhook payload into the internal msg dict.
|
|
||||||
Returns (phone_number_id, msg) — msg may be None for non-message events."""
|
|
||||||
pnid = None
|
|
||||||
for entry in payload.get('entry', []):
|
|
||||||
for change in entry.get('changes', []):
|
|
||||||
value = change.get('value', {})
|
|
||||||
pnid = value.get('metadata', {}).get('phone_number_id', pnid)
|
|
||||||
if not value.get('messages'):
|
|
||||||
continue
|
|
||||||
m = value['messages'][0]
|
|
||||||
contact = (value.get('contacts') or [{}])[0]
|
|
||||||
push_name = (contact.get('profile') or {}).get('name', '')
|
|
||||||
msg = {
|
|
||||||
'phone': m.get('from', ''),
|
|
||||||
'from_me': False,
|
|
||||||
'message_id': m.get('id', ''),
|
|
||||||
'push_name': push_name,
|
|
||||||
'text': '',
|
|
||||||
'media_kind': 'text',
|
|
||||||
}
|
|
||||||
mtype = m.get('type', '')
|
|
||||||
if mtype == 'text':
|
|
||||||
msg['text'] = m.get('text', {}).get('body', '')
|
|
||||||
elif mtype == 'image':
|
|
||||||
msg['media_kind'] = 'image'
|
|
||||||
msg['text'] = m.get('image', {}).get('caption', '') or '(imagen)'
|
|
||||||
b64, mt = _meta_download_media_b64(cfg, m.get('image', {}).get('id'))
|
|
||||||
if b64:
|
|
||||||
msg['media_base64'] = b64
|
|
||||||
msg['media_mimetype'] = mt
|
|
||||||
elif mtype == 'audio' or mtype == 'voice':
|
|
||||||
msg['media_kind'] = 'audio'
|
|
||||||
b64, mt = _meta_download_media_b64(cfg, m.get('audio', {}).get('id'))
|
|
||||||
if b64:
|
|
||||||
msg['media_base64'] = b64
|
|
||||||
msg['media_mimetype'] = mt or 'audio/ogg'
|
|
||||||
elif mtype == 'document':
|
|
||||||
msg['media_kind'] = 'image'
|
|
||||||
msg['text'] = m.get('document', {}).get('caption', '') or '(documento)'
|
|
||||||
b64, mt = _meta_download_media_b64(cfg, m.get('document', {}).get('id'))
|
|
||||||
if b64:
|
|
||||||
msg['media_base64'] = b64
|
|
||||||
msg['media_mimetype'] = mt
|
|
||||||
elif mtype == 'location':
|
|
||||||
lat = m.get('location', {}).get('latitude')
|
|
||||||
lng = m.get('location', {}).get('longitude')
|
|
||||||
msg['media_kind'] = 'location'
|
|
||||||
msg['latitude'] = lat
|
|
||||||
msg['longitude'] = lng
|
|
||||||
return pnid, msg
|
|
||||||
return pnid, None
|
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route('/wacrm/webhook', methods=['GET'])
|
|
||||||
def wacrm_webhook_verify():
|
|
||||||
"""Meta webhook verification handshake."""
|
|
||||||
verify_token = request.args.get('hub.verify_token', '')
|
|
||||||
challenge = request.args.get('hub.challenge', '')
|
|
||||||
if not challenge:
|
|
||||||
return 'Bad request', 400
|
|
||||||
|
|
||||||
expected_global = _os_wa.environ.get('WA_VERIFY_TOKEN', '')
|
|
||||||
if expected_global and verify_token == expected_global:
|
|
||||||
return challenge, 200
|
|
||||||
|
|
||||||
# per-tenant verify tokens
|
|
||||||
try:
|
|
||||||
mconn = get_master_conn()
|
|
||||||
mcur = mconn.cursor()
|
|
||||||
mcur.execute("SELECT id, db_name FROM tenants WHERE is_active = true ORDER BY id")
|
|
||||||
tenants = mcur.fetchall()
|
|
||||||
mcur.close()
|
|
||||||
mconn.close()
|
|
||||||
from tenant_db import get_tenant_conn_by_dbname
|
|
||||||
for tid, db_name in tenants:
|
|
||||||
try:
|
|
||||||
tconn = get_tenant_conn_by_dbname(db_name)
|
|
||||||
cur = tconn.cursor()
|
|
||||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'wa_verify_token'")
|
|
||||||
row = cur.fetchone()
|
|
||||||
tconn.close()
|
|
||||||
if row and row[0] and verify_token == row[0]:
|
|
||||||
return challenge, 200
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return 'Forbidden', 403
|
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route('/wacrm/webhook', methods=['POST'])
|
|
||||||
def wacrm_webhook():
|
|
||||||
"""Meta Cloud API events (WA CRM self-hosted, 1 número por tenant)."""
|
|
||||||
raw = request.get_data()
|
|
||||||
payload = request.get_json(silent=True) or {}
|
|
||||||
signature = request.headers.get('X-Hub-Signature-256', '')
|
|
||||||
|
|
||||||
# First parse to get phone_number_id (to resolve tenant + its app secret)
|
|
||||||
pnid_probe = None
|
|
||||||
for entry in payload.get('entry', []):
|
|
||||||
for change in entry.get('changes', []):
|
|
||||||
pnid_probe = change.get('value', {}).get('metadata', {}).get('phone_number_id')
|
|
||||||
break
|
|
||||||
if pnid_probe:
|
|
||||||
break
|
|
||||||
|
|
||||||
tenant_id, wa_cfg = _resolve_tenant_by_pnid(pnid_probe) if pnid_probe else (None, None)
|
|
||||||
app_secret = (wa_cfg or {}).get('app_secret') or _os_wa.environ.get('WA_APP_SECRET', '')
|
|
||||||
|
|
||||||
from services.whatsapp_service import meta_verify_signature
|
|
||||||
if not meta_verify_signature(app_secret, raw, signature):
|
|
||||||
return 'Invalid signature', 403
|
|
||||||
|
|
||||||
pnid, msg = _meta_parse_msg(payload, wa_cfg or {})
|
|
||||||
|
|
||||||
# Formato de automatización de WA CRM: {"phone": "...", "message": "..."} (o from/text)
|
|
||||||
if not msg:
|
|
||||||
raw_phone = payload.get('phone') or payload.get('from') or payload.get('wa_id')
|
|
||||||
raw_text = payload.get('message') or payload.get('text') or payload.get('body')
|
|
||||||
if raw_phone and raw_text is not None:
|
|
||||||
msg = {
|
|
||||||
'phone': str(raw_phone),
|
|
||||||
'from_me': bool(payload.get('from_me')),
|
|
||||||
'message_id': payload.get('message_id') or payload.get('id') or '',
|
|
||||||
'push_name': payload.get('name') or payload.get('push_name') or '',
|
|
||||||
'text': str(raw_text),
|
|
||||||
'media_kind': 'text',
|
|
||||||
}
|
|
||||||
if not msg:
|
|
||||||
return jsonify({'ok': True}) # statuses / reads / non-message events
|
|
||||||
|
|
||||||
if not tenant_id:
|
|
||||||
tid_arg = request.args.get('tenant_id', type=int)
|
|
||||||
if tid_arg:
|
|
||||||
from tenant_db import get_tenant_conn as _gtc
|
|
||||||
try:
|
|
||||||
_c = _gtc(tid_arg)
|
|
||||||
wa_cfg = _get_whatsapp_config(_c)
|
|
||||||
_c.close()
|
|
||||||
tenant_id = tid_arg
|
|
||||||
except Exception:
|
|
||||||
tenant_id = None
|
|
||||||
if not tenant_id:
|
|
||||||
print(f"[WA-CRM] No tenant for phone_number_id={pnid_probe}")
|
|
||||||
return jsonify({'ok': True})
|
|
||||||
|
|
||||||
return _process_incoming_whatsapp_msg(msg, tenant_id_override=tenant_id)
|
|
||||||
|
|
||||||
|
|
||||||
@whatsapp_bp.route('/send', methods=['POST'])
|
@whatsapp_bp.route('/send', methods=['POST'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def send():
|
def send():
|
||||||
|
|||||||
@@ -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_API_KEY = os.environ.get('QWEN_API_KEY', '')
|
||||||
QWEN_MODEL = os.environ.get('QWEN_MODEL', 'qwen3.6')
|
QWEN_MODEL = os.environ.get('QWEN_MODEL', 'qwen3.6')
|
||||||
|
|
||||||
# ─── Búsqueda semántica (rerank / embeddings, mismo proveedor QWEN) ────────
|
|
||||||
# Consumidas por services/semantic_search.py. OJO: se leen al importar el
|
|
||||||
# módulo — cambiar el flag requiere recargar el worker, no es hot-reload.
|
|
||||||
QWEN_RERANK_MODEL = os.environ.get('QWEN_RERANK_MODEL', 'rerank')
|
|
||||||
QWEN_EMBED_MODEL = os.environ.get('QWEN_EMBED_MODEL', 'qwen3-embedding')
|
|
||||||
# Interruptor maestro (scope 'catalog': catálogo público / API / dashboard).
|
|
||||||
SEARCH_RERANK_ENABLED = os.environ.get('SEARCH_RERANK_ENABLED', 'true').lower() == 'true'
|
|
||||||
# Scope POS (caja + chat): apagado por defecto, el rerank agrega ~0.6-1.8 s a
|
|
||||||
# una búsqueda que responde en ~18 ms (ver services/semantic_search.py).
|
|
||||||
SEARCH_RERANK_POS_ENABLED = os.environ.get('SEARCH_RERANK_POS_ENABLED', 'false').lower() == 'true'
|
|
||||||
# Un valor no numérico en el .env no debe tumbar el arranque del POS.
|
|
||||||
try:
|
|
||||||
SEARCH_RERANK_TIMEOUT = float(os.environ.get('SEARCH_RERANK_TIMEOUT', '2'))
|
|
||||||
except ValueError:
|
|
||||||
SEARCH_RERANK_TIMEOUT = 2.0
|
|
||||||
try:
|
|
||||||
SEARCH_RERANK_MAX_CANDIDATES = int(os.environ.get('SEARCH_RERANK_MAX_CANDIDATES', '50'))
|
|
||||||
except ValueError:
|
|
||||||
SEARCH_RERANK_MAX_CANDIDATES = 50
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Internal Cron / Job Security ──────────────────────────────────────────
|
# ─── Internal Cron / Job Security ──────────────────────────────────────────
|
||||||
INTERNAL_API_KEY = os.environ.get('INTERNAL_API_KEY', '')
|
INTERNAL_API_KEY = os.environ.get('INTERNAL_API_KEY', '')
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
|
|||||||
# Check permissions
|
# Check permissions
|
||||||
if required_permissions:
|
if required_permissions:
|
||||||
missing = set(required_permissions) - g.permissions
|
missing = set(required_permissions) - g.permissions
|
||||||
# owner/admin roles bypass all permission checks
|
# owner role bypasses all permission checks
|
||||||
if g.employee_role not in ('owner', 'admin') and missing:
|
if g.employee_role != 'owner' and missing:
|
||||||
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
|
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
|
|||||||
|
|
||||||
def has_permission(permission):
|
def has_permission(permission):
|
||||||
"""Check if current user has a specific permission. Use inside a route."""
|
"""Check if current user has a specific permission. Use inside a route."""
|
||||||
return g.employee_role in ('owner', 'admin') or permission in g.permissions
|
return g.employee_role == 'owner' or permission in g.permissions
|
||||||
|
|||||||
@@ -89,10 +89,8 @@ def resolve_tenant():
|
|||||||
g.tenant_name = None
|
g.tenant_name = None
|
||||||
g.tenant_subdomain = None
|
g.tenant_subdomain = None
|
||||||
|
|
||||||
# Skip for static files, health check and Prometheus metrics scrape
|
# Skip for static files and health check
|
||||||
if (request.path.startswith('/pos/static/')
|
if request.path.startswith('/pos/static/') or request.path == '/pos/health':
|
||||||
or request.path == '/pos/health'
|
|
||||||
or request.path == '/metrics'):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
subdomain = _extract_subdomain()
|
subdomain = _extract_subdomain()
|
||||||
|
|||||||
@@ -52,29 +52,6 @@ MIGRATIONS = {
|
|||||||
"v4.3": "v4.3_facturapi.sql",
|
"v4.3": "v4.3_facturapi.sql",
|
||||||
"v4.4": "v4.4_workshop.sql",
|
"v4.4": "v4.4_workshop.sql",
|
||||||
"v4.5": "v4.5_customer_max_discount.sql",
|
"v4.5": "v4.5_customer_max_discount.sql",
|
||||||
"v4.6": "v4.6_inventory_support.sql",
|
|
||||||
"v4.7": "v4.7_workshop_business.sql",
|
|
||||||
"v4.8": "v4.8_workshop_permissions.sql",
|
|
||||||
"v4.9": "v4.9_workshop_customers_view.sql",
|
|
||||||
"v4.10": "v4.10_fleet_permissions.sql",
|
|
||||||
"v4.11": "v4.11_clean_workshop_mechanic_permissions.sql",
|
|
||||||
"v4.12": "v4.12_service_order_soft_delete.sql",
|
|
||||||
"v4.13": "v4.13_fleet_vehicle_customer.sql",
|
|
||||||
"v4.14": "v4.14_service_order_delivery_cleanup.sql",
|
|
||||||
"v4.15": "v4.15_counter_remission.sql",
|
|
||||||
"v4.16": "v4.16_remission_courier.sql",
|
|
||||||
"v4.17": "v4.17_service_order_invoice.sql",
|
|
||||||
"v4.18": "v4.18_rached_workshop.sql",
|
|
||||||
"v4.19": "v4.19_counter_inventory_create.sql",
|
|
||||||
"v4.20": "v4.20_cashier_sell_permissions.sql",
|
|
||||||
"v4.21": "v4.21_cashier_workshop_permissions.sql",
|
|
||||||
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
|
|
||||||
"v4.23": "v4.23_service_order_mechanic_name.sql",
|
|
||||||
"v4.24": "v4.24_trgm_search_indexes.sql",
|
|
||||||
"v4.25": "v4.25_transfer_documents.sql",
|
|
||||||
"v4.26": "v4.26_sales_client_uuid.sql",
|
|
||||||
"v4.27": "v4.27_yonke_m1.sql",
|
|
||||||
"v4.28": "v4.28_meli_commerce.sql",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -130,10 +107,7 @@ def apply_migration(db_name, version):
|
|||||||
def run_migrations():
|
def run_migrations():
|
||||||
"""Apply pending migrations to all tenants."""
|
"""Apply pending migrations to all tenants."""
|
||||||
tenants = get_all_tenants()
|
tenants = get_all_tenants()
|
||||||
def _version_key(v):
|
sorted_versions = sorted(MIGRATIONS.keys())
|
||||||
return tuple(int(x) for x in v.lstrip('v').split('.'))
|
|
||||||
|
|
||||||
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
|
|
||||||
|
|
||||||
print(f"Found {len(tenants)} active tenants")
|
print(f"Found {len(tenants)} active tenants")
|
||||||
print(f"Available migrations: {sorted_versions}")
|
print(f"Available migrations: {sorted_versions}")
|
||||||
@@ -141,9 +115,8 @@ def run_migrations():
|
|||||||
for tenant_id, db_name, name, current_version in tenants:
|
for tenant_id, db_name, name, current_version in tenants:
|
||||||
print(f"\n[{name}] (db={db_name}, current={current_version})")
|
print(f"\n[{name}] (db={db_name}, current={current_version})")
|
||||||
|
|
||||||
current_key = _version_key(current_version)
|
|
||||||
for version in sorted_versions:
|
for version in sorted_versions:
|
||||||
if _version_key(version) <= current_key:
|
if version <= current_version:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f" Applying {version}...", end=" ")
|
print(f" Applying {version}...", end=" ")
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
-- : SKIP
|
|
||||||
-- v1.2: Add subdomain column to tenants table (nexus_master)
|
-- v1.2: Add subdomain column to tenants table (nexus_master)
|
||||||
-- This migration runs against the MASTER database, not tenant databases.
|
-- This migration runs against the MASTER database, not tenant databases.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
-- : SKIP
|
|
||||||
-- Marketplace orders (cross-tenant)
|
-- Marketplace orders (cross-tenant)
|
||||||
-- These tables go in nexus_autoparts (master DB), not tenant DBs
|
-- These tables go in nexus_autoparts (master DB), not tenant DBs
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,15 @@
|
|||||||
-- Applies to: master DB (nexus_autoparts) and all tenant DBs
|
-- Applies to: master DB (nexus_autoparts) and all tenant DBs
|
||||||
|
|
||||||
-- ─── MASTER DB ─────────────────────────────────
|
-- ─── MASTER DB ─────────────────────────────────
|
||||||
-- These indexes only exist in the master DB (nexus_autoparts). On tenant DBs
|
|
||||||
-- (the `parts`/`warehouse_inventory` OEM tables live only in master) they are
|
|
||||||
-- skipped via existence guards so this migration can run against tenants too.
|
|
||||||
|
|
||||||
DO $$
|
-- Index for parts name lookups (used by get_part_types, shop supplies)
|
||||||
BEGIN
|
CREATE INDEX IF NOT EXISTS idx_parts_name_part ON parts(name_part);
|
||||||
IF to_regclass('public.parts') IS NOT NULL THEN
|
CREATE INDEX IF NOT EXISTS idx_parts_name_part_pattern ON parts(name_part text_pattern_ops);
|
||||||
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_parts_name_part ON parts(name_part)';
|
|
||||||
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_parts_name_part_pattern ON parts(name_part text_pattern_ops)';
|
-- Partial index for warehouse_inventory stock lookups (already created in Fase 1,
|
||||||
END IF;
|
-- kept here for completeness on fresh installs)
|
||||||
IF to_regclass('public.warehouse_inventory') IS NOT NULL THEN
|
CREATE INDEX IF NOT EXISTS idx_wi_part_stock_positive
|
||||||
EXECUTE 'CREATE INDEX IF NOT EXISTS idx_wi_part_stock_positive ON warehouse_inventory(part_id) WHERE stock_quantity > 0';
|
ON warehouse_inventory(part_id) WHERE stock_quantity > 0;
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ─── TENANT DB (run on each tenant) ────────────
|
-- ─── TENANT DB (run on each tenant) ────────────
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,9 @@
|
|||||||
-- To apply manually on a tenant with WhatsApp tables:
|
-- To apply manually on a tenant with WhatsApp tables:
|
||||||
-- psql <tenant_db> -f pos/migrations/v3.5_whatsapp_state_machine.sql
|
-- psql <tenant_db> -f pos/migrations/v3.5_whatsapp_state_machine.sql
|
||||||
-- (Remove the ': SKIP' line above before manual execution.)
|
-- (Remove the ': SKIP' line above before manual execution.)
|
||||||
--
|
|
||||||
-- 2026-10-11: fixed nested dollar-quoting (the outer DO used $$ and the inner
|
|
||||||
-- trigger function also used $$, which broke the parser). Now $do$ / $fn$.
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
DO $do$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
-- 1. Extender whatsapp_sessions con estado y contexto
|
-- 1. Extender whatsapp_sessions con estado y contexto
|
||||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
|
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'whatsapp_sessions') THEN
|
||||||
@@ -44,12 +41,12 @@ BEGIN
|
|||||||
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
|
CREATE INDEX IF NOT EXISTS idx_wa_cust_link_customer ON wa_customer_links(customer_id);
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
|
CREATE OR REPLACE FUNCTION update_wa_link_timestamp()
|
||||||
RETURNS TRIGGER AS $fn$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
NEW.updated_at = NOW();
|
NEW.updated_at = NOW();
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$fn$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
|
DROP TRIGGER IF EXISTS trg_wa_link_updated ON wa_customer_links;
|
||||||
CREATE TRIGGER trg_wa_link_updated
|
CREATE TRIGGER trg_wa_link_updated
|
||||||
@@ -118,4 +115,4 @@ BEGIN
|
|||||||
FROM wa_customer_links wcl
|
FROM wa_customer_links wcl
|
||||||
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
|
WHERE ws.phone = wcl.phone AND ws.customer_id IS NULL;
|
||||||
END IF;
|
END IF;
|
||||||
END $do$;
|
END $$;
|
||||||
|
|||||||
@@ -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);
|
VALUES ('220', 'IVA Trasladado', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '220', true, true);
|
||||||
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
||||||
VALUES ('230', 'ISR por Pagar', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '230', true, true);
|
VALUES ('230', 'ISR por Pagar', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '230', true, true);
|
||||||
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
|
||||||
VALUES ('240', 'Anticipos de Clientes', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '240', true, true);
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- CAPITAL (300)
|
-- CAPITAL (300)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ Account codes (from SAT seed):
|
|||||||
140 = IVA Acreditable
|
140 = IVA Acreditable
|
||||||
210 = Proveedores
|
210 = Proveedores
|
||||||
220 = IVA Trasladado
|
220 = IVA Trasladado
|
||||||
240 = Anticipos de Clientes (created on demand for layaways)
|
|
||||||
410 = Ventas
|
410 = Ventas
|
||||||
420 = Devoluciones sobre Ventas
|
420 = Devoluciones sobre Ventas
|
||||||
510 = Costo de Mercancia Vendida
|
510 = Costo de Mercancia Vendida
|
||||||
@@ -21,13 +20,10 @@ Account codes (from SAT seed):
|
|||||||
All functions receive a psycopg2 connection (caller controls commit).
|
All functions receive a psycopg2 connection (caller controls commit).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from flask import g
|
from flask import g
|
||||||
|
|
||||||
logger = logging.getLogger('accounting')
|
|
||||||
|
|
||||||
|
|
||||||
def _to_dec(val):
|
def _to_dec(val):
|
||||||
"""Convert a value to Decimal for precise arithmetic."""
|
"""Convert a value to Decimal for precise arithmetic."""
|
||||||
@@ -50,64 +46,18 @@ def _get_account_id(cur, code):
|
|||||||
|
|
||||||
def _get_account_ids(cur, codes):
|
def _get_account_ids(cur, codes):
|
||||||
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
||||||
cur.execute(
|
result = {}
|
||||||
"SELECT code, id FROM accounts WHERE code = ANY(%s) AND is_active = true",
|
for code in codes:
|
||||||
(list(codes),)
|
result[code] = _get_account_id(cur, code)
|
||||||
)
|
return result
|
||||||
rows = {row[0]: row[1] for row in cur.fetchall()}
|
|
||||||
missing = set(codes) - set(rows)
|
|
||||||
if missing:
|
|
||||||
raise ValueError(f"Account(s) with code {sorted(missing)} not found")
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_account(cur, code, name, parent_code, acc_type):
|
|
||||||
"""Return the account ID for a code, creating it if it is missing.
|
|
||||||
|
|
||||||
Tenants seeded before an auxiliary account (e.g. customer advances) was
|
|
||||||
introduced do not have it in their chart of accounts, so it is created
|
|
||||||
on first use. Creation is idempotent (accounts.code is unique).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cur: psycopg2 cursor
|
|
||||||
code: account code to look up / create
|
|
||||||
name: account name used when creating
|
|
||||||
parent_code: code of the parent account (None for roots)
|
|
||||||
acc_type: 'activo' | 'pasivo' | 'capital' | 'ingreso' | 'costo' | 'gasto'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: account ID
|
|
||||||
"""
|
|
||||||
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
return row[0]
|
|
||||||
|
|
||||||
parent_id = None
|
|
||||||
if parent_code:
|
|
||||||
cur.execute("SELECT id FROM accounts WHERE code = %s", (parent_code,))
|
|
||||||
parent = cur.fetchone()
|
|
||||||
parent_id = parent[0] if parent else None
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, false, true)
|
|
||||||
ON CONFLICT (code) DO NOTHING
|
|
||||||
RETURNING id
|
|
||||||
""", (code, name, parent_id, acc_type, code))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if row:
|
|
||||||
return row[0]
|
|
||||||
|
|
||||||
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
|
|
||||||
return cur.fetchone()[0]
|
|
||||||
|
|
||||||
|
|
||||||
def get_next_entry_number(conn):
|
def get_next_entry_number(conn):
|
||||||
"""Get the next sequential journal entry number.
|
"""Get the next sequential journal entry number.
|
||||||
|
|
||||||
Uses a transaction-level advisory lock to prevent duplicate numbers
|
Uses a simple MAX+1 approach. For high-concurrency environments this
|
||||||
when multiple journal entries are created concurrently.
|
could be replaced with a sequence, but for single-tenant refaccionarias
|
||||||
|
the transaction-level lock from the INSERT is sufficient.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conn: psycopg2 connection to tenant DB
|
conn: psycopg2 connection to tenant DB
|
||||||
@@ -116,7 +66,6 @@ def get_next_entry_number(conn):
|
|||||||
int: next entry number (starts at 1)
|
int: next entry number (starts at 1)
|
||||||
"""
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT pg_advisory_xact_lock(hashtext('journal_entry_number'))")
|
|
||||||
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
|
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
|
||||||
number = cur.fetchone()[0]
|
number = cur.fetchone()[0]
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -622,434 +571,6 @@ def record_cancellation_entry(conn, sale):
|
|||||||
return entry_id
|
return entry_id
|
||||||
|
|
||||||
|
|
||||||
def record_return_entry(conn, ret):
|
|
||||||
"""Generate the journal entry for a customer return (devolucion).
|
|
||||||
|
|
||||||
Reverses the revenue side of the original sale and puts the returned
|
|
||||||
goods back into inventory:
|
|
||||||
Debit 420 Devoluciones sobre Ventas = net (after discount, before tax)
|
|
||||||
Debit 220 IVA Trasladado = tax
|
|
||||||
Credit 110/111/120 (refund account) = net + tax
|
|
||||||
Debit 130 Inventarios = cost
|
|
||||||
Credit 510 Costo de Mercancia Vendida = cost
|
|
||||||
|
|
||||||
The net/tax/cost split is read from return_items joined with sale_items,
|
|
||||||
so callers only need the return id and the original sale.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: psycopg2 connection to tenant DB
|
|
||||||
ret: dict with keys:
|
|
||||||
id: return id
|
|
||||||
sale_id: original sale id (optional)
|
|
||||||
sale_type: 'cash' | 'credit' (optional, looked up from sales)
|
|
||||||
payment_method: original payment method (optional, looked up)
|
|
||||||
total: refund total (used only when the item detail is missing)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: journal entry ID
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
|
||||||
entry_date = date.today()
|
|
||||||
_check_period_open(cur, entry_date)
|
|
||||||
|
|
||||||
entry_number = get_next_entry_number(conn)
|
|
||||||
return_id = ret['id']
|
|
||||||
sale_id = ret.get('sale_id')
|
|
||||||
sale_type = ret.get('sale_type')
|
|
||||||
payment_method = ret.get('payment_method')
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT si.unit_price, si.discount_pct, si.tax_rate, si.unit_cost, ri.quantity
|
|
||||||
FROM return_items ri
|
|
||||||
JOIN sale_items si ON si.id = ri.sale_item_id
|
|
||||||
WHERE ri.return_id = %s
|
|
||||||
""", (return_id,))
|
|
||||||
item_rows = cur.fetchall()
|
|
||||||
|
|
||||||
if sale_id and (sale_type is None or payment_method is None):
|
|
||||||
cur.execute("SELECT sale_type, payment_method FROM sales WHERE id = %s", (sale_id,))
|
|
||||||
sale_row = cur.fetchone()
|
|
||||||
if sale_row:
|
|
||||||
sale_type = sale_type or sale_row[0]
|
|
||||||
payment_method = payment_method or sale_row[1]
|
|
||||||
|
|
||||||
net = Decimal('0')
|
|
||||||
tax = Decimal('0')
|
|
||||||
cost_total = Decimal('0')
|
|
||||||
for unit_price, discount_pct, tax_rate, unit_cost, quantity in item_rows:
|
|
||||||
qty = int(quantity or 0)
|
|
||||||
line_gross = (_to_dec(unit_price) * qty).quantize(TWO, ROUND_HALF_UP)
|
|
||||||
line_net = (line_gross * (Decimal('1') - _to_dec(discount_pct) / Decimal('100'))
|
|
||||||
).quantize(TWO, ROUND_HALF_UP)
|
|
||||||
net += line_net
|
|
||||||
tax += (line_net * _to_dec(tax_rate)).quantize(TWO, ROUND_HALF_UP)
|
|
||||||
cost_total += (_to_dec(unit_cost) * qty).quantize(TWO, ROUND_HALF_UP)
|
|
||||||
|
|
||||||
if net == 0 and tax == 0:
|
|
||||||
# No item detail available: fall back to the refund total (no IVA split)
|
|
||||||
net = _to_dec(ret.get('total', 0))
|
|
||||||
|
|
||||||
total = (net + tax).quantize(TWO, ROUND_HALF_UP)
|
|
||||||
|
|
||||||
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '420', '510'])
|
|
||||||
|
|
||||||
if sale_type == 'credit':
|
|
||||||
refund_account_id = accounts['120'] # Clientes
|
|
||||||
refund_desc = f'Clientes - Devolucion #{return_id}'
|
|
||||||
elif payment_method in ('transferencia', 'tarjeta'):
|
|
||||||
refund_account_id = accounts['111'] # Bancos
|
|
||||||
refund_desc = f'Bancos - Devolucion #{return_id}'
|
|
||||||
else:
|
|
||||||
refund_account_id = accounts['110'] # Caja
|
|
||||||
refund_desc = f'Caja - Devolucion #{return_id}'
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
{
|
|
||||||
'account_id': accounts['420'],
|
|
||||||
'debit': float(net.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'credit': 0,
|
|
||||||
'description': f'Devoluciones sobre ventas - Devolucion #{return_id}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'account_id': refund_account_id,
|
|
||||||
'debit': 0,
|
|
||||||
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'description': refund_desc,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if tax > 0:
|
|
||||||
lines.append({
|
|
||||||
'account_id': accounts['220'],
|
|
||||||
'debit': float(tax.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'credit': 0,
|
|
||||||
'description': f'IVA Trasladado - Devolucion #{return_id}',
|
|
||||||
})
|
|
||||||
|
|
||||||
if cost_total > 0:
|
|
||||||
lines.append({
|
|
||||||
'account_id': accounts['130'],
|
|
||||||
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'credit': 0,
|
|
||||||
'description': f'Inventarios - Devolucion #{return_id}',
|
|
||||||
})
|
|
||||||
lines.append({
|
|
||||||
'account_id': accounts['510'],
|
|
||||||
'debit': 0,
|
|
||||||
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'description': f'Costo mercancia - Devolucion #{return_id}',
|
|
||||||
})
|
|
||||||
|
|
||||||
entry_id = _create_entry(
|
|
||||||
cur, entry_number, entry_date, 'egreso',
|
|
||||||
f'Devolucion #{return_id}' + (f' - Venta #{sale_id}' if sale_id else ''),
|
|
||||||
'return', return_id, lines
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
return entry_id
|
|
||||||
|
|
||||||
|
|
||||||
def record_layaway_entry(conn, layaway):
|
|
||||||
"""Generate the journal entry for a layaway (apartado) payment.
|
|
||||||
|
|
||||||
A layaway deposit is money held on behalf of the customer, not revenue,
|
|
||||||
so it is recorded as a customer advance:
|
|
||||||
Debit 110 Caja (or 111 Bancos) = amount
|
|
||||||
Credit 240 Anticipos de Clientes = amount
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: psycopg2 connection
|
|
||||||
layaway: dict with keys:
|
|
||||||
id: layaway id
|
|
||||||
amount: float
|
|
||||||
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int or None: journal entry ID (None when amount <= 0)
|
|
||||||
"""
|
|
||||||
amount = _to_dec(layaway.get('amount', 0))
|
|
||||||
if amount <= 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cur = conn.cursor()
|
|
||||||
entry_date = date.today()
|
|
||||||
_check_period_open(cur, entry_date)
|
|
||||||
|
|
||||||
entry_number = get_next_entry_number(conn)
|
|
||||||
layaway_id = layaway['id']
|
|
||||||
method = layaway.get('payment_method', 'efectivo')
|
|
||||||
|
|
||||||
accounts = _get_account_ids(cur, ['110', '111'])
|
|
||||||
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
|
|
||||||
|
|
||||||
if method in ('transferencia', 'tarjeta'):
|
|
||||||
debit_account = accounts['111']
|
|
||||||
debit_desc = f'Bancos - Apartado #{layaway_id}'
|
|
||||||
else:
|
|
||||||
debit_account = accounts['110']
|
|
||||||
debit_desc = f'Caja - Apartado #{layaway_id}'
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
{
|
|
||||||
'account_id': debit_account,
|
|
||||||
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'credit': 0,
|
|
||||||
'description': debit_desc,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'account_id': advance_account,
|
|
||||||
'debit': 0,
|
|
||||||
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
entry_id = _create_entry(
|
|
||||||
cur, entry_number, entry_date, 'ingreso',
|
|
||||||
f'Apartado #{layaway_id} - anticipo ({method})',
|
|
||||||
'layaway', layaway_id, lines
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
return entry_id
|
|
||||||
|
|
||||||
|
|
||||||
def record_layaway_conversion_entry(conn, sale):
|
|
||||||
"""Generate the journal entry for a fully paid layaway converted to a sale.
|
|
||||||
|
|
||||||
Applies the advances collected during the layaway and recognizes the
|
|
||||||
revenue, trasladado IVA and cost of goods:
|
|
||||||
Debit 240 Anticipos de Clientes = total
|
|
||||||
Credit 410 Ventas = subtotal
|
|
||||||
Credit 220 IVA Trasladado = tax_total
|
|
||||||
Debit 510 Costo de Mercancia Vendida = cost
|
|
||||||
Credit 130 Inventarios = cost
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: psycopg2 connection
|
|
||||||
sale: dict with keys:
|
|
||||||
id: sale id created for the layaway
|
|
||||||
layaway_id: layaway id (optional)
|
|
||||||
subtotal: float (net, after discounts, before tax)
|
|
||||||
tax_total: float
|
|
||||||
total: float (subtotal + tax_total)
|
|
||||||
items: [{unit_cost, quantity}] (optional; read from DB when absent)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: journal entry ID
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
|
||||||
entry_date = date.today()
|
|
||||||
_check_period_open(cur, entry_date)
|
|
||||||
|
|
||||||
entry_number = get_next_entry_number(conn)
|
|
||||||
sale_id = sale['id']
|
|
||||||
layaway_id = sale.get('layaway_id')
|
|
||||||
subtotal = _to_dec(sale['subtotal'])
|
|
||||||
tax_total = _to_dec(sale['tax_total'])
|
|
||||||
total = _to_dec(sale['total'])
|
|
||||||
|
|
||||||
items = sale.get('items')
|
|
||||||
if not items:
|
|
||||||
cur.execute("SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s", (sale_id,))
|
|
||||||
items = [{'unit_cost': r[0], 'quantity': r[1]} for r in cur.fetchall()]
|
|
||||||
|
|
||||||
cost_total = Decimal('0')
|
|
||||||
for item in items:
|
|
||||||
cost_total += (_to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
|
|
||||||
).quantize(TWO, ROUND_HALF_UP)
|
|
||||||
|
|
||||||
accounts = _get_account_ids(cur, ['130', '220', '410', '510'])
|
|
||||||
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
|
|
||||||
|
|
||||||
lines = [
|
|
||||||
{
|
|
||||||
'account_id': advance_account,
|
|
||||||
'debit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'credit': 0,
|
|
||||||
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'account_id': accounts['410'],
|
|
||||||
'debit': 0,
|
|
||||||
'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'description': f'Ventas - Apartado #{layaway_id} (venta #{sale_id})',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'account_id': accounts['220'],
|
|
||||||
'debit': 0,
|
|
||||||
'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'description': f'IVA Trasladado - Apartado #{layaway_id} (venta #{sale_id})',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if cost_total > 0:
|
|
||||||
lines.append({
|
|
||||||
'account_id': accounts['510'],
|
|
||||||
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'credit': 0,
|
|
||||||
'description': f'Costo mercancia - Apartado #{layaway_id}',
|
|
||||||
})
|
|
||||||
lines.append({
|
|
||||||
'account_id': accounts['130'],
|
|
||||||
'debit': 0,
|
|
||||||
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'description': f'Inventarios - Apartado #{layaway_id}',
|
|
||||||
})
|
|
||||||
|
|
||||||
entry_id = _create_entry(
|
|
||||||
cur, entry_number, entry_date, 'ingreso',
|
|
||||||
f'Apartado #{layaway_id} - venta #{sale_id}',
|
|
||||||
'layaway', layaway_id, lines
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
return entry_id
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_supplier_payments_table(conn):
|
|
||||||
"""Create the supplier_payments table if it does not exist (idempotent).
|
|
||||||
|
|
||||||
The table links each payment to the purchase order (cuentas por pagar)
|
|
||||||
it settles. It is created on first use so tenants that predate it work
|
|
||||||
without a manual migration.
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS supplier_payments (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
po_id INTEGER REFERENCES purchase_orders(id) ON DELETE CASCADE,
|
|
||||||
supplier_id INTEGER REFERENCES suppliers(id) ON DELETE SET NULL,
|
|
||||||
amount NUMERIC(12,2) NOT NULL,
|
|
||||||
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
|
|
||||||
reference VARCHAR(100),
|
|
||||||
employee_id INTEGER REFERENCES employees(id),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
cur.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_supplier_payments_po ON supplier_payments(po_id)"
|
|
||||||
)
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
|
|
||||||
def record_supplier_payment_entry(conn, payment):
|
|
||||||
"""Register a payment to a supplier against a purchase order (CxP).
|
|
||||||
|
|
||||||
Debit 210 Proveedores = amount
|
|
||||||
Credit 110 Caja / 111 Bancos = amount
|
|
||||||
|
|
||||||
The payment is stored in supplier_payments (created on first use) and the
|
|
||||||
purchase order is marked 'paid' once the accumulated payments cover its
|
|
||||||
total. This is what feeds the 'paid' column of the accounts-payable aging.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: psycopg2 connection
|
|
||||||
payment: dict with keys:
|
|
||||||
po_id: purchase order id (required)
|
|
||||||
amount: float (required, in the PO currency)
|
|
||||||
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
|
|
||||||
reference: str (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {entry_id, payment_id, po_id, status, paid, balance}
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
|
||||||
po_id = payment.get('po_id')
|
|
||||||
amount = _to_dec(payment.get('amount', 0))
|
|
||||||
if not po_id:
|
|
||||||
raise ValueError("po_id is required")
|
|
||||||
if amount <= 0:
|
|
||||||
raise ValueError("amount must be greater than 0")
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT supplier_id, total, status FROM purchase_orders WHERE id = %s FOR UPDATE
|
|
||||||
""", (po_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row:
|
|
||||||
raise ValueError(f"Purchase order {po_id} not found")
|
|
||||||
supplier_id, po_total, po_status = row
|
|
||||||
if po_status == 'cancelled':
|
|
||||||
raise ValueError("Cannot pay a cancelled purchase order")
|
|
||||||
|
|
||||||
ensure_supplier_payments_table(conn)
|
|
||||||
cur.execute("SELECT COALESCE(SUM(amount), 0) FROM supplier_payments WHERE po_id = %s", (po_id,))
|
|
||||||
already_paid = _to_dec(cur.fetchone()[0])
|
|
||||||
|
|
||||||
po_total = _to_dec(po_total)
|
|
||||||
paid_total = already_paid + amount
|
|
||||||
if paid_total > po_total:
|
|
||||||
raise ValueError(
|
|
||||||
f"Payment ${amount} exceeds balance ${(po_total - already_paid).quantize(TWO)} "
|
|
||||||
f"of purchase order {po_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
entry_date = date.today()
|
|
||||||
_check_period_open(cur, entry_date)
|
|
||||||
|
|
||||||
try:
|
|
||||||
created_by = getattr(g, 'employee_id', None)
|
|
||||||
except RuntimeError:
|
|
||||||
created_by = None
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO supplier_payments
|
|
||||||
(po_id, supplier_id, amount, payment_method, reference, employee_id)
|
|
||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
|
||||||
RETURNING id
|
|
||||||
""", (
|
|
||||||
po_id, supplier_id, float(amount),
|
|
||||||
payment.get('payment_method', 'efectivo'),
|
|
||||||
payment.get('reference'), created_by
|
|
||||||
))
|
|
||||||
payment_id = cur.fetchone()[0]
|
|
||||||
|
|
||||||
accounts = _get_account_ids(cur, ['110', '111', '210'])
|
|
||||||
method = payment.get('payment_method', 'efectivo')
|
|
||||||
if method in ('transferencia', 'tarjeta'):
|
|
||||||
credit_account = accounts['111']
|
|
||||||
credit_desc = f'Bancos - Pago a proveedor OC #{po_id}'
|
|
||||||
else:
|
|
||||||
credit_account = accounts['110']
|
|
||||||
credit_desc = f'Caja - Pago a proveedor OC #{po_id}'
|
|
||||||
|
|
||||||
entry_number = get_next_entry_number(conn)
|
|
||||||
entry_id = _create_entry(
|
|
||||||
cur, entry_number, entry_date, 'egreso',
|
|
||||||
f'Pago a proveedor - OC #{po_id}',
|
|
||||||
'purchase', po_id,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'account_id': accounts['210'],
|
|
||||||
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'credit': 0,
|
|
||||||
'description': f'Proveedores - Pago OC #{po_id}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'account_id': credit_account,
|
|
||||||
'debit': 0,
|
|
||||||
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'description': credit_desc,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
new_status = 'paid' if paid_total >= po_total else po_status
|
|
||||||
if new_status != po_status:
|
|
||||||
cur.execute("UPDATE purchase_orders SET status = %s WHERE id = %s", (new_status, po_id))
|
|
||||||
|
|
||||||
cur.close()
|
|
||||||
return {
|
|
||||||
'entry_id': entry_id,
|
|
||||||
'payment_id': payment_id,
|
|
||||||
'po_id': po_id,
|
|
||||||
'status': new_status,
|
|
||||||
'paid': float(paid_total.quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
'balance': float((po_total - paid_total).quantize(TWO, ROUND_HALF_UP)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def create_manual_entry(conn, entry_data):
|
def create_manual_entry(conn, entry_data):
|
||||||
"""Create a manual journal entry (type='diario').
|
"""Create a manual journal entry (type='diario').
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,8 @@
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
import json
|
import json
|
||||||
import os as _os
|
from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY
|
||||||
try: # config del POS; con sys.path del dashboard puede resolver el config raíz
|
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
|
||||||
from config import OPENROUTER_API_KEY, HERMES_API_URL, HERMES_API_KEY
|
|
||||||
from config import QWEN_API_URL, QWEN_API_KEY, QWEN_MODEL
|
|
||||||
except ImportError:
|
|
||||||
OPENROUTER_API_KEY = _os.environ.get('OPENROUTER_API_KEY', '')
|
|
||||||
HERMES_API_URL = _os.environ.get('HERMES_API_URL', '')
|
|
||||||
HERMES_API_KEY = _os.environ.get('HERMES_API_KEY', '')
|
|
||||||
QWEN_API_URL = _os.environ.get('QWEN_API_URL', '')
|
|
||||||
QWEN_API_KEY = _os.environ.get('QWEN_API_KEY', '')
|
|
||||||
QWEN_MODEL = _os.environ.get('QWEN_MODEL', 'qwen3.6')
|
|
||||||
|
|
||||||
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
||||||
HERMES_ENABLED = bool(HERMES_API_KEY and HERMES_API_URL)
|
HERMES_ENABLED = bool(HERMES_API_KEY and HERMES_API_URL)
|
||||||
@@ -403,7 +394,7 @@ def classify_part(part_number):
|
|||||||
|
|
||||||
for url, key, model_id in backends:
|
for url, key, model_id in backends:
|
||||||
_validate_model(model_id)
|
_validate_model(model_id)
|
||||||
result = _post_chat_completion(url, key, model_id, messages, max_tokens=800, temperature=0.2, timeout=70)
|
result = _post_chat_completion(url, key, model_id, messages, max_tokens=300, temperature=0.2, timeout=15)
|
||||||
if result is None:
|
if result is None:
|
||||||
continue
|
continue
|
||||||
content = result["content"]
|
content = result["content"]
|
||||||
@@ -552,7 +543,7 @@ def chat_cache_clear():
|
|||||||
_RESPONSE_CACHE.clear()
|
_RESPONSE_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
def chat(user_message, conversation_history=None, inventory_context=None, persona=None):
|
def chat(user_message, conversation_history=None, inventory_context=None):
|
||||||
"""Send a message to the AI and get a response with search suggestions.
|
"""Send a message to the AI and get a response with search suggestions.
|
||||||
|
|
||||||
Caches responses for repeated identical questions (subject to bypass
|
Caches responses for repeated identical questions (subject to bypass
|
||||||
@@ -562,7 +553,6 @@ def chat(user_message, conversation_history=None, inventory_context=None, person
|
|||||||
user_message: The user's chat message.
|
user_message: The user's chat message.
|
||||||
conversation_history: Previous messages in the conversation.
|
conversation_history: Previous messages in the conversation.
|
||||||
inventory_context: Optional inventory summary string to inject into the system prompt.
|
inventory_context: Optional inventory summary string to inject into the system prompt.
|
||||||
persona: Optional persona intro (first line of system prompt) for tenant branding.
|
|
||||||
"""
|
"""
|
||||||
# Cache lookup — only when there's no conversation history (stateless)
|
# Cache lookup — only when there's no conversation history (stateless)
|
||||||
cache_key = None
|
cache_key = None
|
||||||
@@ -574,9 +564,6 @@ def chat(user_message, conversation_history=None, inventory_context=None, person
|
|||||||
return cached
|
return cached
|
||||||
|
|
||||||
system_content = SYSTEM_PROMPT
|
system_content = SYSTEM_PROMPT
|
||||||
if persona:
|
|
||||||
# Replace the hardcoded first line (persona de marca por tenant)
|
|
||||||
system_content = persona + "\n" + system_content.split("\n", 1)[1]
|
|
||||||
if inventory_context:
|
if inventory_context:
|
||||||
system_content = SYSTEM_PROMPT + "\n\n" + inventory_context
|
system_content = SYSTEM_PROMPT + "\n\n" + inventory_context
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from tenant_db import get_master_conn
|
||||||
|
|
||||||
PLANS = {
|
PLANS = {
|
||||||
# `demo` is a time-limited full trial (see scripts/expire_demos.py). It has
|
|
||||||
# no resource limits on purpose: a prospect must be able to import their
|
|
||||||
# real inventory without hitting a wall. Access is gated by the
|
|
||||||
# subscriptions.expires_at date, not by data volume.
|
|
||||||
'demo': {'name': 'Demo', 'price_mxn': 0, 'max_products': None, 'max_employees': None, 'max_branches': None},
|
|
||||||
# Yonke: plan comercial para negocios de SOLO partes usadas (sin catálogo TecDoc)
|
|
||||||
'yonke': {'name': 'Yonke', 'price_mxn': 299, 'max_products': None, 'max_employees': 10, 'max_branches': 1},
|
|
||||||
'free': {'name': 'Gratis', 'price_mxn': 0, 'max_products': 100, 'max_employees': 2, 'max_branches': 1},
|
'free': {'name': 'Gratis', 'price_mxn': 0, 'max_products': 100, 'max_employees': 2, 'max_branches': 1},
|
||||||
'basic': {'name': 'Basico', 'price_mxn': 499, 'max_products': 5000, 'max_employees': 5, 'max_branches': 2},
|
'basic': {'name': 'Basico', 'price_mxn': 499, 'max_products': 5000, 'max_employees': 5, 'max_branches': 2},
|
||||||
'standard': {'name': 'Estandar', 'price_mxn': 999, 'max_products': 20000, 'max_employees': 10, 'max_branches': 3},
|
|
||||||
'pro': {'name': 'Pro', 'price_mxn': 1499, 'max_products': 50000, 'max_employees': 15, 'max_branches': 5},
|
'pro': {'name': 'Pro', 'price_mxn': 1499, 'max_products': 50000, 'max_employees': 15, 'max_branches': 5},
|
||||||
'enterprise': {'name': 'Enterprise', 'price_mxn': 3999, 'max_products': None, 'max_employees': None, 'max_branches': None},
|
'enterprise': {'name': 'Enterprise', 'price_mxn': 3999, 'max_products': None, 'max_employees': None, 'max_branches': None},
|
||||||
}
|
}
|
||||||
|
|
||||||
PLAN_ORDER = ['free', 'demo', 'basic', 'standard', 'pro', 'enterprise']
|
PLAN_ORDER = ['free', 'basic', 'pro', 'enterprise']
|
||||||
|
|
||||||
|
|
||||||
def get_plan(tenant_id):
|
def get_plan(tenant_id):
|
||||||
|
|||||||
@@ -737,10 +737,9 @@ def get_nexpart_groups_for_vehicle(master_conn, mye_id, tenant_conn=None):
|
|||||||
|
|
||||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||||
# Merge local inventory and supplier catalog classifications
|
# Merge local inventory and supplier catalog classifications
|
||||||
# supplier_catalog vive en la maestra: siempre se puede clasificar.
|
|
||||||
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
|
||||||
if tenant_conn:
|
if tenant_conn:
|
||||||
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
||||||
|
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
# Iterate in canonical Nexpart order so the UI is stable
|
# Iterate in canonical Nexpart order so the UI is stable
|
||||||
@@ -770,10 +769,9 @@ def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug, tenant_co
|
|||||||
)
|
)
|
||||||
|
|
||||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||||
# supplier_catalog vive en la maestra: siempre se puede clasificar.
|
|
||||||
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
|
||||||
if tenant_conn:
|
if tenant_conn:
|
||||||
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
||||||
|
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
||||||
group_data = classified.get(group_slug, {})
|
group_data = classified.get(group_slug, {})
|
||||||
if not group_data:
|
if not group_data:
|
||||||
return []
|
return []
|
||||||
@@ -1174,10 +1172,9 @@ def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
|
|||||||
"""
|
"""
|
||||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||||
# Also merge inventory and supplier catalog so the leaf IDs may be a mix
|
# Also merge inventory and supplier catalog so the leaf IDs may be a mix
|
||||||
# supplier_catalog vive en la maestra: siempre se puede clasificar.
|
|
||||||
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
|
||||||
if tenant_conn:
|
if tenant_conn:
|
||||||
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
||||||
|
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
||||||
|
|
||||||
all_ids = (
|
all_ids = (
|
||||||
classified
|
classified
|
||||||
@@ -1330,10 +1327,9 @@ def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup
|
|||||||
)
|
)
|
||||||
|
|
||||||
classified = _classify_vehicle_parts(master_conn, mye_id)
|
classified = _classify_vehicle_parts(master_conn, mye_id)
|
||||||
# supplier_catalog vive en la maestra: siempre se puede clasificar.
|
|
||||||
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
|
||||||
if tenant_conn:
|
if tenant_conn:
|
||||||
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
_merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id))
|
||||||
|
_merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id))
|
||||||
subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {})
|
subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {})
|
||||||
if not subgroup_data:
|
if not subgroup_data:
|
||||||
return []
|
return []
|
||||||
@@ -1856,169 +1852,49 @@ def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _alternatives_from_supplier_catalog(conn, catalog_id=None, part_number=None,
|
def _get_alternatives(cur, part_id):
|
||||||
name=None, category=None, limit=20,
|
"""Get cross-references + aftermarket parts for a given OEM part."""
|
||||||
use_semantic=True,
|
|
||||||
semantic_threshold=0.72,
|
|
||||||
semantic_pool=80,
|
|
||||||
semantic_timeout=20.0):
|
|
||||||
"""Alternatives for a supplier-catalog item, on live (non-TecDoc) tables.
|
|
||||||
|
|
||||||
Two sources, in order:
|
|
||||||
|
|
||||||
1. exact — other ``supplier_catalog`` items that share an interchange
|
|
||||||
cross-reference or an SKU with the base item
|
|
||||||
(``supplier_catalog_interchange``).
|
|
||||||
2. semantic — ``semantic_search.find_similar`` over same-category catalog
|
|
||||||
names (substitute parts that are worded differently). Degrades to
|
|
||||||
nothing when the embedding provider is unavailable.
|
|
||||||
|
|
||||||
Works with either a connection or a cursor (``conn``/``cur``); the caller
|
|
||||||
owns it. Never raises on the semantic step.
|
|
||||||
|
|
||||||
Returns list[dict]: {part_number, manufacturer, name, type, similarity,
|
|
||||||
catalog_id} — the caller adds stock/bodega fields.
|
|
||||||
"""
|
|
||||||
cur = conn.cursor() if hasattr(conn, 'cursor') else conn
|
|
||||||
results = []
|
results = []
|
||||||
seen = set()
|
|
||||||
|
|
||||||
base = None
|
# Cross-references (other OEM numbers that reference this part)
|
||||||
if catalog_id:
|
cur.execute("""
|
||||||
cur.execute(
|
SELECT pcr.cross_reference_number, pcr.source_ref
|
||||||
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
|
FROM part_cross_references pcr
|
||||||
"WHERE id = %s AND is_active = true", (catalog_id,)
|
WHERE pcr.part_id = %s
|
||||||
)
|
LIMIT 50
|
||||||
base = cur.fetchone()
|
""", (part_id,))
|
||||||
elif part_number:
|
for r in cur.fetchall():
|
||||||
cur.execute(
|
results.append({
|
||||||
"SELECT id, supplier_name, sku, name, category FROM supplier_catalog "
|
'part_number': r[0],
|
||||||
"WHERE UPPER(sku) = UPPER(%s) AND is_active = true LIMIT 1",
|
'manufacturer': r[1] or 'OEM Cross-Ref',
|
||||||
(part_number,)
|
'name': None,
|
||||||
)
|
'type': 'cross_reference',
|
||||||
base = cur.fetchone()
|
'local_stock': 0,
|
||||||
|
'bodega_count': 0,
|
||||||
|
})
|
||||||
|
|
||||||
if base:
|
# Aftermarket alternatives
|
||||||
base_id, base_sup, base_sku, base_name, base_cat = base
|
cur.execute("""
|
||||||
else:
|
SELECT ap.part_number, m.name_manufacture,
|
||||||
base_id, base_sup, base_sku = None, None, None
|
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS name
|
||||||
base_name = name
|
FROM aftermarket_parts ap
|
||||||
base_cat = category
|
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
|
||||||
|
WHERE ap.oem_part_id = %s
|
||||||
if base_id:
|
LIMIT 50
|
||||||
seen.add(base_id)
|
""", (part_id,))
|
||||||
|
for r in cur.fetchall():
|
||||||
# ── 1. Exact interchange / SKU cross-references ────────────────────────
|
results.append({
|
||||||
if base_id:
|
'part_number': r[0],
|
||||||
cur.execute(
|
'manufacturer': r[1],
|
||||||
"SELECT DISTINCT part_number FROM supplier_catalog_interchange "
|
'name': r[2],
|
||||||
"WHERE catalog_id = %s AND part_number IS NOT NULL",
|
'type': 'aftermarket',
|
||||||
(base_id,)
|
'local_stock': 0,
|
||||||
)
|
'bodega_count': 0,
|
||||||
xrefs = [r[0] for r in cur.fetchall()]
|
})
|
||||||
keys_upper = set()
|
|
||||||
if base_sku:
|
|
||||||
keys_upper.add(str(base_sku).upper())
|
|
||||||
for x in xrefs:
|
|
||||||
keys_upper.add(str(x).upper())
|
|
||||||
keys_upper.discard(None)
|
|
||||||
|
|
||||||
if keys_upper:
|
|
||||||
key_list = list(keys_upper)
|
|
||||||
# (a) catalog items that *are* one of our keys
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, supplier_name, sku, name FROM supplier_catalog
|
|
||||||
WHERE UPPER(sku) = ANY(%s) AND is_active = true
|
|
||||||
LIMIT %s
|
|
||||||
""", (key_list, limit * 2))
|
|
||||||
exact_rows = list(cur.fetchall())
|
|
||||||
# (b) catalog items whose interchange list contains one of our keys
|
|
||||||
cur.execute("""
|
|
||||||
SELECT DISTINCT sc.id, sc.supplier_name, sc.sku, sc.name
|
|
||||||
FROM supplier_catalog sc
|
|
||||||
JOIN supplier_catalog_interchange sci ON sci.catalog_id = sc.id
|
|
||||||
WHERE UPPER(sci.part_number) = ANY(%s) AND sc.is_active = true
|
|
||||||
LIMIT %s
|
|
||||||
""", (key_list, limit * 2))
|
|
||||||
exact_rows.extend(cur.fetchall())
|
|
||||||
|
|
||||||
for cid, sup, sku, cname in exact_rows:
|
|
||||||
if not cid or cid in seen:
|
|
||||||
continue
|
|
||||||
seen.add(cid)
|
|
||||||
results.append({
|
|
||||||
'part_number': sku,
|
|
||||||
'manufacturer': sup,
|
|
||||||
'name': cname,
|
|
||||||
'type': 'cross_reference',
|
|
||||||
'similarity': None,
|
|
||||||
'catalog_id': cid,
|
|
||||||
'local_stock': 0,
|
|
||||||
'bodega_count': 0,
|
|
||||||
})
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
# ── 2. Semantic substitutes (same category, different wording) ─────────
|
|
||||||
if use_semantic and base_name and len(results) < limit:
|
|
||||||
pool_cat = base_cat
|
|
||||||
try:
|
|
||||||
if pool_cat:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, supplier_name, sku, name FROM supplier_catalog
|
|
||||||
WHERE is_active = true AND category = %s AND id <> COALESCE(%s, -1)
|
|
||||||
LIMIT %s
|
|
||||||
""", (pool_cat, base_id, semantic_pool))
|
|
||||||
else:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id, supplier_name, sku, name FROM supplier_catalog
|
|
||||||
WHERE is_active = true AND id <> COALESCE(%s, -1)
|
|
||||||
LIMIT %s
|
|
||||||
""", (base_id, semantic_pool))
|
|
||||||
pool = [r for r in cur.fetchall() if r[0] not in seen]
|
|
||||||
except Exception:
|
|
||||||
pool = []
|
|
||||||
|
|
||||||
if pool:
|
|
||||||
try:
|
|
||||||
from services import semantic_search as ss
|
|
||||||
ranked = ss.find_similar(base_name, [r[3] for r in pool],
|
|
||||||
timeout=semantic_timeout)
|
|
||||||
except Exception:
|
|
||||||
ranked = None
|
|
||||||
if ranked:
|
|
||||||
for item in ranked:
|
|
||||||
if item['similarity'] < semantic_threshold:
|
|
||||||
continue
|
|
||||||
cid, sup, sku, cname = pool[item['index']]
|
|
||||||
if cid in seen:
|
|
||||||
continue
|
|
||||||
seen.add(cid)
|
|
||||||
results.append({
|
|
||||||
'part_number': sku,
|
|
||||||
'manufacturer': sup,
|
|
||||||
'name': cname,
|
|
||||||
'type': 'semantic',
|
|
||||||
'similarity': round(item['similarity'], 3),
|
|
||||||
'catalog_id': cid,
|
|
||||||
'local_stock': 0,
|
|
||||||
'bodega_count': 0,
|
|
||||||
})
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _get_alternatives(cur, part_id):
|
|
||||||
"""Back-compat shim: alternatives for a supplier-catalog id.
|
|
||||||
|
|
||||||
The retired TecDoc tables (``part_cross_references`` / ``aftermarket_parts``)
|
|
||||||
are gone; this now delegates to ``_alternatives_from_supplier_catalog``.
|
|
||||||
"""
|
|
||||||
return _alternatives_from_supplier_catalog(cur, catalog_id=part_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# SMART SEARCH
|
# SMART SEARCH
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -2063,46 +1939,7 @@ def _search_meili_fallback(master_conn, q, limit):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _rerank_document(item):
|
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None, tenant_id=None):
|
||||||
"""Texto con el que el reranker semántico evalúa un resultado."""
|
|
||||||
parts = [
|
|
||||||
item.get('name'),
|
|
||||||
item.get('oem_part_number'),
|
|
||||||
item.get('brand') or item.get('vehicle_info'),
|
|
||||||
]
|
|
||||||
return ' '.join(str(p) for p in parts if p)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_rerank(query, results, scope='pos'):
|
|
||||||
"""Reordena ``results`` por relevancia semántica (QWEN /rerank).
|
|
||||||
|
|
||||||
Solo cambia el ORDEN: la paginación, el tope de ``limit`` y el filtrado por
|
|
||||||
vehículo/sucursal ya ocurrieron antes y quedan intactos.
|
|
||||||
|
|
||||||
Degradación obligatoria (``semantic_search.rerank`` nunca lanza): si el
|
|
||||||
rerank está apagado, sin configurar, tarda más que el timeout, falla o
|
|
||||||
devuelve un orden inconsistente, se conserva el orden original.
|
|
||||||
"""
|
|
||||||
if not results or not scope:
|
|
||||||
return results
|
|
||||||
try:
|
|
||||||
from services import semantic_search as ss
|
|
||||||
if not ss.is_rerank_enabled(scope):
|
|
||||||
return results
|
|
||||||
head = results[:ss.max_candidates()]
|
|
||||||
order = ss.rerank_order(query, [_rerank_document(r) for r in head])
|
|
||||||
reordered = [head[i] for i in order if 0 <= i < len(head)]
|
|
||||||
if len(reordered) != len(head):
|
|
||||||
# Salvaguarda: un orden incompleto perdería items → orden original.
|
|
||||||
return results
|
|
||||||
return reordered + results[len(head):]
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[catalog_service] rerank omitido: {e}")
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
|
||||||
tenant_id=None, rerank_scope='pos'):
|
|
||||||
"""Search parts by part number or text. Enriches with local stock.
|
"""Search parts by part number or text. Enriches with local stock.
|
||||||
|
|
||||||
Strategy:
|
Strategy:
|
||||||
@@ -2110,9 +1947,6 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
|||||||
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
|
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
|
||||||
3. Search local inventory items by part_number or name
|
3. Search local inventory items by part_number or name
|
||||||
4. Always enriches results with local stock from tenant DB
|
4. Always enriches results with local stock from tenant DB
|
||||||
5. Reordena los candidatos con rerank semántico según ``rerank_scope``
|
|
||||||
('pos' por defecto = flag SEARCH_RERANK_POS_ENABLED, apagado; el
|
|
||||||
catálogo público/dashboard pasa 'catalog' → SEARCH_RERANK_ENABLED)
|
|
||||||
"""
|
"""
|
||||||
q = q.strip()
|
q = q.strip()
|
||||||
if not q or len(q) < 2:
|
if not q or len(q) < 2:
|
||||||
@@ -2140,7 +1974,6 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
|||||||
'image_url': li['image_url'],
|
'image_url': li['image_url'],
|
||||||
'local_stock': li['stock'],
|
'local_stock': li['stock'],
|
||||||
'local_price': li['price_1'],
|
'local_price': li['price_1'],
|
||||||
'brand': li.get('brand'),
|
|
||||||
'vehicle_info': '',
|
'vehicle_info': '',
|
||||||
'source': 'local_inventory',
|
'source': 'local_inventory',
|
||||||
})
|
})
|
||||||
@@ -2150,10 +1983,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
|||||||
|
|
||||||
# ── Inject supplier catalog items ───────────────────────────────────────
|
# ── Inject supplier catalog items ───────────────────────────────────────
|
||||||
if master_conn:
|
if master_conn:
|
||||||
# Meilisearch first (typo tolerance, sub-100ms); SQL fallback below.
|
supplier_items = _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=tenant_id)
|
||||||
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:
|
for si in supplier_items:
|
||||||
if f"sc:{si['id']}" in seen_local_ids:
|
if f"sc:{si['id']}" in seen_local_ids:
|
||||||
continue
|
continue
|
||||||
@@ -2173,7 +2003,7 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None,
|
|||||||
if len(results) >= limit:
|
if len(results) >= limit:
|
||||||
break
|
break
|
||||||
|
|
||||||
return _apply_rerank(q, results, rerank_scope)
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
|
def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
|
||||||
@@ -2197,33 +2027,6 @@ def _get_supplier_prices(master_conn, tenant_id, catalog_ids):
|
|||||||
return prices
|
return prices
|
||||||
|
|
||||||
|
|
||||||
def _search_meili_supplier_catalog(q, limit):
|
|
||||||
"""Typo-tolerant supplier catalog search via Meilisearch.
|
|
||||||
|
|
||||||
Returns [] on any failure so the SQL fallback always applies.
|
|
||||||
Result shape matches _search_supplier_catalog.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import meilisearch
|
|
||||||
from services.meili_search import MEILI_URL, MEILI_API_KEY
|
|
||||||
client = meilisearch.Client(MEILI_URL, MEILI_API_KEY)
|
|
||||||
res = client.index('nexus_supplier_catalog').search(q, {'limit': limit})
|
|
||||||
out = []
|
|
||||||
for h in res.get('hits', []):
|
|
||||||
out.append({
|
|
||||||
'id': h['id'],
|
|
||||||
'sku': h.get('sku'),
|
|
||||||
'name': h.get('name'),
|
|
||||||
'image_url': h.get('image_url'),
|
|
||||||
'category': h.get('category'),
|
|
||||||
'supplier_price': h.get('supplier_price'),
|
|
||||||
'supplier_currency': None,
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
|
def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
|
||||||
"""Search supplier catalog items by SKU or name.
|
"""Search supplier catalog items by SKU or name.
|
||||||
|
|
||||||
@@ -2289,181 +2092,13 @@ def _search_supplier_catalog(master_conn, q, mye_id, limit, tenant_id=None):
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _tenant_db_name(tenant_conn):
|
|
||||||
"""db_name de la BD del tenant a partir de su conexión (o None)."""
|
|
||||||
try:
|
|
||||||
return tenant_conn.info.dbname
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _meili_inventory_ids(db_name, q, limit):
|
|
||||||
"""IDs de inventario que Meilisearch considera relevantes.
|
|
||||||
|
|
||||||
None ⇒ Meilisearch no disponible (usar SQL); [] ⇒ cero resultados.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from services.meili_search import search_inventory
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
hits = search_inventory(db_name, q, limit=limit)
|
|
||||||
if hits is None:
|
|
||||||
return None
|
|
||||||
return [h['id'] for h in hits if h.get('id') is not None]
|
|
||||||
|
|
||||||
|
|
||||||
def _hydrate_inventory_rows(tenant_conn, ids, mye_id, branch_id):
|
|
||||||
"""Trae stock/precio frescos de Postgres para los IDs que dio Meilisearch.
|
|
||||||
|
|
||||||
Respeta el filtro de vehículo/sucursal y conserva el orden de relevancia
|
|
||||||
de Meilisearch.
|
|
||||||
"""
|
|
||||||
cur = tenant_conn.cursor()
|
|
||||||
try:
|
|
||||||
where_vehicle = ""
|
|
||||||
params = []
|
|
||||||
if mye_id:
|
|
||||||
where_vehicle = """ AND EXISTS (
|
|
||||||
SELECT 1 FROM inventory_vehicle_compat ivc
|
|
||||||
WHERE ivc.inventory_id = i.id AND ivc.model_year_engine_id = %s)"""
|
|
||||||
if branch_id:
|
|
||||||
if mye_id:
|
|
||||||
params = [branch_id, list(ids), mye_id]
|
|
||||||
else:
|
|
||||||
params = [branch_id, list(ids)]
|
|
||||||
cur.execute("""
|
|
||||||
SELECT i.id, i.part_number, i.name, i.image_url, i.price_1,
|
|
||||||
i.brand, COALESCE(s.stock, 0) AS stock
|
|
||||||
FROM inventory i
|
|
||||||
LEFT JOIN inventory_stock_summary s
|
|
||||||
ON s.inventory_id = i.id AND s.branch_id = %s
|
|
||||||
WHERE i.is_active = true AND i.id = ANY(%s)""" + where_vehicle, params)
|
|
||||||
else:
|
|
||||||
params = [list(ids), mye_id] if mye_id else [list(ids)]
|
|
||||||
cur.execute("""
|
|
||||||
SELECT i.id, i.part_number, i.name, i.image_url, i.price_1,
|
|
||||||
i.brand, COALESCE(SUM(s.stock), 0) AS stock
|
|
||||||
FROM inventory i
|
|
||||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
|
||||||
WHERE i.is_active = true AND i.id = ANY(%s)""" + where_vehicle + """
|
|
||||||
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand""", params)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
by_id = {r[0]: r for r in rows}
|
|
||||||
ordered = [by_id[i] for i in ids if i in by_id]
|
|
||||||
return [{
|
|
||||||
'inventory_id': r[0],
|
|
||||||
'part_number': r[1],
|
|
||||||
'name': r[2],
|
|
||||||
'image_url': r[3],
|
|
||||||
'price_1': float(r[4]) if r[4] is not None else None,
|
|
||||||
'brand': r[5],
|
|
||||||
'stock': int(r[6]) if r[6] is not None else 0,
|
|
||||||
} for r in ordered]
|
|
||||||
|
|
||||||
|
|
||||||
def _search_inventory_by_alias(tenant_conn, q, mye_id, branch_id, limit):
|
|
||||||
"""Inventory items whose SKU alias matches the query (A6 cross-refs).
|
|
||||||
|
|
||||||
``inventory_sku_aliases`` holds 2-3 alternative supplier SKUs per item;
|
|
||||||
this lets a search by an alternate number find the product even when the
|
|
||||||
main part_number does not match.
|
|
||||||
"""
|
|
||||||
clean_q = q.replace(' ', '').upper()
|
|
||||||
params = []
|
|
||||||
if branch_id:
|
|
||||||
stock_join = ("LEFT JOIN inventory_stock_summary s "
|
|
||||||
"ON s.inventory_id = i.id AND s.branch_id = %s")
|
|
||||||
params.append(branch_id)
|
|
||||||
else:
|
|
||||||
stock_join = "LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id"
|
|
||||||
params.append(f'%{clean_q}%')
|
|
||||||
mye_clause = ""
|
|
||||||
if mye_id:
|
|
||||||
mye_clause = """ AND EXISTS (
|
|
||||||
SELECT 1 FROM inventory_vehicle_compat ivc
|
|
||||||
WHERE ivc.inventory_id = i.id AND ivc.model_year_engine_id = %s)"""
|
|
||||||
params.append(mye_id)
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
cur = tenant_conn.cursor()
|
|
||||||
try:
|
|
||||||
cur.execute(f"""
|
|
||||||
SELECT DISTINCT i.id, i.part_number, i.name, i.image_url,
|
|
||||||
i.price_1, i.brand, COALESCE(s.stock, 0)
|
|
||||||
FROM inventory i
|
|
||||||
JOIN inventory_sku_aliases a
|
|
||||||
ON a.inventory_id = i.id AND a.is_active = true
|
|
||||||
{stock_join}
|
|
||||||
WHERE i.is_active = true
|
|
||||||
AND REPLACE(UPPER(a.sku), ' ', '') LIKE %s
|
|
||||||
{mye_clause}
|
|
||||||
ORDER BY i.name
|
|
||||||
LIMIT %s
|
|
||||||
""", params)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
return [{
|
|
||||||
'inventory_id': r[0],
|
|
||||||
'part_number': r[1],
|
|
||||||
'name': r[2],
|
|
||||||
'image_url': r[3],
|
|
||||||
'price_1': float(r[4]) if r[4] is not None else None,
|
|
||||||
'brand': r[5],
|
|
||||||
'stock': int(r[6]) if r[6] is not None else 0,
|
|
||||||
} for r in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def _augment_with_aliases(tenant_conn, rows, q, mye_id, branch_id, limit):
|
|
||||||
"""Append alias-borne matches to a result list, de-duplicated by id."""
|
|
||||||
try:
|
|
||||||
alias_rows = _search_inventory_by_alias(tenant_conn, q, mye_id, branch_id, limit)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
tenant_conn.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return rows
|
|
||||||
if not alias_rows:
|
|
||||||
return rows
|
|
||||||
seen = {r['inventory_id'] for r in rows}
|
|
||||||
merged = list(rows)
|
|
||||||
for r in alias_rows:
|
|
||||||
if r['inventory_id'] not in seen:
|
|
||||||
merged.append(r)
|
|
||||||
seen.add(r['inventory_id'])
|
|
||||||
return merged[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
||||||
"""Search tenant inventory items by part_number or name.
|
"""Search tenant inventory items by part_number or name.
|
||||||
|
|
||||||
If mye_id is provided, only returns items compatible with that vehicle.
|
If mye_id is provided, only returns items compatible with that vehicle.
|
||||||
|
|
||||||
Primero consulta Meilisearch (índice `nexus_inventory_<db>` del tenant) e
|
|
||||||
hidrata desde Postgres para tener stock/precio frescos. Si Meilisearch
|
|
||||||
está apagado, falla o no devuelve nada, cae al SQL de abajo (ILIKE).
|
|
||||||
En ambos caminos se añaden los items cuyo SKU alias (cross-ref) coincide.
|
|
||||||
"""
|
"""
|
||||||
if tenant_conn is None:
|
if tenant_conn is None:
|
||||||
return []
|
return []
|
||||||
db_name = _tenant_db_name(tenant_conn)
|
|
||||||
if db_name:
|
|
||||||
ids = _meili_inventory_ids(db_name, q, limit)
|
|
||||||
if ids:
|
|
||||||
try:
|
|
||||||
rows = _hydrate_inventory_rows(tenant_conn, ids, mye_id, branch_id)
|
|
||||||
except Exception:
|
|
||||||
try:
|
|
||||||
tenant_conn.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
rows = []
|
|
||||||
if rows:
|
|
||||||
return _augment_with_aliases(tenant_conn, rows, q, mye_id, branch_id, limit)
|
|
||||||
cur = tenant_conn.cursor()
|
cur = tenant_conn.cursor()
|
||||||
clean_q = q.replace(' ', '').upper()
|
clean_q = q.replace(' ', '').upper()
|
||||||
|
|
||||||
@@ -2487,7 +2122,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
|||||||
if branch_id:
|
if branch_id:
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||||
i.price_1, i.brand, COALESCE(s.stock, 0) as stock
|
i.price_1, COALESCE(s.stock, 0) as stock
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
|
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
|
||||||
LEFT JOIN inventory_stock_summary s
|
LEFT JOIN inventory_stock_summary s
|
||||||
@@ -2502,7 +2137,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
|||||||
else:
|
else:
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||||
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock
|
i.price_1, COALESCE(SUM(s.stock), 0) as stock
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
|
JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id
|
||||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||||
@@ -2510,7 +2145,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
|||||||
AND i.is_active = true
|
AND i.is_active = true
|
||||||
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
|
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
|
||||||
OR {_SQL_UNACCENT} LIKE %s)
|
OR {_SQL_UNACCENT} LIKE %s)
|
||||||
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand
|
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
|
||||||
ORDER BY i.name
|
ORDER BY i.name
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""", (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
|
""", (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit))
|
||||||
@@ -2519,7 +2154,7 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
|||||||
if branch_id:
|
if branch_id:
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||||
i.price_1, i.brand, COALESCE(s.stock, 0) as stock
|
i.price_1, COALESCE(s.stock, 0) as stock
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN inventory_stock_summary s
|
LEFT JOIN inventory_stock_summary s
|
||||||
ON s.inventory_id = i.id AND s.branch_id = %s
|
ON s.inventory_id = i.id AND s.branch_id = %s
|
||||||
@@ -2532,32 +2167,30 @@ def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit):
|
|||||||
else:
|
else:
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT i.id, i.part_number, i.name, i.image_url,
|
SELECT i.id, i.part_number, i.name, i.image_url,
|
||||||
i.price_1, i.brand, COALESCE(SUM(s.stock), 0) as stock
|
i.price_1, COALESCE(SUM(s.stock), 0) as stock
|
||||||
FROM inventory i
|
FROM inventory i
|
||||||
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
|
||||||
WHERE i.is_active = true
|
WHERE i.is_active = true
|
||||||
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
|
AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s
|
||||||
OR {_SQL_UNACCENT} LIKE %s)
|
OR {_SQL_UNACCENT} LIKE %s)
|
||||||
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1, i.brand
|
GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1
|
||||||
ORDER BY i.name
|
ORDER BY i.name
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""", (f'%{clean_q}%', f'%{_q_unaccent}%', limit))
|
""", (f'%{clean_q}%', f'%{_q_unaccent}%', limit))
|
||||||
|
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
cur.close()
|
cur.close()
|
||||||
results = [
|
return [
|
||||||
{
|
{
|
||||||
'inventory_id': r[0],
|
'inventory_id': r[0],
|
||||||
'part_number': r[1],
|
'part_number': r[1],
|
||||||
'name': r[2],
|
'name': r[2],
|
||||||
'image_url': r[3],
|
'image_url': r[3],
|
||||||
'price_1': float(r[4]) if r[4] is not None else None,
|
'price_1': float(r[4]) if r[4] is not None else None,
|
||||||
'brand': r[5],
|
'stock': int(r[5]) if r[5] is not None else 0,
|
||||||
'stock': int(r[6]) if r[6] is not None else 0,
|
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
return _augment_with_aliases(tenant_conn, results, q, mye_id, branch_id, limit)
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -2699,50 +2332,11 @@ def get_bodega_availability(master_conn, part_id):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_alternatives(master_conn, catalog_id=None, part_number=None, name=None,
|
def get_alternatives(master_conn, part_id):
|
||||||
category=None, tenant_conn=None, branch_id=None, limit=20,
|
"""Public wrapper: get cross-references + aftermarket parts for a given OEM part."""
|
||||||
use_semantic=True, semantic_threshold=0.72,
|
|
||||||
semantic_timeout=20.0):
|
|
||||||
"""Public wrapper: alternatives for a supplier-catalog item.
|
|
||||||
|
|
||||||
Rebuilt on the live tables (``supplier_catalog`` /
|
|
||||||
``supplier_catalog_interchange``) + semantic substitutes; the retired
|
|
||||||
TecDoc ``aftermarket_parts`` / ``part_cross_references`` tables are no
|
|
||||||
longer queried.
|
|
||||||
|
|
||||||
Pass ``catalog_id`` (``supplier_catalog.id``, e.g. ``inventory.catalog_part_id``)
|
|
||||||
and/or ``part_number``. When ``tenant_conn`` is given, each alternative is
|
|
||||||
enriched with local stock (``local_stock``) from that tenant.
|
|
||||||
|
|
||||||
NOTE: the dashboard endpoints that still serve the old TecDoc-based result
|
|
||||||
are ``dashboard/server.py`` ``/api/catalog/part/<id>`` (line ~1326),
|
|
||||||
``/api/parts/<id>/alternatives`` (line ~1938) and
|
|
||||||
``/api/parts/<id>/aftermarket`` (line ~4916); POS uses
|
|
||||||
``GET /catalog/part/<id>`` (``blueprints/catalog_bp.py``) via
|
|
||||||
``get_part_detail``. Those call sites are owned by other workstreams.
|
|
||||||
"""
|
|
||||||
cur = master_conn.cursor()
|
cur = master_conn.cursor()
|
||||||
try:
|
results = _get_alternatives(cur, part_id)
|
||||||
results = _alternatives_from_supplier_catalog(
|
cur.close()
|
||||||
cur, catalog_id=catalog_id, part_number=part_number, name=name,
|
|
||||||
category=category, limit=limit, use_semantic=use_semantic,
|
|
||||||
semantic_threshold=semantic_threshold,
|
|
||||||
semantic_timeout=semantic_timeout,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
cur.close()
|
|
||||||
|
|
||||||
if tenant_conn is not None and results:
|
|
||||||
alt_pns = [a['part_number'] for a in results if a.get('part_number')]
|
|
||||||
alt_cids = [a['catalog_id'] for a in results if a.get('catalog_id')]
|
|
||||||
try:
|
|
||||||
local = _get_local_stock_bulk(tenant_conn, branch_id, alt_pns, alt_cids)
|
|
||||||
except Exception:
|
|
||||||
local = {}
|
|
||||||
for a in results:
|
|
||||||
l = local.get(a.get('part_number')) or local.get(f"cat:{a.get('catalog_id')}")
|
|
||||||
a['local_stock'] = l['stock'] if l else 0
|
|
||||||
a.setdefault('bodega_count', 0)
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -63,28 +63,6 @@ def _format_rate(val):
|
|||||||
return str(_to_dec(val).quantize(SIX, ROUND_HALF_UP))
|
return str(_to_dec(val).quantize(SIX, ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
def _item_sat_keys(item):
|
|
||||||
"""Return (clave_prod_serv, clave_unidad), falling back to SAT rules.
|
|
||||||
|
|
||||||
Precedence: the value carried by the sale item, then the rule engine over
|
|
||||||
the item name/brand/category (``services.sat_keys``), then the historic
|
|
||||||
hard-coded default. Never raises, so a CFDI can always be built.
|
|
||||||
"""
|
|
||||||
cps = item.get('clave_prod_serv')
|
|
||||||
cu = item.get('clave_unidad')
|
|
||||||
if cps and cu:
|
|
||||||
return cps, cu
|
|
||||||
try:
|
|
||||||
from services import sat_keys
|
|
||||||
k, u, _src = sat_keys.resolve_key(
|
|
||||||
name=item.get('name'), brand=item.get('brand'),
|
|
||||||
category=item.get('category'), part_number=item.get('part_number'),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
k, u = None, None
|
|
||||||
return (cps or k or '25174800'), (cu or u or 'H87')
|
|
||||||
|
|
||||||
|
|
||||||
def _make_element(parent, tag, attribs=None, ns=CFDI_NS):
|
def _make_element(parent, tag, attribs=None, ns=CFDI_NS):
|
||||||
"""Create a subelement with the given namespace."""
|
"""Create a subelement with the given namespace."""
|
||||||
elem = etree.SubElement(parent, f'{{{ns}}}{tag}')
|
elem = etree.SubElement(parent, f'{{{ns}}}{tag}')
|
||||||
@@ -105,9 +83,6 @@ def build_ingreso_xml(sale, tenant_config, customer=None):
|
|||||||
items: [{part_number, name, quantity, unit_price, discount_amount,
|
items: [{part_number, name, quantity, unit_price, discount_amount,
|
||||||
tax_rate, tax_amount, subtotal,
|
tax_rate, tax_amount, subtotal,
|
||||||
clave_prod_serv, clave_unidad}]
|
clave_prod_serv, clave_unidad}]
|
||||||
(clave_prod_serv / clave_unidad are optional: when absent they are
|
|
||||||
resolved by services.sat_keys rules over name/brand/category,
|
|
||||||
falling back to 25174800 / H87)
|
|
||||||
tenant_config: dict with keys:
|
tenant_config: dict with keys:
|
||||||
rfc, razon_social, regimen_fiscal, cp (codigo postal),
|
rfc, razon_social, regimen_fiscal, cp (codigo postal),
|
||||||
serie (optional), nombre_comercial (optional)
|
serie (optional), nombre_comercial (optional)
|
||||||
@@ -200,10 +175,10 @@ def build_ingreso_xml(sale, tenant_config, customer=None):
|
|||||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
concepto = _make_element(conceptos, 'Concepto')
|
concepto = _make_element(conceptos, 'Concepto')
|
||||||
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
|
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||||
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
||||||
concepto.set('Cantidad', str(qty))
|
concepto.set('Cantidad', str(qty))
|
||||||
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
|
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||||
concepto.set('Unidad', 'PZA')
|
concepto.set('Unidad', 'PZA')
|
||||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||||
@@ -326,10 +301,10 @@ def build_egreso_xml(sale, tenant_config, customer, original_uuid):
|
|||||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
concepto = _make_element(conceptos, 'Concepto')
|
concepto = _make_element(conceptos, 'Concepto')
|
||||||
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
|
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||||
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
concepto.set('NoIdentificacion', item.get('part_number') or '')
|
||||||
concepto.set('Cantidad', str(qty))
|
concepto.set('Cantidad', str(qty))
|
||||||
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
|
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||||
concepto.set('Unidad', 'PZA')
|
concepto.set('Unidad', 'PZA')
|
||||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||||
@@ -581,10 +556,10 @@ def build_global_invoice_xml(sales, tenant_config, year, month):
|
|||||||
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
||||||
|
|
||||||
concepto = _make_element(conceptos, 'Concepto')
|
concepto = _make_element(conceptos, 'Concepto')
|
||||||
concepto.set('ClaveProdServ', _item_sat_keys(item)[0])
|
concepto.set('ClaveProdServ', item.get('clave_prod_serv') or '25174800')
|
||||||
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
|
concepto.set('NoIdentificacion', item.get('part_number') or str(sale['id']))
|
||||||
concepto.set('Cantidad', str(qty))
|
concepto.set('Cantidad', str(qty))
|
||||||
concepto.set('ClaveUnidad', _item_sat_keys(item)[1])
|
concepto.set('ClaveUnidad', item.get('clave_unidad') or 'H87')
|
||||||
concepto.set('Unidad', 'PZA')
|
concepto.set('Unidad', 'PZA')
|
||||||
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
concepto.set('Descripcion', item.get('name') or 'Autoparte')
|
||||||
concepto.set('ValorUnitario', _format_amount(unit_price))
|
concepto.set('ValorUnitario', _format_amount(unit_price))
|
||||||
|
|||||||
@@ -69,27 +69,6 @@ def _resolve_metodo_pago(sale):
|
|||||||
return "PUE"
|
return "PUE"
|
||||||
|
|
||||||
|
|
||||||
def _item_sat_keys(item):
|
|
||||||
"""Return (clave_prod_serv, clave_unidad) with SAT-rule fallback.
|
|
||||||
|
|
||||||
Precedence: sale-item value, then ``services.sat_keys`` rules over
|
|
||||||
name/brand/category, then the historic hard-coded default. Never raises.
|
|
||||||
"""
|
|
||||||
cps = item.get("clave_prod_serv")
|
|
||||||
cu = item.get("clave_unidad")
|
|
||||||
if cps and cu:
|
|
||||||
return cps, cu
|
|
||||||
try:
|
|
||||||
from services import sat_keys
|
|
||||||
k, u, _src = sat_keys.resolve_key(
|
|
||||||
name=item.get("name"), brand=item.get("brand"),
|
|
||||||
category=item.get("category"), part_number=item.get("part_number"),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
k, u = None, None
|
|
||||||
return (cps or k or "25174800"), (cu or u or "H87")
|
|
||||||
|
|
||||||
|
|
||||||
def _build_items(sale_items):
|
def _build_items(sale_items):
|
||||||
items = []
|
items = []
|
||||||
for item in sale_items or []:
|
for item in sale_items or []:
|
||||||
@@ -97,13 +76,12 @@ def _build_items(sale_items):
|
|||||||
unit_price = _to_dec(item.get("unit_price", 0))
|
unit_price = _to_dec(item.get("unit_price", 0))
|
||||||
discount = _to_dec(item.get("discount_amount", 0))
|
discount = _to_dec(item.get("discount_amount", 0))
|
||||||
tax_rate = _to_dec(item.get("tax_rate", "0.16"))
|
tax_rate = _to_dec(item.get("tax_rate", "0.16"))
|
||||||
product_key, unit_key = _item_sat_keys(item)
|
|
||||||
|
|
||||||
# Facturapi price is unit price before taxes and discounts
|
# Facturapi price is unit price before taxes and discounts
|
||||||
product = {
|
product = {
|
||||||
"description": item.get("name") or "Autoparte",
|
"description": item.get("name") or "Autoparte",
|
||||||
"product_key": product_key,
|
"product_key": item.get("clave_prod_serv") or "25174800",
|
||||||
"unit_key": unit_key,
|
"unit_key": item.get("clave_unidad") or "H87",
|
||||||
"unit_name": "Pieza",
|
"unit_name": "Pieza",
|
||||||
"price": _fmt2(unit_price),
|
"price": _fmt2(unit_price),
|
||||||
"tax_included": False,
|
"tax_included": False,
|
||||||
@@ -158,91 +136,65 @@ def build_ingreso_payload(sale, tenant_config, customer=None):
|
|||||||
"folio_number": sale["id"],
|
"folio_number": sale["id"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Optional currency/exchange rate for non-MXN sales
|
# Optional exchange rate for USD
|
||||||
currency = (sale.get("currency") or "MXN").upper()
|
if sale.get("currency") and sale["currency"] != "MXN" and sale.get("exchange_rate"):
|
||||||
if currency != "MXN":
|
payload["exchange"] = _fmt6(sale["exchange_rate"])
|
||||||
payload["currency"] = currency
|
payload["currency"] = sale["currency"]
|
||||||
if sale.get("exchange_rate"):
|
|
||||||
payload["exchange"] = _fmt6(sale["exchange_rate"])
|
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def build_egreso_payload(sale, tenant_config, customer, original_uuid, folio_number=None, idempotency_key=None):
|
def build_egreso_payload(sale, tenant_config, customer, original_uuid):
|
||||||
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
|
"""Build Facturapi payload for a credit note (Comprobante tipo Egreso)."""
|
||||||
payload = build_ingreso_payload(sale, tenant_config, customer)
|
payload = build_ingreso_payload(sale, tenant_config, customer)
|
||||||
payload["type"] = "E"
|
payload["type"] = "E"
|
||||||
payload["use"] = "G02" # Devoluciones, descuentos o bonificaciones
|
payload["related_documents"] = [{"relationship": "01", "documents": [original_uuid]}]
|
||||||
payload["payment_method"] = "PUE"
|
payload["payment_method"] = "PUE"
|
||||||
payload["related_documents"] = [{"relationship": "01", "documents": original_uuid}]
|
|
||||||
if folio_number is not None:
|
|
||||||
payload["folio_number"] = folio_number
|
|
||||||
if idempotency_key:
|
|
||||||
payload["idempotency_key"] = idempotency_key
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _normalize_payment_date(value):
|
def build_pago_payload(payment, tenant_config, customer, original_uuid):
|
||||||
"""Return the ISO8601 datetime string Facturapi expects for a payment."""
|
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago)."""
|
||||||
if not value:
|
|
||||||
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
||||||
text = str(value).replace(" ", "T")
|
|
||||||
if len(text) == 10:
|
|
||||||
return f"{text}T12:00:00"
|
|
||||||
return text[:19]
|
|
||||||
|
|
||||||
|
|
||||||
def build_pago_payload(payment, tenant_config, customer, original_uuid, idempotency_key=None):
|
|
||||||
"""Build Facturapi payload for a payment complement (Comprobante tipo Pago).
|
|
||||||
|
|
||||||
`complements[0].data` is a list of payments (Facturapi JS SDK v5 types),
|
|
||||||
each payment carrying its own related_documents with the installment number
|
|
||||||
and the previous balance of the linked PPD invoice.
|
|
||||||
"""
|
|
||||||
tenant_cp = tenant_config.get("cp", "00000")
|
tenant_cp = tenant_config.get("cp", "00000")
|
||||||
customer_payload = _build_customer_payload(customer, tenant_cp)
|
customer_payload = _build_customer_payload(customer, tenant_cp)
|
||||||
|
|
||||||
amount = _to_dec(payment.get("amount", 0))
|
amount = _to_dec(payment.get("amount", 0))
|
||||||
rate = _to_dec(payment.get("tax_rate") or "0.16") or Decimal("0.16")
|
base = (amount / Decimal("1.16")).quantize(TWO, ROUND_HALF_UP)
|
||||||
base = (amount / (Decimal("1") + rate)).quantize(TWO, ROUND_HALF_UP)
|
|
||||||
|
payment_date = payment.get("date") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
if "T" not in str(payment_date):
|
||||||
|
payment_date = f"{payment_date}T12:00:00"
|
||||||
|
|
||||||
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
|
forma_pago = FORMA_PAGO_MAP.get((payment.get("payment_method") or "").lower().strip(), "01")
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"type": "P",
|
"type": "P",
|
||||||
"customer": customer_payload,
|
"customer": customer_payload,
|
||||||
"payment_form": forma_pago,
|
|
||||||
"complements": [
|
"complements": [
|
||||||
{
|
{
|
||||||
"type": "pago",
|
"type": "pago",
|
||||||
"data": [
|
"data": {
|
||||||
{
|
"payment_form": forma_pago,
|
||||||
"payment_form": forma_pago,
|
"payment_date": payment_date,
|
||||||
"date": _normalize_payment_date(payment.get("date")),
|
"amount": _fmt2(amount),
|
||||||
"related_documents": [
|
"related_documents": [
|
||||||
{
|
{
|
||||||
"uuid": original_uuid,
|
"uuid": original_uuid,
|
||||||
"amount": _fmt2(amount),
|
"amount": _fmt2(amount),
|
||||||
"installment": int(payment.get("installment") or 1),
|
"taxes": [
|
||||||
"last_balance": _fmt2(payment.get("last_balance") or 0),
|
{
|
||||||
"taxes": [
|
"type": "IVA",
|
||||||
{
|
"rate": 0.16,
|
||||||
"base": _fmt2(base),
|
"factor": "Tasa",
|
||||||
"type": "IVA",
|
"base": _fmt2(base),
|
||||||
"rate": _fmt6(rate),
|
}
|
||||||
"factor": "Tasa",
|
],
|
||||||
"withholding": False,
|
}
|
||||||
}
|
],
|
||||||
],
|
},
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
if idempotency_key:
|
|
||||||
payload["idempotency_key"] = idempotency_key
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
1. enqueue_cfdi() — inserts Facturapi JSON payload into cfdi_queue with status='pending'
|
||||||
2. enqueue_pending_complements() — enqueues the missing payment complements
|
2. process_queue() — sends pending items to Facturapi, updates status
|
||||||
(CFDI type "P") of PPD invoices and the credit notes (type "E") of returns
|
3. retry_failed() — retries failed items with exponential backoff
|
||||||
3. process_queue() — sends pending items to Facturapi, updates status
|
4. cancel_cfdi() — cancels a stamped CFDI via Facturapi
|
||||||
4. retry_failed() — retries failed items with exponential backoff
|
|
||||||
5. cancel_cfdi() — cancels a stamped CFDI via Facturapi
|
|
||||||
|
|
||||||
Facturapi endpoints used:
|
Facturapi endpoints used:
|
||||||
POST /v2/invoices — create and stamp an invoice
|
POST /v2/invoices — create and stamp an invoice
|
||||||
@@ -19,11 +17,9 @@ Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
|
||||||
|
|
||||||
from services import facturapi_service
|
from services import facturapi_service
|
||||||
from services.cfdi_facturapi_builder import build_egreso_payload, build_pago_payload
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,73 +27,10 @@ logger = logging.getLogger(__name__)
|
|||||||
BACKOFF_INTERVALS = [5, 30, 120, 600, 3600]
|
BACKOFF_INTERVALS = [5, 30, 120, 600, 3600]
|
||||||
MAX_RETRIES = len(BACKOFF_INTERVALS)
|
MAX_RETRIES = len(BACKOFF_INTERVALS)
|
||||||
|
|
||||||
# Movements older than this window are only reconciled on explicit backfill.
|
|
||||||
COMPLEMENT_LOOKBACK_DAYS = 7
|
|
||||||
|
|
||||||
TWO = Decimal("0.01")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _send_cfdi_pdf_wa(conn, invoice_id_external, sale_id, tenant_config):
|
|
||||||
"""Descarga el PDF timbrado y lo envía por WhatsApp al cliente (si tiene teléfono)."""
|
|
||||||
try:
|
|
||||||
from services import facturapi_service
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
SELECT c.phone FROM sales s
|
|
||||||
JOIN customers c ON c.id = s.customer_id
|
|
||||||
WHERE s.id = %s
|
|
||||||
""", (sale_id,))
|
|
||||||
row = cur.fetchone()
|
|
||||||
cur.close()
|
|
||||||
if not row or not row[0]:
|
|
||||||
return
|
|
||||||
phone = row[0]
|
|
||||||
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%' OR key LIKE 'wa_%'")
|
|
||||||
raw = {k: v for k, v in cur.fetchall()}
|
|
||||||
cur.close()
|
|
||||||
cfg = {
|
|
||||||
'provider': raw.get('whatsapp_provider', 'baileys'),
|
|
||||||
'phone_number_id': raw.get('wa_phone_number_id', ''),
|
|
||||||
'access_token': raw.get('wa_access_token', ''),
|
|
||||||
'bridge_url': raw.get('whatsapp_bridge_url', ''),
|
|
||||||
}
|
|
||||||
# Solo enviar si hay proveedor configurado (Meta o Baileys con bridge)
|
|
||||||
if cfg['provider'] != 'meta' and not cfg['bridge_url']:
|
|
||||||
return
|
|
||||||
|
|
||||||
pdf_bytes = facturapi_service.download_pdf(tenant_config, invoice_id_external)
|
|
||||||
import base64 as _b64
|
|
||||||
b64 = _b64.b64encode(pdf_bytes).decode()
|
|
||||||
filename = f"CFDI_{sale_id}.pdf"
|
|
||||||
|
|
||||||
from services.whatsapp_service import wa_send_document
|
|
||||||
result = wa_send_document(cfg, phone, b64, filename,
|
|
||||||
caption="Adjuntamos tu factura CFDI. Gracias por tu compra.")
|
|
||||||
try:
|
|
||||||
from services.audit import log_action
|
|
||||||
log_action(conn, 'CFDI_PDF_SENT', 'sale', sale_id,
|
|
||||||
new_value={'result': result, 'phone': phone})
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
logger.warning("PDF WA send failed for sale %s: %s", sale_id, e)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_provisional_folio(conn):
|
def _generate_provisional_folio(conn):
|
||||||
"""Generate a provisional folio like PRE-00001.
|
"""Generate a provisional folio like PRE-00001."""
|
||||||
|
|
||||||
Uses a transaction-level advisory lock to avoid duplicate provisional
|
|
||||||
folios when multiple CFDIs are enqueued concurrently.
|
|
||||||
"""
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("SELECT pg_advisory_xact_lock(hashtext('cfdi_provisional_folio'))")
|
|
||||||
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
||||||
seq = cur.fetchone()[0]
|
seq = cur.fetchone()[0]
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -143,340 +76,6 @@ def enqueue_cfdi(conn, sale_id, cfdi_type, payload):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ─── Payment complements (REP) and credit notes reconciliation ──────────────
|
|
||||||
|
|
||||||
|
|
||||||
def _to_dec(val):
|
|
||||||
if val is None:
|
|
||||||
return Decimal("0")
|
|
||||||
return Decimal(str(val))
|
|
||||||
|
|
||||||
|
|
||||||
def _payload_customer(payload):
|
|
||||||
"""Adapt the receptor block of a stored CFDI payload to builder input."""
|
|
||||||
customer = payload.get("customer") or {}
|
|
||||||
tax_id = customer.get("tax_id")
|
|
||||||
if not tax_id:
|
|
||||||
return None
|
|
||||||
address = customer.get("address") or {}
|
|
||||||
return {
|
|
||||||
"rfc": tax_id,
|
|
||||||
"razon_social": customer.get("legal_name"),
|
|
||||||
"regimen_fiscal": customer.get("tax_system"),
|
|
||||||
"cp": address.get("zip"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _pago_related_amounts(payload):
|
|
||||||
"""Amounts of the related documents of a stored pago payload."""
|
|
||||||
amounts = []
|
|
||||||
for complement in payload.get("complements") or []:
|
|
||||||
data = complement.get("data")
|
|
||||||
blocks = data if isinstance(data, list) else [data]
|
|
||||||
for block in blocks:
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
continue
|
|
||||||
for doc in block.get("related_documents") or []:
|
|
||||||
try:
|
|
||||||
amounts.append(round(float(doc.get("amount") or 0), 2))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return amounts
|
|
||||||
|
|
||||||
|
|
||||||
def _items_signature(payload):
|
|
||||||
"""Comparable signature of a payload's concepts (product key, qty, price)."""
|
|
||||||
signature = []
|
|
||||||
for item in payload.get("items") or []:
|
|
||||||
product = item.get("product") or {}
|
|
||||||
try:
|
|
||||||
signature.append(
|
|
||||||
(
|
|
||||||
str(product.get("product_key") or ""),
|
|
||||||
int(item.get("quantity") or 0),
|
|
||||||
round(float(product.get("price") or 0), 2),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
return tuple(sorted(signature))
|
|
||||||
|
|
||||||
|
|
||||||
def _stored_signatures(cur, sale_id, cfdi_type):
|
|
||||||
"""Signatures/amounts of the non-cancelled CFDIs of a sale, for dedupe."""
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT payload_unsigned FROM cfdi_queue
|
|
||||||
WHERE sale_id = %s AND type = %s AND status <> 'cancelled'
|
|
||||||
""",
|
|
||||||
(sale_id, cfdi_type),
|
|
||||||
)
|
|
||||||
values = []
|
|
||||||
for (payload_unsigned,) in cur.fetchall():
|
|
||||||
try:
|
|
||||||
payload = json.loads(payload_unsigned or "{}")
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if cfdi_type == "pago":
|
|
||||||
values.extend(_pago_related_amounts(payload))
|
|
||||||
else:
|
|
||||||
signature = _items_signature(payload)
|
|
||||||
if signature:
|
|
||||||
values.append(signature)
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _enqueue_pending_pagos(conn, cur, tenant_config, since_dt):
|
|
||||||
"""Enqueue payment complements for every payment of a stamped PPD invoice."""
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT q.sale_id, q.uuid_fiscal, q.payload_unsigned,
|
|
||||||
s.total, s.subtotal, s.discount_total, s.tax_total
|
|
||||||
FROM cfdi_queue q
|
|
||||||
JOIN sales s ON s.id = q.sale_id
|
|
||||||
WHERE q.type = 'ingreso' AND q.status = 'stamped'
|
|
||||||
AND q.uuid_fiscal IS NOT NULL AND q.sale_id IS NOT NULL
|
|
||||||
AND (%s::timestamptz IS NULL OR EXISTS (
|
|
||||||
SELECT 1 FROM sale_payments sp
|
|
||||||
WHERE sp.sale_id = q.sale_id
|
|
||||||
AND sp.amount > 0
|
|
||||||
AND sp.created_at >= %s))
|
|
||||||
ORDER BY q.sale_id
|
|
||||||
""",
|
|
||||||
(since_dt, since_dt),
|
|
||||||
)
|
|
||||||
|
|
||||||
created = []
|
|
||||||
for sale_id, original_uuid, payload_unsigned, total, subtotal, discount_total, tax_total in cur.fetchall():
|
|
||||||
try:
|
|
||||||
invoice_payload = json.loads(payload_unsigned or "{}")
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if str(invoice_payload.get("payment_method") or "").upper() != "PPD":
|
|
||||||
continue
|
|
||||||
|
|
||||||
customer = _payload_customer(invoice_payload)
|
|
||||||
if not customer:
|
|
||||||
continue
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, amount, method, created_at
|
|
||||||
FROM sale_payments
|
|
||||||
WHERE sale_id = %s AND amount > 0
|
|
||||||
ORDER BY id
|
|
||||||
""",
|
|
||||||
(sale_id,),
|
|
||||||
)
|
|
||||||
payments = cur.fetchall()
|
|
||||||
if not payments:
|
|
||||||
continue
|
|
||||||
|
|
||||||
already = _stored_signatures(cur, sale_id, "pago")
|
|
||||||
|
|
||||||
base_total = _to_dec(subtotal) - _to_dec(discount_total)
|
|
||||||
tax_rate = (_to_dec(tax_total) / base_total) if base_total > 0 else Decimal("0.16")
|
|
||||||
|
|
||||||
balance = _to_dec(total)
|
|
||||||
for installment, (payment_id, amount, method, created_at) in enumerate(payments, start=1):
|
|
||||||
paid = _to_dec(amount)
|
|
||||||
previous_balance = balance if balance > Decimal("0") else Decimal("0")
|
|
||||||
balance -= paid
|
|
||||||
if paid <= 0:
|
|
||||||
continue
|
|
||||||
if round(float(paid), 2) in already:
|
|
||||||
already.remove(round(float(paid), 2))
|
|
||||||
continue
|
|
||||||
if since_dt and created_at and created_at < since_dt:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = build_pago_payload(
|
|
||||||
{
|
|
||||||
"id": payment_id,
|
|
||||||
"amount": paid,
|
|
||||||
"payment_method": method,
|
|
||||||
"date": created_at,
|
|
||||||
"installment": installment,
|
|
||||||
"last_balance": previous_balance,
|
|
||||||
"tax_rate": tax_rate,
|
|
||||||
},
|
|
||||||
tenant_config,
|
|
||||||
customer,
|
|
||||||
original_uuid,
|
|
||||||
idempotency_key=f"nexus-rep-{payment_id}",
|
|
||||||
)
|
|
||||||
queued = enqueue_cfdi(conn, sale_id, "pago", payload)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not enqueue payment complement for sale_payment %s: %s", payment_id, e)
|
|
||||||
continue
|
|
||||||
|
|
||||||
created.append(
|
|
||||||
{
|
|
||||||
"cfdi_id": queued["id"],
|
|
||||||
"sale_id": sale_id,
|
|
||||||
"sale_payment_id": payment_id,
|
|
||||||
"amount": float(paid),
|
|
||||||
"installment": installment,
|
|
||||||
"original_uuid": original_uuid,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
def _enqueue_pending_egresos(conn, cur, tenant_config, since_dt):
|
|
||||||
"""Enqueue credit notes (egreso) for returns of already stamped sales."""
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT r.id, r.sale_id, r.created_at, s.currency, s.exchange_rate,
|
|
||||||
ing.uuid_fiscal, ing.payload_unsigned
|
|
||||||
FROM returns r
|
|
||||||
JOIN sales s ON s.id = r.sale_id
|
|
||||||
JOIN LATERAL (
|
|
||||||
SELECT q.uuid_fiscal, q.payload_unsigned
|
|
||||||
FROM cfdi_queue q
|
|
||||||
WHERE q.sale_id = r.sale_id AND q.type = 'ingreso'
|
|
||||||
AND q.status = 'stamped' AND q.uuid_fiscal IS NOT NULL
|
|
||||||
ORDER BY q.stamped_at DESC NULLS LAST, q.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) ing ON TRUE
|
|
||||||
WHERE r.status = 'completed'
|
|
||||||
AND (%s::timestamptz IS NULL OR r.created_at >= %s)
|
|
||||||
ORDER BY r.id
|
|
||||||
""",
|
|
||||||
(since_dt, since_dt),
|
|
||||||
)
|
|
||||||
|
|
||||||
created = []
|
|
||||||
for return_id, sale_id, created_at, currency, exchange_rate, original_uuid, payload_unsigned in cur.fetchall():
|
|
||||||
if since_dt and created_at and created_at < since_dt:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
invoice_payload = json.loads(payload_unsigned or "{}")
|
|
||||||
except ValueError:
|
|
||||||
invoice_payload = {}
|
|
||||||
customer = _payload_customer(invoice_payload)
|
|
||||||
if not customer:
|
|
||||||
continue
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT si.part_number, si.name, ri.quantity, si.unit_price, si.discount_pct,
|
|
||||||
si.tax_rate, si.clave_prod_serv, si.clave_unidad
|
|
||||||
FROM return_items ri
|
|
||||||
JOIN sale_items si ON si.id = ri.sale_item_id
|
|
||||||
WHERE ri.return_id = %s
|
|
||||||
ORDER BY ri.id
|
|
||||||
""",
|
|
||||||
(return_id,),
|
|
||||||
)
|
|
||||||
items = []
|
|
||||||
for part_number, name, quantity, unit_price, discount_pct, tax_rate, clave_prod, clave_unidad in cur.fetchall():
|
|
||||||
price = float(unit_price or 0)
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"part_number": part_number,
|
|
||||||
"name": name or part_number,
|
|
||||||
"quantity": int(quantity or 0),
|
|
||||||
"unit_price": price,
|
|
||||||
"discount_amount": round(price * float(discount_pct or 0) / 100 * int(quantity or 0), 2),
|
|
||||||
"tax_rate": float(tax_rate if tax_rate is not None else 0.16),
|
|
||||||
"clave_prod_serv": clave_prod,
|
|
||||||
"clave_unidad": clave_unidad,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = build_egreso_payload(
|
|
||||||
{
|
|
||||||
"id": sale_id,
|
|
||||||
"items": items,
|
|
||||||
"currency": currency,
|
|
||||||
"exchange_rate": exchange_rate,
|
|
||||||
"forma_pago_sat": invoice_payload.get("payment_form"),
|
|
||||||
},
|
|
||||||
tenant_config,
|
|
||||||
customer,
|
|
||||||
original_uuid,
|
|
||||||
folio_number=return_id,
|
|
||||||
idempotency_key=f"nexus-egreso-{return_id}",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not build credit note for return %s: %s", return_id, e)
|
|
||||||
continue
|
|
||||||
|
|
||||||
signature = _items_signature(payload)
|
|
||||||
already = _stored_signatures(cur, sale_id, "egreso")
|
|
||||||
if signature and signature in already:
|
|
||||||
already.remove(signature)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
queued = enqueue_cfdi(conn, sale_id, "egreso", payload)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not enqueue credit note for return %s: %s", return_id, e)
|
|
||||||
continue
|
|
||||||
|
|
||||||
created.append(
|
|
||||||
{
|
|
||||||
"cfdi_id": queued["id"],
|
|
||||||
"sale_id": sale_id,
|
|
||||||
"return_id": return_id,
|
|
||||||
"original_uuid": original_uuid,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
def enqueue_pending_complements(conn, tenant_config=None, since=None, backfill=False):
|
|
||||||
"""Enqueue the missing payment complements and credit notes.
|
|
||||||
|
|
||||||
Every payment of a stamped PPD invoice gets a CFDI type "P" linked to the
|
|
||||||
original UUID, and every completed return of a stamped sale gets a CFDI
|
|
||||||
type "E". Idempotent: a movement that already has a non-cancelled CFDI of
|
|
||||||
the matching type/amount is skipped.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: psycopg2 connection
|
|
||||||
tenant_config: issuer config (only the default zip is used)
|
|
||||||
since: datetime or ISO string; reconcile movements created after it.
|
|
||||||
Defaults to COMPLEMENT_LOOKBACK_DAYS ago.
|
|
||||||
backfill: reconcile the whole history, ignoring `since`
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: {pagos: [...], egresos: [...], since: iso string}
|
|
||||||
"""
|
|
||||||
if backfill:
|
|
||||||
since_dt = None
|
|
||||||
elif since:
|
|
||||||
if isinstance(since, datetime):
|
|
||||||
since_dt = since
|
|
||||||
else:
|
|
||||||
since_dt = datetime.fromisoformat(str(since))
|
|
||||||
if since_dt.tzinfo is None:
|
|
||||||
since_dt = since_dt.replace(tzinfo=timezone.utc)
|
|
||||||
else:
|
|
||||||
since_dt = datetime.now(timezone.utc) - timedelta(days=COMPLEMENT_LOOKBACK_DAYS)
|
|
||||||
|
|
||||||
tenant_config = tenant_config or {}
|
|
||||||
cur = conn.cursor()
|
|
||||||
result = {"pagos": [], "egresos": [], "since": str(since_dt) if since_dt else None}
|
|
||||||
|
|
||||||
for key, sweep in (("pagos", _enqueue_pending_pagos), ("egresos", _enqueue_pending_egresos)):
|
|
||||||
try:
|
|
||||||
result[key] = sweep(conn, cur, tenant_config, since_dt)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not reconcile %s complements: %s", key, e)
|
|
||||||
conn.rollback()
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
cur.close()
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def process_queue(conn, tenant_config, dry_run=False):
|
def process_queue(conn, tenant_config, dry_run=False):
|
||||||
"""Process all pending CFDI items in the queue.
|
"""Process all pending CFDI items in the queue.
|
||||||
|
|
||||||
@@ -492,15 +91,6 @@ def process_queue(conn, tenant_config, dry_run=False):
|
|||||||
Returns:
|
Returns:
|
||||||
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
||||||
"""
|
"""
|
||||||
# First enqueue the complements (REP / credit notes) that are still missing,
|
|
||||||
# so they are stamped in this same run.
|
|
||||||
if not dry_run:
|
|
||||||
try:
|
|
||||||
enqueue_pending_complements(conn, tenant_config)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Complement reconciliation failed: %s", e)
|
|
||||||
conn.rollback()
|
|
||||||
|
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -511,7 +101,6 @@ def process_queue(conn, tenant_config, dry_run=False):
|
|||||||
AND retry_count < %s
|
AND retry_count < %s
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
FOR UPDATE SKIP LOCKED
|
|
||||||
""",
|
""",
|
||||||
(MAX_RETRIES,),
|
(MAX_RETRIES,),
|
||||||
)
|
)
|
||||||
@@ -569,17 +158,10 @@ def process_queue(conn, tenant_config, dry_run=False):
|
|||||||
error_message = NULL
|
error_message = NULL
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""",
|
""",
|
||||||
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
|
(xml_signed_str, uuid_fiscal, invoice_id, cfdi_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# Enviar PDF por WhatsApp al cliente (best effort)
|
|
||||||
try:
|
|
||||||
if _sale_id:
|
|
||||||
_send_cfdi_pdf_wa(conn, invoice_id, _sale_id, tenant_config)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
results["stamped"] += 1
|
results["stamped"] += 1
|
||||||
results["details"].append(
|
results["details"].append(
|
||||||
{
|
{
|
||||||
@@ -782,7 +364,7 @@ def get_queue_status(conn, filters=None):
|
|||||||
params.append(int(filters["sale_id"]))
|
params.append(int(filters["sale_id"]))
|
||||||
|
|
||||||
if filters.get("type"):
|
if filters.get("type"):
|
||||||
where_clauses.append("LOWER(q.type) = LOWER(%s)")
|
where_clauses.append("q.type = %s")
|
||||||
params.append(filters["type"])
|
params.append(filters["type"])
|
||||||
|
|
||||||
where = " AND ".join(where_clauses)
|
where = " AND ".join(where_clauses)
|
||||||
@@ -794,12 +376,8 @@ def get_queue_status(conn, filters=None):
|
|||||||
f"""
|
f"""
|
||||||
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
||||||
q.retry_count, q.provisional_folio, q.error_message,
|
q.retry_count, q.provisional_folio, q.error_message,
|
||||||
q.cancel_motive, q.created_at, q.stamped_at, q.external_id,
|
q.cancel_motive, q.created_at, q.stamped_at, q.external_id
|
||||||
c.name as customer_name, c.rfc,
|
|
||||||
s.subtotal, s.tax_total, s.total, s.payment_method
|
|
||||||
FROM cfdi_queue q
|
FROM cfdi_queue q
|
||||||
LEFT JOIN sales s ON q.sale_id = s.id
|
|
||||||
LEFT JOIN customers c ON s.customer_id = c.id
|
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
ORDER BY q.created_at DESC
|
ORDER BY q.created_at DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
@@ -823,12 +401,6 @@ def get_queue_status(conn, filters=None):
|
|||||||
"created_at": str(r[9]) if r[9] else None,
|
"created_at": str(r[9]) if r[9] else None,
|
||||||
"stamped_at": str(r[10]) if r[10] else None,
|
"stamped_at": str(r[10]) if r[10] else None,
|
||||||
"external_id": r[11],
|
"external_id": r[11],
|
||||||
"customer_name": r[12],
|
|
||||||
"rfc": r[13],
|
|
||||||
"subtotal": float(r[14]) if r[14] else 0,
|
|
||||||
"tax_total": float(r[15]) if r[15] else 0,
|
|
||||||
"total": float(r[16]) if r[16] else 0,
|
|
||||||
"payment_method": r[17],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
- Customer analytics (LTV, frequency, churn risk)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
def _aware(dt):
|
|
||||||
"""Normalize a possibly-naive datetime (psycopg2 may return either) to UTC."""
|
|
||||||
if dt is None:
|
|
||||||
return None
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
return dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Customer Activities ─────────────────────────────
|
# ─── Customer Activities ─────────────────────────────
|
||||||
@@ -311,15 +302,11 @@ def get_customer_analytics(conn, customer_id):
|
|||||||
WHERE customer_id = %s AND status = 'completed'
|
WHERE customer_id = %s AND status = 'completed'
|
||||||
""", (customer_id,))
|
""", (customer_id,))
|
||||||
ltv, total_orders, first_purchase, last_purchase, aov = cur.fetchone()
|
ltv, total_orders, first_purchase, last_purchase, aov = cur.fetchone()
|
||||||
first_purchase = _aware(first_purchase)
|
|
||||||
last_purchase = _aware(last_purchase)
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# Days since last purchase
|
# Days since last purchase
|
||||||
days_since_last = None
|
days_since_last = None
|
||||||
if last_purchase:
|
if last_purchase:
|
||||||
days_since_last = (now - last_purchase).days
|
days_since_last = (datetime.utcnow() - last_purchase).days
|
||||||
|
|
||||||
# Purchase frequency (orders per month)
|
# Purchase frequency (orders per month)
|
||||||
frequency = 0.0
|
frequency = 0.0
|
||||||
@@ -327,69 +314,13 @@ def get_customer_analytics(conn, customer_id):
|
|||||||
months = max(1, (last_purchase - first_purchase).days / 30.0)
|
months = max(1, (last_purchase - first_purchase).days / 30.0)
|
||||||
frequency = round(total_orders / months, 2)
|
frequency = round(total_orders / months, 2)
|
||||||
|
|
||||||
# Real inter-purchase interval per customer (A12). Uses the gaps between
|
# Churn risk: no purchase in 90+ days = high, 60-90 = medium, <60 = low
|
||||||
# consecutive completed sales instead of fixed 60/90-day thresholds.
|
churn_risk = 'low'
|
||||||
cur.execute("""
|
if days_since_last is not None:
|
||||||
SELECT created_at FROM sales
|
if days_since_last > 90:
|
||||||
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_risk = 'high'
|
||||||
churn_probability = 0.9
|
|
||||||
churn_reason = (f"Lleva {days_since_last} días sin comprar, más de 2× "
|
|
||||||
f"su intervalo habitual ({avg_interval:.0f} días).")
|
|
||||||
elif ratio > 1.5:
|
|
||||||
churn_risk = 'high'
|
|
||||||
churn_probability = 0.7
|
|
||||||
churn_reason = (f"Rebasó 1.5× su intervalo habitual de compra "
|
|
||||||
f"({avg_interval:.0f} días).")
|
|
||||||
elif ratio > 1.0:
|
|
||||||
churn_risk = 'medium'
|
|
||||||
churn_probability = 0.4
|
|
||||||
churn_reason = f"Ya superó su intervalo habitual de compra ({avg_interval:.0f} días)."
|
|
||||||
else:
|
|
||||||
churn_risk = 'low'
|
|
||||||
churn_probability = 0.15
|
|
||||||
churn_reason = f"Dentro de su intervalo habitual de compra ({avg_interval:.0f} días)."
|
|
||||||
else:
|
|
||||||
# Fewer than two purchases: not enough history for a cadence. Fall back
|
|
||||||
# to the legacy fixed thresholds but report that we lacked data.
|
|
||||||
if days_since_last is None:
|
|
||||||
churn_risk = 'low'
|
|
||||||
churn_probability = 0.0
|
|
||||||
churn_reason = 'Sin compras registradas.'
|
|
||||||
elif days_since_last > 90:
|
|
||||||
churn_risk = 'high'
|
|
||||||
churn_probability = 0.75
|
|
||||||
churn_reason = f"Sin compras desde hace {days_since_last} días."
|
|
||||||
elif days_since_last > 60:
|
elif days_since_last > 60:
|
||||||
churn_risk = 'medium'
|
churn_risk = 'medium'
|
||||||
churn_probability = 0.45
|
|
||||||
churn_reason = f"Sin compras desde hace {days_since_last} días."
|
|
||||||
else:
|
|
||||||
churn_risk = 'low'
|
|
||||||
churn_probability = 0.15
|
|
||||||
churn_reason = f"Compra reciente (hace {days_since_last} días)."
|
|
||||||
|
|
||||||
# Favorite categories (from inventory via sale_items)
|
# Favorite categories (from inventory via sale_items)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -431,15 +362,6 @@ def get_customer_analytics(conn, customer_id):
|
|||||||
'days_since_last_purchase': days_since_last,
|
'days_since_last_purchase': days_since_last,
|
||||||
'purchase_frequency_monthly': frequency,
|
'purchase_frequency_monthly': frequency,
|
||||||
'churn_risk': churn_risk,
|
'churn_risk': churn_risk,
|
||||||
# A12 additions: real inter-purchase cadence + prediction.
|
|
||||||
'avg_days_between_purchases': avg_interval,
|
|
||||||
'purchase_intervals_count': len(intervals),
|
|
||||||
'predicted_next_purchase_date': (
|
|
||||||
predicted_next_purchase_date.isoformat() if predicted_next_purchase_date else None
|
|
||||||
),
|
|
||||||
'days_until_next_purchase': days_until_next_purchase,
|
|
||||||
'churn_probability': churn_probability,
|
|
||||||
'churn_reason': churn_reason,
|
|
||||||
'favorite_categories': categories,
|
'favorite_categories': categories,
|
||||||
'loyalty': {
|
'loyalty': {
|
||||||
'points_balance': loyalty_balance,
|
'points_balance': loyalty_balance,
|
||||||
|
|||||||
@@ -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
|
return USER_KEY.strip() or None
|
||||||
|
|
||||||
|
|
||||||
def _find_user_key(tenant_config: dict) -> str | None:
|
|
||||||
"""Return the Facturapi user key (env or sk_user_* tenant key), if any."""
|
|
||||||
user_key = _get_user_key()
|
|
||||||
if user_key:
|
|
||||||
return user_key
|
|
||||||
for key in ("facturapi_key", "cfdi_facturapi_key"):
|
|
||||||
tenant_key = (tenant_config.get(key) or "").strip()
|
|
||||||
if tenant_key.startswith("sk_user_"):
|
|
||||||
return tenant_key
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_user_key_mode(tenant_config: dict) -> bool:
|
def _is_user_key_mode(tenant_config: dict) -> bool:
|
||||||
return bool(_get_user_key()) and not _get_secret_key(tenant_config)
|
return bool(_get_user_key()) and not _get_secret_key(tenant_config)
|
||||||
|
|
||||||
@@ -150,9 +138,7 @@ def get_organization(org_id: str, api_key: str) -> dict:
|
|||||||
def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict:
|
def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -> dict:
|
||||||
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
|
"""Upload CSD (Certificado de Sello Digital) to Facturapi.
|
||||||
|
|
||||||
cer_b64 and key_b64 are base64-encoded strings. Uses the documented
|
cer_b64 and key_b64 are base64-encoded strings.
|
||||||
PUT /organizations/{id}/certificate endpoint with the `cer`, `key` and
|
|
||||||
`password` multipart fields.
|
|
||||||
"""
|
"""
|
||||||
api_key = get_api_key(tenant_config)
|
api_key = get_api_key(tenant_config)
|
||||||
org_id = _get_org_id(tenant_config)
|
org_id = _get_org_id(tenant_config)
|
||||||
@@ -164,11 +150,11 @@ def upload_csd(tenant_config: dict, cer_b64: str, key_b64: str, password: str) -
|
|||||||
|
|
||||||
url = f"{BASE_URL}/organizations/{org_id}/certificate"
|
url = f"{BASE_URL}/organizations/{org_id}/certificate"
|
||||||
files = {
|
files = {
|
||||||
"cer": ("certificate.cer", cer_bytes, "application/octet-stream"),
|
"certificate": ("certificate.cer", cer_bytes, "application/octet-stream"),
|
||||||
"key": ("private_key.key", key_bytes, "application/octet-stream"),
|
"private_key": ("private_key.key", key_bytes, "application/octet-stream"),
|
||||||
"password": (None, password),
|
"secret": (None, password),
|
||||||
}
|
}
|
||||||
resp = requests.put(url, auth=(api_key, ""), files=files, timeout=60)
|
resp = requests.post(url, auth=(api_key, ""), files=files, timeout=60)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
raise FacturapiError(
|
raise FacturapiError(
|
||||||
f"CSD upload failed: {resp.status_code} {resp.text[:500]}",
|
f"CSD upload failed: {resp.status_code} {resp.text[:500]}",
|
||||||
@@ -184,9 +170,13 @@ def _get_user_key_for_tenant(tenant_config: dict) -> str:
|
|||||||
1. FACTURAPI_USER_KEY environment variable
|
1. FACTURAPI_USER_KEY environment variable
|
||||||
2. tenant_config.facturapi_key if it starts with sk_user_
|
2. tenant_config.facturapi_key if it starts with sk_user_
|
||||||
"""
|
"""
|
||||||
user_key = _find_user_key(tenant_config)
|
user_key = _get_user_key()
|
||||||
if user_key:
|
if user_key:
|
||||||
return user_key
|
return user_key
|
||||||
|
for key in ("facturapi_key", "cfdi_facturapi_key"):
|
||||||
|
tenant_key = (tenant_config.get(key) or "").strip()
|
||||||
|
if tenant_key.startswith("sk_user_"):
|
||||||
|
return tenant_key
|
||||||
raise FacturapiError("FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required")
|
raise FacturapiError("FACTURAPI_USER_KEY env or a Facturapi user key (sk_user_*) is required")
|
||||||
|
|
||||||
|
|
||||||
@@ -216,7 +206,7 @@ def find_organization_by_rfc(tenant_config: dict) -> dict | None:
|
|||||||
|
|
||||||
|
|
||||||
def create_organization(tenant_config: dict) -> dict:
|
def create_organization(tenant_config: dict) -> dict:
|
||||||
"""Create or reuse a Facturapi organization, configure legal data and return a live key.
|
"""Create a new Facturapi organization for the tenant and return live key.
|
||||||
|
|
||||||
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
|
Requires FACTURAPI_USER_KEY env or a user key (sk_user_*) in tenant_config.
|
||||||
Uses tenant RFC/razon_social if available.
|
Uses tenant RFC/razon_social if available.
|
||||||
@@ -226,121 +216,24 @@ def create_organization(tenant_config: dict) -> dict:
|
|||||||
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
||||||
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
|
name = tenant_config.get("razon_social") or tenant_config.get("name") or rfc or "Nexus"
|
||||||
|
|
||||||
# 1) Reuse the organization already stored locally, if it still exists in Facturapi.
|
# First try to find existing org by RFC
|
||||||
local_org_id = _get_org_id(tenant_config)
|
existing = find_organization_by_rfc(tenant_config) if rfc else None
|
||||||
if local_org_id:
|
if existing:
|
||||||
try:
|
org_id = existing["id"]
|
||||||
get_organization(local_org_id, user_key)
|
else:
|
||||||
org_id = local_org_id
|
|
||||||
except FacturapiError:
|
|
||||||
local_org_id = None
|
|
||||||
|
|
||||||
# 2) Try to find an existing organization by RFC (legacy / Horux-style lookup).
|
|
||||||
if not local_org_id and rfc:
|
|
||||||
existing = find_organization_by_rfc(tenant_config)
|
|
||||||
if existing:
|
|
||||||
org_id = existing["id"]
|
|
||||||
else:
|
|
||||||
payload = {"name": name}
|
|
||||||
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
|
|
||||||
org_id = org.get("id")
|
|
||||||
if not org_id:
|
|
||||||
raise FacturapiError("Could not create organization: no id returned")
|
|
||||||
elif not local_org_id:
|
|
||||||
payload = {"name": name}
|
payload = {"name": name}
|
||||||
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
|
org = _request("POST", "/organizations", user_key, json_payload=payload, timeout=60)
|
||||||
org_id = org.get("id")
|
org_id = org.get("id")
|
||||||
if not org_id:
|
if not org_id:
|
||||||
raise FacturapiError("Could not create organization: no id returned")
|
raise FacturapiError("Could not create organization: no id returned")
|
||||||
|
|
||||||
# Configure fiscal/legal data (required before Live mode can be used)
|
|
||||||
try:
|
|
||||||
update_organization_legal(tenant_config, org_id)
|
|
||||||
except FacturapiError:
|
|
||||||
# If legal update fails, still return org info so the caller can surface it,
|
|
||||||
# but do not generate a live key because it would be unusable.
|
|
||||||
return {"org_id": org_id, "api_key": None, "legal_updated": False}
|
|
||||||
|
|
||||||
# Generate live secret key
|
# Generate live secret key
|
||||||
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
|
key_resp = _request("PUT", f"/organizations/{org_id}/apikeys/live", user_key, json_payload={}, timeout=60)
|
||||||
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
|
live_key = key_resp.get("key") if isinstance(key_resp, dict) else str(key_resp)
|
||||||
if not live_key:
|
if not live_key:
|
||||||
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
raise FacturapiError(f"Could not generate live key for org {org_id}")
|
||||||
|
|
||||||
return {"org_id": org_id, "api_key": live_key, "legal_updated": True}
|
return {"org_id": org_id, "api_key": live_key}
|
||||||
|
|
||||||
|
|
||||||
def _build_legal_payload(tenant_config: dict) -> dict:
|
|
||||||
"""Build Facturapi /organizations/{id}/legal payload from tenant config.
|
|
||||||
|
|
||||||
Facturapi expects `name`, `legal_name`, `tax_system` and an `address` object
|
|
||||||
with at least `street` and `exterior` non-empty. `tax_id` is not accepted
|
|
||||||
by this endpoint (it is set from the CSD or from the organization profile).
|
|
||||||
"""
|
|
||||||
rfc = (tenant_config.get("rfc") or "").upper().strip()
|
|
||||||
legal_name = (tenant_config.get("razon_social") or "").strip()
|
|
||||||
tax_system = (tenant_config.get("regimen_fiscal") or "").strip() or "601"
|
|
||||||
zip_code = (tenant_config.get("cp") or "").strip() or "00000"
|
|
||||||
if not rfc or not legal_name:
|
|
||||||
raise FacturapiError("RFC y Razón Social son obligatorios para configurar la organización en Facturapi")
|
|
||||||
|
|
||||||
name = legal_name or rfc or "Nexus"
|
|
||||||
street = (tenant_config.get("direccion") or "").strip() or "No especificada"
|
|
||||||
exterior = (
|
|
||||||
(tenant_config.get("exterior") or "").strip()
|
|
||||||
or (tenant_config.get("numero_exterior") or "").strip()
|
|
||||||
or "S/N"
|
|
||||||
)
|
|
||||||
|
|
||||||
address = {
|
|
||||||
"zip": zip_code,
|
|
||||||
"street": street,
|
|
||||||
"exterior": exterior,
|
|
||||||
}
|
|
||||||
|
|
||||||
optional_address_fields = {
|
|
||||||
"interior": (tenant_config.get("interior") or tenant_config.get("numero_interior") or "").strip(),
|
|
||||||
"neighborhood": (tenant_config.get("colonia") or "").strip(),
|
|
||||||
"city": (tenant_config.get("ciudad") or "").strip(),
|
|
||||||
"municipality": (tenant_config.get("municipio") or "").strip(),
|
|
||||||
"state": (tenant_config.get("estado") or "").strip(),
|
|
||||||
}
|
|
||||||
for key, value in optional_address_fields.items():
|
|
||||||
if value:
|
|
||||||
address[key] = value
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": name,
|
|
||||||
"legal_name": legal_name,
|
|
||||||
"tax_system": tax_system,
|
|
||||||
"address": address,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def update_organization_legal(tenant_config: dict, org_id: str) -> dict:
|
|
||||||
"""Update the legal/fiscal data of a Facturapi organization.
|
|
||||||
|
|
||||||
Uses the user key (admin) because the org may not have a live key yet.
|
|
||||||
"""
|
|
||||||
user_key = _get_user_key_for_tenant(tenant_config)
|
|
||||||
payload = _build_legal_payload(tenant_config)
|
|
||||||
return _request("PUT", f"/organizations/{org_id}/legal", user_key, json_payload=payload, timeout=60)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_status_key(tenant_config: dict) -> str | None:
|
|
||||||
"""Return the best key for read-only organization status checks.
|
|
||||||
|
|
||||||
Prefer the Facturapi user key because the live secret key cannot read
|
|
||||||
organization metadata until the org is production-ready.
|
|
||||||
"""
|
|
||||||
user = _get_user_key()
|
|
||||||
if user:
|
|
||||||
return user
|
|
||||||
for key in ("facturapi_key", "cfdi_facturapi_key"):
|
|
||||||
tenant_key = (tenant_config.get(key) or "").strip()
|
|
||||||
if tenant_key.startswith("sk_user_"):
|
|
||||||
return tenant_key
|
|
||||||
return _get_secret_key(tenant_config)
|
|
||||||
|
|
||||||
|
|
||||||
def get_org_status(tenant_config: dict) -> dict:
|
def get_org_status(tenant_config: dict) -> dict:
|
||||||
@@ -356,12 +249,12 @@ def get_org_status(tenant_config: dict) -> dict:
|
|||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
has_secret = bool(_get_secret_key(tenant_config))
|
try:
|
||||||
has_user = bool(_find_user_key(tenant_config))
|
api_key = get_api_key(tenant_config)
|
||||||
if not has_secret and not has_user:
|
result["has_key"] = True
|
||||||
result["error"] = "Facturapi not configured. Set FACTURAPI_USER_KEY env or tenant_config.facturapi_secret_key"
|
except FacturapiError as e:
|
||||||
|
result["error"] = str(e)
|
||||||
return result
|
return result
|
||||||
result["has_key"] = True
|
|
||||||
|
|
||||||
org_id = _get_org_id(tenant_config)
|
org_id = _get_org_id(tenant_config)
|
||||||
if not org_id:
|
if not org_id:
|
||||||
@@ -371,37 +264,21 @@ def get_org_status(tenant_config: dict) -> dict:
|
|||||||
result["has_org_id"] = True
|
result["has_org_id"] = True
|
||||||
result["org_id"] = org_id
|
result["org_id"] = org_id
|
||||||
|
|
||||||
api_key = _get_status_key(tenant_config)
|
|
||||||
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 {
|
|
||||||
"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:
|
try:
|
||||||
result.update(_fetch())
|
org = get_organization(org_id, api_key)
|
||||||
|
legal = org.get("legal", {})
|
||||||
|
cert = org.get("certificate", {})
|
||||||
|
result.update(
|
||||||
|
{
|
||||||
|
"configured": True,
|
||||||
|
"has_csd": bool(cert.get("has_certificate")),
|
||||||
|
"legal_name": legal.get("name") or legal.get("legal_name"),
|
||||||
|
"tax_id": legal.get("tax_id"),
|
||||||
|
"pending_steps": org.get("pending_steps", []),
|
||||||
|
}
|
||||||
|
)
|
||||||
except FacturapiError as e:
|
except FacturapiError as e:
|
||||||
# If the org reports "not configured for Live" (401) and we have a user key,
|
result["error"] = str(e)
|
||||||
# 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
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -58,18 +58,19 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
|||||||
cur.close()
|
cur.close()
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Load sale details with items in two bulk queries (O(1) round-trips)
|
# Load sale details with items
|
||||||
cur.execute("""
|
sales = []
|
||||||
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
for sale_id in sale_ids:
|
||||||
payment_method, subtotal, discount_total, tax_total, total,
|
cur.execute("""
|
||||||
metodo_pago_sat, forma_pago_sat, status, created_at
|
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
||||||
FROM sales
|
payment_method, subtotal, discount_total, tax_total, total,
|
||||||
WHERE id = ANY(%s)
|
metodo_pago_sat, forma_pago_sat, status, created_at
|
||||||
ORDER BY created_at ASC
|
FROM sales WHERE id = %s
|
||||||
""", (sale_ids,))
|
""", (sale_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
|
||||||
sales = {}
|
|
||||||
for row in cur.fetchall():
|
|
||||||
sale = {
|
sale = {
|
||||||
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
||||||
'employee_id': row[3], 'sale_type': row[4],
|
'employee_id': row[3], 'sale_type': row[4],
|
||||||
@@ -84,37 +85,33 @@ def get_eligible_sales(conn, year, month, branch_id=None, max_total=2000):
|
|||||||
'created_at': str(row[13]),
|
'created_at': str(row[13]),
|
||||||
'items': [],
|
'items': [],
|
||||||
}
|
}
|
||||||
sales[row[0]] = sale
|
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id, sale_id, inventory_id, part_number, name, quantity, unit_price,
|
SELECT id, inventory_id, part_number, name, quantity, unit_price,
|
||||||
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
||||||
subtotal, clave_prod_serv, clave_unidad
|
subtotal, clave_prod_serv, clave_unidad
|
||||||
FROM sale_items
|
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||||
WHERE sale_id = ANY(%s)
|
""", (sale_id,))
|
||||||
ORDER BY sale_id, id
|
|
||||||
""", (sale_ids,))
|
|
||||||
|
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
sale = sales.get(r[1])
|
sale['items'].append({
|
||||||
if not sale:
|
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
||||||
continue
|
'name': r[3], 'quantity': r[4],
|
||||||
sale['items'].append({
|
'unit_price': float(r[5]) if r[5] else 0,
|
||||||
'id': r[0], 'inventory_id': r[2], 'part_number': r[3],
|
'unit_cost': float(r[6]) if r[6] else 0,
|
||||||
'name': r[4], 'quantity': r[5],
|
'discount_pct': float(r[7]) if r[7] else 0,
|
||||||
'unit_price': float(r[6]) if r[6] else 0,
|
'discount_amount': float(r[8]) if r[8] else 0,
|
||||||
'unit_cost': float(r[7]) if r[7] else 0,
|
'tax_rate': float(r[9]) if r[9] else 0.16,
|
||||||
'discount_pct': float(r[8]) if r[8] else 0,
|
'tax_amount': float(r[10]) if r[10] else 0,
|
||||||
'discount_amount': float(r[9]) if r[9] else 0,
|
'subtotal': float(r[11]) if r[11] else 0,
|
||||||
'tax_rate': float(r[10]) if r[10] else 0.16,
|
'clave_prod_serv': r[12] or '25174800',
|
||||||
'tax_amount': float(r[11]) if r[11] else 0,
|
'clave_unidad': r[13] or 'H87',
|
||||||
'subtotal': float(r[12]) if r[12] else 0,
|
})
|
||||||
'clave_prod_serv': r[13] or '25174800',
|
|
||||||
'clave_unidad': r[14] or 'H87',
|
sales.append(sale)
|
||||||
})
|
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
return list(sales.values())
|
return sales
|
||||||
|
|
||||||
|
|
||||||
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
def generate_global_invoice(conn, tenant_config, year, month, branch_id=None,
|
||||||
|
|||||||
@@ -122,23 +122,16 @@ def record_operation(conn, inventory_id, branch_id, operation_type, quantity,
|
|||||||
))
|
))
|
||||||
op_id = cur.fetchone()[0]
|
op_id = cur.fetchone()[0]
|
||||||
|
|
||||||
# Queue ML stock sync if this product has an active ML listing.
|
# Queue ML stock sync if this product has an active ML listing
|
||||||
# Skip gracefully if the marketplace tables do not exist in this tenant.
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT COUNT(*) FROM information_schema.tables
|
INSERT INTO meli_sync_queue (inventory_id, action, status)
|
||||||
WHERE table_schema = 'public'
|
SELECT %s, 'stock_update', 'pending'
|
||||||
AND table_name IN ('marketplace_listings', 'meli_sync_queue')
|
WHERE EXISTS (
|
||||||
""")
|
SELECT 1 FROM marketplace_listings
|
||||||
if cur.fetchone()[0] >= 2:
|
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
|
||||||
cur.execute("""
|
)
|
||||||
INSERT INTO meli_sync_queue (inventory_id, action, status)
|
ON CONFLICT DO NOTHING
|
||||||
SELECT %s, 'stock_update', 'pending'
|
""", (inventory_id, inventory_id))
|
||||||
WHERE EXISTS (
|
|
||||||
SELECT 1 FROM marketplace_listings
|
|
||||||
WHERE inventory_id = %s AND channel = 'mercadolibre' AND is_active = true
|
|
||||||
)
|
|
||||||
ON CONFLICT DO NOTHING
|
|
||||||
""", (inventory_id, inventory_id))
|
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
return op_id
|
return op_id
|
||||||
@@ -237,54 +230,6 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
|
|||||||
return op_id
|
return op_id
|
||||||
|
|
||||||
|
|
||||||
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
|
|
||||||
cost_at_time=None, remaining_stock=None):
|
|
||||||
"""Reserve stock for a counter remission note (negative quantity).
|
|
||||||
|
|
||||||
The reserved quantity is deducted from available stock until the note is
|
|
||||||
paid or cancelled.
|
|
||||||
"""
|
|
||||||
op_id = record_operation(
|
|
||||||
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
|
|
||||||
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
|
|
||||||
notes='Reserva por nota de remision'
|
|
||||||
)
|
|
||||||
invalidate_stock(inventory_id, branch_id)
|
|
||||||
invalidate_stock(inventory_id, None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
|
|
||||||
if remaining <= 0:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
|
|
||||||
inv_row = cur.fetchone()
|
|
||||||
cur.close()
|
|
||||||
if inv_row:
|
|
||||||
from services.push_service import notify_owner
|
|
||||||
notify_owner(
|
|
||||||
conn,
|
|
||||||
'Stock en Cero',
|
|
||||||
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
|
|
||||||
'/pos'
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return op_id
|
|
||||||
|
|
||||||
|
|
||||||
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
|
||||||
"""Release a previous reservation (positive quantity)."""
|
|
||||||
result = record_operation(
|
|
||||||
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
|
|
||||||
reference_id=sale_id, reference_type='sale',
|
|
||||||
notes=notes or 'Liberacion de reserva de nota de remision'
|
|
||||||
)
|
|
||||||
invalidate_stock(inventory_id, branch_id)
|
|
||||||
invalidate_stock(inventory_id, None)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||||
"""Record a customer return (positive quantity)."""
|
"""Record a customer return (positive quantity)."""
|
||||||
result = record_operation(
|
result = record_operation(
|
||||||
@@ -429,7 +374,7 @@ def get_movement_history(conn, inventory_id, limit=50):
|
|||||||
history.append({
|
history.append({
|
||||||
'id': r[0], 'type': r[1], 'quantity': r[2],
|
'id': r[0], 'type': r[1], 'quantity': r[2],
|
||||||
'cost': float(r[3]) if r[3] else None,
|
'cost': float(r[3]) if r[3] else None,
|
||||||
'notes': r[4], 'date': r[5].isoformat() if r[5] else None,
|
'notes': r[4], 'date': str(r[5]),
|
||||||
'employee': r[6], 'branch_id': r[7]
|
'employee': r[6], 'branch_id': r[7]
|
||||||
})
|
})
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
@@ -35,93 +35,194 @@ def get_compat_source(tenant_id):
|
|||||||
return 'both'
|
return 'both'
|
||||||
|
|
||||||
|
|
||||||
def _norm_pn(pn):
|
|
||||||
"""Normalize a part number: uppercase, strip spaces and separators."""
|
|
||||||
if not pn:
|
|
||||||
return ''
|
|
||||||
return (str(pn).replace(' ', '').replace('-', '').replace('/', '')
|
|
||||||
.replace('.', '').replace('_', '').upper())
|
|
||||||
|
|
||||||
|
|
||||||
def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, part_number,
|
def auto_match_vehicle_compatibility(master_conn, tenant_conn, inventory_id, part_number,
|
||||||
brand=None, name=None):
|
brand=None, name=None):
|
||||||
"""Find vehicle compatibility for an inventory item via the supplier catalog.
|
"""Find vehicle compatibility for an inventory item by part_number.
|
||||||
|
|
||||||
Legacy note: the original implementation queried the removed TecDoc tables
|
Searches (in order of precision):
|
||||||
(parts / aftermarket_parts / part_cross_references / vehicle_parts) and
|
1. parts.oem_part_number exact normalized
|
||||||
always failed. This version uses the LIVE master tables:
|
2. aftermarket_parts.part_number exact normalized
|
||||||
1. supplier_catalog.sku exact-normalized
|
3. part_cross_references.cross_reference_number exact normalized
|
||||||
2. supplier_catalog_interchange.part_number exact-normalized (cross-refs)
|
4. parts.oem_part_number partial (ILIKE) — strips separators too
|
||||||
3. prefix fallback on catalog SKU (only for >=5 char numbers, few matches)
|
5. aftermarket_parts.part_number partial (ILIKE)
|
||||||
then collects DISTINCT model_year_engine_id from supplier_catalog_compat
|
6. part_cross_references.cross_reference_number partial (ILIKE)
|
||||||
and inserts them into the tenant inventory_vehicle_compat table.
|
7. parts.name_part / parts.name_es by inventory item name (ILIKE)
|
||||||
|
8. parts.name_part / parts.name_es by inventory item name (tsvector)
|
||||||
|
|
||||||
Returns dict {'matched': bool, 'matches': [...], 'myes': [...], 'inserted': int}
|
If a brand hint is provided, results are preferentially filtered to
|
||||||
(same shape as the legacy implementation).
|
vehicles of that brand.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {'matched': bool, 'matches': [...], 'myes': [...]}
|
||||||
"""
|
"""
|
||||||
cur = master_conn.cursor()
|
cur = master_conn.cursor()
|
||||||
clean_pn = _norm_pn(part_number)
|
oem_ids = set()
|
||||||
catalog_ids = set()
|
|
||||||
|
|
||||||
|
# Normalization helpers
|
||||||
|
clean_pn = (part_number or '').replace(' ', '').upper()
|
||||||
|
no_seps = clean_pn.replace('-', '').replace('/', '').replace('.', '').replace('_', '')
|
||||||
|
name_q = (name or '').strip()
|
||||||
|
brand_hint = (brand or '').strip().upper()
|
||||||
|
|
||||||
|
# ── 1. Exact normalized ──────────────────────────────────────────────
|
||||||
if clean_pn:
|
if clean_pn:
|
||||||
# 1. Exact match on catalog SKU (with and without inner spaces)
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT id FROM supplier_catalog
|
SELECT id_part FROM parts
|
||||||
WHERE REPLACE(UPPER(sku), '-', '') = %s
|
WHERE REPLACE(UPPER(oem_part_number), ' ', '') = %s
|
||||||
OR REPLACE(UPPER(REPLACE(sku, ' ', '')), '-', '') = %s
|
""", (clean_pn,))
|
||||||
LIMIT 50
|
|
||||||
""", (clean_pn, clean_pn))
|
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
catalog_ids.add(r[0])
|
oem_ids.add(r[0])
|
||||||
|
|
||||||
# 2. Exact match on interchange part numbers (cross references)
|
if clean_pn and not oem_ids:
|
||||||
if not catalog_ids:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT DISTINCT catalog_id FROM supplier_catalog_interchange
|
|
||||||
WHERE REPLACE(UPPER(REPLACE(part_number, '-', '')), ' ', '') = %s
|
|
||||||
LIMIT 50
|
|
||||||
""", (clean_pn,))
|
|
||||||
for r in cur.fetchall():
|
|
||||||
catalog_ids.add(r[0])
|
|
||||||
|
|
||||||
# 3. Prefix fallback for longer numbers when exact found little
|
|
||||||
if len(clean_pn) >= 5 and len(catalog_ids) < 3:
|
|
||||||
cur.execute("""
|
|
||||||
SELECT id FROM supplier_catalog
|
|
||||||
WHERE REPLACE(UPPER(sku), '-', '') LIKE %s
|
|
||||||
LIMIT 10
|
|
||||||
""", (clean_pn[:8] + '%',))
|
|
||||||
for r in cur.fetchall():
|
|
||||||
catalog_ids.add(r[0])
|
|
||||||
|
|
||||||
mye_ids = []
|
|
||||||
if catalog_ids:
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT DISTINCT model_year_engine_id FROM supplier_catalog_compat
|
SELECT DISTINCT oem_part_id FROM aftermarket_parts
|
||||||
WHERE catalog_id = ANY(%s) AND model_year_engine_id IS NOT NULL
|
WHERE REPLACE(UPPER(part_number), ' ', '') = %s
|
||||||
|
""", (clean_pn,))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
if r[0]:
|
||||||
|
oem_ids.add(r[0])
|
||||||
|
|
||||||
|
if clean_pn and not oem_ids:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT part_id FROM part_cross_references
|
||||||
|
WHERE REPLACE(UPPER(cross_reference_number), ' ', '') = %s
|
||||||
|
""", (clean_pn,))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
if r[0]:
|
||||||
|
oem_ids.add(r[0])
|
||||||
|
|
||||||
|
# ── 2. Partial / separator-stripped ──────────────────────────────────
|
||||||
|
if clean_pn and len(clean_pn) >= 3 and len(oem_ids) < 10:
|
||||||
|
# OEM part number partial
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id_part FROM parts
|
||||||
|
WHERE REPLACE(UPPER(oem_part_number), ' ', '') LIKE %s
|
||||||
|
AND id_part NOT IN (SELECT unnest(%s::int[]))
|
||||||
|
LIMIT 20
|
||||||
|
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
oem_ids.add(r[0])
|
||||||
|
|
||||||
|
# Aftermarket partial
|
||||||
|
if len(oem_ids) < 10:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT oem_part_id FROM aftermarket_parts
|
||||||
|
WHERE REPLACE(UPPER(part_number), ' ', '') LIKE %s
|
||||||
|
AND oem_part_id NOT IN (SELECT unnest(%s::int[]))
|
||||||
|
LIMIT 20
|
||||||
|
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
if r[0]:
|
||||||
|
oem_ids.add(r[0])
|
||||||
|
|
||||||
|
# Cross-reference partial
|
||||||
|
if len(oem_ids) < 10:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT part_id FROM part_cross_references
|
||||||
|
WHERE REPLACE(UPPER(cross_reference_number), ' ', '') LIKE %s
|
||||||
|
AND part_id NOT IN (SELECT unnest(%s::int[]))
|
||||||
|
LIMIT 20
|
||||||
|
""", (f'%{clean_pn}%', list(oem_ids) or [0]))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
if r[0]:
|
||||||
|
oem_ids.add(r[0])
|
||||||
|
|
||||||
|
# Separator-stripped fallback (e.g. KYB-343412 → KYB343412)
|
||||||
|
if len(no_seps) >= 4 and len(oem_ids) < 10:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id_part FROM parts
|
||||||
|
WHERE REPLACE(REPLACE(REPLACE(REPLACE(UPPER(oem_part_number), ' ', ''), '-', ''), '/', ''), '.', '') = %s
|
||||||
|
AND id_part NOT IN (SELECT unnest(%s::int[]))
|
||||||
|
LIMIT 10
|
||||||
|
""", (no_seps, list(oem_ids) or [0]))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
oem_ids.add(r[0])
|
||||||
|
|
||||||
|
# ── 3. Search by item name (when part_number yields nothing) ─────────
|
||||||
|
if not oem_ids and name_q and len(name_q) >= 3:
|
||||||
|
# Name-based ILIKE search on parts
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id_part FROM parts
|
||||||
|
WHERE UPPER(name_part) LIKE %s OR UPPER(COALESCE(name_es, '')) LIKE %s
|
||||||
|
LIMIT 20
|
||||||
|
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
oem_ids.add(r[0])
|
||||||
|
|
||||||
|
# Name-based ILIKE search on aftermarket_parts
|
||||||
|
if len(oem_ids) < 20:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT oem_part_id FROM aftermarket_parts
|
||||||
|
WHERE UPPER(COALESCE(name_aftermarket_parts, '')) LIKE %s
|
||||||
|
OR UPPER(COALESCE(name_es, '')) LIKE %s
|
||||||
|
LIMIT 20
|
||||||
|
""", (f'%{name_q.upper()}%', f'%{name_q.upper()}%'))
|
||||||
|
for r in cur.fetchall():
|
||||||
|
if r[0]:
|
||||||
|
oem_ids.add(r[0])
|
||||||
|
|
||||||
|
if not oem_ids:
|
||||||
|
cur.close()
|
||||||
|
return {'matched': False, 'matches': [], 'myes': []}
|
||||||
|
|
||||||
|
# ── Resolve MYEs ─────────────────────────────────────────────────────
|
||||||
|
oem_ids = list(oem_ids)
|
||||||
|
|
||||||
|
if brand_hint and brand_hint != 'GENERAL':
|
||||||
|
# Brand-aware: only MYEs for vehicles of the hinted brand
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT vp.model_year_engine_id
|
||||||
|
FROM vehicle_parts vp
|
||||||
|
JOIN model_year_engine mye ON mye.id_mye = vp.model_year_engine_id
|
||||||
|
JOIN models m ON m.id_model = mye.model_id
|
||||||
|
JOIN brands b ON b.id_brand = m.brand_id
|
||||||
|
WHERE vp.part_id = ANY(%s)
|
||||||
|
AND b.name_brand = %s
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
""", (list(catalog_ids),))
|
""", (oem_ids, brand_hint))
|
||||||
mye_ids = [r[0] for r in cur.fetchall()]
|
mye_ids = [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# Fallback: if brand filter yields nothing, the brand hint may be an
|
||||||
|
# aftermarket supplier (e.g. Motorcraft, NGK, Bosch) rather than an
|
||||||
|
# OEM vehicle brand. Search without brand filter.
|
||||||
|
if not mye_ids:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT model_year_engine_id
|
||||||
|
FROM vehicle_parts
|
||||||
|
WHERE part_id = ANY(%s)
|
||||||
|
LIMIT 500
|
||||||
|
""", (oem_ids,))
|
||||||
|
mye_ids = [r[0] for r in cur.fetchall()]
|
||||||
|
else:
|
||||||
|
# No brand hint — return all MYEs for these parts
|
||||||
|
cur.execute("""
|
||||||
|
SELECT DISTINCT model_year_engine_id
|
||||||
|
FROM vehicle_parts
|
||||||
|
WHERE part_id = ANY(%s)
|
||||||
|
LIMIT 500
|
||||||
|
""", (oem_ids,))
|
||||||
|
mye_ids = [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
||||||
|
# ── Insert into tenant table ─────────────────────────────────────────
|
||||||
inserted = 0
|
inserted = 0
|
||||||
if mye_ids:
|
cur2 = tenant_conn.cursor()
|
||||||
cur2 = tenant_conn.cursor()
|
for mye_id in mye_ids:
|
||||||
for mye_id in mye_ids:
|
cur2.execute("""
|
||||||
cur2.execute("""
|
INSERT INTO inventory_vehicle_compat
|
||||||
INSERT INTO inventory_vehicle_compat
|
(inventory_id, model_year_engine_id, source, confidence)
|
||||||
(inventory_id, model_year_engine_id, source, confidence)
|
VALUES (%s, %s, 'auto_match', 1.0)
|
||||||
VALUES (%s, %s, 'supplier_catalog', 0.95)
|
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
|
||||||
ON CONFLICT (inventory_id, model_year_engine_id) DO NOTHING
|
""", (inventory_id, mye_id))
|
||||||
""", (inventory_id, mye_id))
|
if cur2.rowcount > 0:
|
||||||
if cur2.rowcount > 0:
|
inserted += 1
|
||||||
inserted += 1
|
tenant_conn.commit()
|
||||||
tenant_conn.commit()
|
cur2.close()
|
||||||
cur2.close()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'matched': len(mye_ids) > 0,
|
'matched': len(mye_ids) > 0,
|
||||||
'matches': sorted(catalog_ids),
|
'matches': oem_ids,
|
||||||
'myes': mye_ids,
|
'myes': mye_ids,
|
||||||
'inserted': inserted,
|
'inserted': inserted,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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