diff --git a/dashboard/catalog-public.html b/dashboard/catalog-public.html index 0aa625c..19336b3 100644 --- a/dashboard/catalog-public.html +++ b/dashboard/catalog-public.html @@ -12,8 +12,10 @@ - + - -
-
-

Nexus Autoparts

-

Tu conexion directa con las partes que necesitas

- Ver Catalogo - Acceder al POS -
-
-
1.5M+
-
Partes
+ +
+ +
+

Nexus Autoparts

+

Todo lo que necesita una refaccionaria en una sola plataforma. POS, inventario, catalogo TecDoc, facturacion, marketplace B2B e inteligencia artificial.

+
+ +
+ +
+
+
1.5M+
+
Partes OEM
-
-
36
+
+
304K+
+
Aftermarket
+
+
+
15.8M+
+
Cross-Refs
+
+
+
36
Marcas
-
-
85K+
-
Vehiculos
-
- -
+ +
+
+ product.tsx + +
+
-

Por que Nexus

-
-
-
🔍
-

Catalogo 1.5M+ Partes

-

Base de datos TecDoc completa con partes OEM y aftermarket para vehiculos vendidos en Mexico, USA y Canada.

+

El Producto

+

El unico sistema que combina POS + Inventario + CFDI + Catalogo + Marketplace + IA en una sola plataforma

+
+ +
+

Ventas & POS

+
    +
  • Punto de venta completo con F-keys y escaner
  • +
  • Caja registradora multi-caja, cortes X/Z
  • +
  • Cotizaciones, apartados, devoluciones
  • +
  • Clientes con credito y 3 niveles de precio
  • +
  • Facturacion CFDI 4.0 (Ingreso, Egreso, Pago)
  • +
  • Impresion termica ESC/POS
  • +
  • Contabilidad con polizas automaticas
  • +
  • Reportes: ventas, ABC, cortes, utilidad
  • +
-
-
🚗
-

Navegacion por Vehiculo

-

Encuentra la parte exacta navegando por Marca, Modelo, Ano, Motor y Categoria. Sin adivinar numeros de parte.

+ +
+

Catalogo & Inventario

+
    +
  • Catalogo TecDoc: 1.5M+ partes OEM
  • +
  • 304K+ partes aftermarket con cross-refs
  • +
  • Navegacion: Ano > Marca > Modelo > Motor
  • +
  • VIN decoder + busqueda por placas MX
  • +
  • Inventario append-only, toma fisica
  • +
  • Imagenes de productos con upload masivo
  • +
  • Traduccion automatica EN > ES (326 partes)
  • +
  • Marketplace B2B: bodegas ↔ talleres
  • +
-
-
🔄
-

Cross-References OEM / Aftermarket

-

Ve las equivalencias entre partes originales y alternativas de fabricantes como Bosch, Denso, Monroe, Gates y mas.

+ +
+

IA & Plataforma

+
    +
  • Chatbot IA: diagnostico, cotizacion inteligente
  • +
  • Entrada por voz (Web Speech API)
  • +
  • Reconocimiento de partes por foto (Vision AI)
  • +
  • WhatsApp Business integrado (envio de cotizaciones)
  • +
  • Gestion de flotillas y mantenimiento
  • +
  • PWA + App Android, modo kiosko
  • +
  • Offline-first con sync automatico
  • +
  • 2 temas, 2 idiomas (ES/EN), 2 monedas (MXN/USD)
  • +
-
-
🌎
-

Multi-Marca

-

Toyota, Nissan, Ford, VW, Honda, Chevrolet, Hyundai, Kia, Mazda, BMW, Mercedes-Benz, Renault y mas.

+ +
+ +
+
+ 🖥 +
A partir del plan Pro: servidor en rack 3D personalizado — Mini PC + switch + AP + UPS.
Todo incluido por $2,000 MXN/mes. Solo conectar y empezar a vender.
- + +
+
+ workflow.tsx + +
-

Como Funciona

-
-
+

Como Funciona

+
+
1
-

Selecciona tu Vehiculo

-

Elige marca, modelo, ano y motor para filtrar las partes compatibles.

+

Instalamos el sistema

+

Mini PC con el POS preinstalado. Solo conectar a internet y empezar a vender.

-
+
2
-

Encuentra la Parte

-

Navega por categorias o busca directamente por numero de parte OEM.

+

Catalogo + Inventario

+

Tu inventario conectado al catalogo TecDoc. Busca por vehiculo, parte o VIN.

-
+
3
-

Contacta un Distribuidor

-

Consulta disponibilidad y precios con distribuidores de la red Nexus.

+

Vende y Crece

+

POS, facturacion, marketplace B2B, WhatsApp e IA — todo desde un solo lugar.

- -
+ +
+
+ competitive.tsx + +
+
-

Marcas Disponibles

-
- +

Por que Nexus

+

Lo que nos hace diferentes de Pitz, Meru, Jetz y los POS tradicionales

+
+
+
🔍
+

Catalogo TecDoc

+

1.5M+ partes con cross-references. Nadie mas lo tiene en MX.

+
+
+
💰
+

POS + CFDI integrado

+

No necesitas un sistema aparte para facturar. Todo en uno.

+
+
+
🤖
+

IA + WhatsApp

+

Chatbot que diagnostica, cotiza y atiende por WhatsApp.

+
+
+
🚀
+

Marketplace B2B

+

Conecta bodegas con talleres. Mas ventas, menos llamadas.

+
+
+
🖥
+

Hardware incluido

+

Rack 3D con servidor. Renta todo por $2,000/mes.

+
+
+
🌐
+

Offline-first

+

Funciona sin internet. PWA + Android + modo kiosko.

+
- + +
+
+
36 Marcas
+
+
+
+
+
+ + +
+
+ pricing.tsx + +
+
+
+

Planes

+

Software desde $999/mes. Hardware incluido a partir del plan Pro.

+
+
+

Basico

+
$999
+
MXN / mes — solo software
+
    +
  • POS + Inventario
  • +
  • Catalogo TecDoc
  • +
  • CFDI 4.0
  • +
  • Reportes basicos
  • +
+
+ +
+

Enterprise

+
$3,999
+
MXN / mes — hardware incluido
+
    +
  • Todo Pro +
  • +
  • Flotillas + Multi-bodega
  • +
  • API dedicada
  • +
  • Soporte prioritario
  • +
  • 🖥 Hardware dedicado por sucursal
  • +
+
+
+
+
+ + +
+
+ contact.tsx + +
-

Contacto

-
-
-
+

Contacto

+
+ -
-
📱
+
+
📱

WhatsApp

- Enviar Mensaje + Enviar Mensaje
-
-
📍
+
+
📍

Ubicaciones

Tijuana, B.C.

Guadalajara, Jal.

+

6 ciudades en expansion

- +
-

© 2026 Nexus Autoparts. Todos los derechos reservados.

+
- - - + diff --git a/dashboard/landing.js b/dashboard/landing.js new file mode 100644 index 0000000..987ab63 --- /dev/null +++ b/dashboard/landing.js @@ -0,0 +1,394 @@ +/** + * landing.js — Pixel-Perfect inspired interactions for Nexus Autoparts + * + * Features: + * - Animated canvas grid background (grid lines, stars at intersections, glowing tiles) + * - Scroll-reveal via IntersectionObserver + * - Counter animation on stat cards + * - Typewriter effect + * - Infinite brand marquee loader + * - Theme toggle with icon swap + */ +(function () { + 'use strict'; + + // ═══════════════════════════════════════════════════════════════════════════ + // THEME TOGGLE + // ═══════════════════════════════════════════════════════════════════════════ + + var themeIcon = document.getElementById('themeIcon'); + + function updateThemeIcon() { + var theme = document.documentElement.getAttribute('data-theme'); + if (themeIcon) themeIcon.innerHTML = theme === 'industrial' ? '☾' : '☀'; + } + + window.toggleTheme = function () { + var html = document.documentElement; + var current = html.getAttribute('data-theme'); + var next = current === 'industrial' ? 'modern' : 'industrial'; + html.setAttribute('data-theme', next); + localStorage.setItem('nexus-theme', next); + updateThemeIcon(); + // Canvas will pick up new CSS vars on next frame + }; + + updateThemeIcon(); + + // ═══════════════════════════════════════════════════════════════════════════ + // CANVAS ANIMATED GRID + // ═══════════════════════════════════════════════════════════════════════════ + + var canvas = document.getElementById('heroCanvas'); + if (canvas) { + var ctx = canvas.getContext('2d'); + var CELL = 80; + var MAX_GLOWS = 6; + var glows = []; + var frame = 0; + + function resizeCanvas() { + var hero = canvas.parentElement; + canvas.width = hero.offsetWidth; + canvas.height = hero.offsetHeight; + } + + function getColor(varName) { + return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); + } + + function drawStar(x, y, outer, inner, color) { + ctx.beginPath(); + for (var i = 0; i < 4; i++) { + var angle = (Math.PI / 2) * i; + ctx.lineTo(x + Math.cos(angle) * outer, y + Math.sin(angle) * outer); + ctx.lineTo(x + Math.cos(angle + Math.PI / 4) * inner, y + Math.sin(angle + Math.PI / 4) * inner); + } + ctx.closePath(); + ctx.fillStyle = color; + ctx.fill(); + } + + function spawnGlow() { + var cols = Math.floor(canvas.width / CELL); + var rows = Math.floor(canvas.height / CELL); + glows.push({ + col: Math.floor(Math.random() * cols), + row: Math.floor(Math.random() * rows), + life: 0, + maxLife: 90 + Math.random() * 60, // 1.5-2.5s at 60fps + }); + } + + function animateGrid() { + frame++; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + var gridColor = getColor('--canvas-grid-color') || 'rgba(255,255,255,0.06)'; + var starColor = getColor('--canvas-star-color') || 'rgba(245,166,35,0.3)'; + var glowColor = getColor('--canvas-glow-color') || 'rgba(245,166,35,0.08)'; + + var cols = Math.floor(canvas.width / CELL) + 1; + var rows = Math.floor(canvas.height / CELL) + 1; + + // Draw grid lines + ctx.strokeStyle = gridColor; + ctx.lineWidth = 1; + for (var c = 0; c <= cols; c++) { + var x = c * CELL; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, canvas.height); + ctx.stroke(); + } + for (var r = 0; r <= rows; r++) { + var y = r * CELL; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(canvas.width, y); + ctx.stroke(); + } + + // Draw stars at intersections (every other one for performance) + for (c = 0; c <= cols; c += 2) { + for (r = 0; r <= rows; r += 2) { + drawStar(c * CELL, r * CELL, 6, 3, starColor); + } + } + + // Spawn glowing tiles + if (glows.length < MAX_GLOWS && Math.random() < 0.03) { + spawnGlow(); + } + + // Draw glowing tiles + for (var i = glows.length - 1; i >= 0; i--) { + var g = glows[i]; + g.life++; + var progress = g.life / g.maxLife; + var opacity = progress < 0.5 ? progress * 2 : (1 - progress) * 2; + + ctx.fillStyle = glowColor; + ctx.globalAlpha = opacity; + ctx.fillRect(g.col * CELL + 1, g.row * CELL + 1, CELL - 2, CELL - 2); + + // Subtle shadow glow + ctx.shadowColor = glowColor; + ctx.shadowBlur = 20; + ctx.fillRect(g.col * CELL + 1, g.row * CELL + 1, CELL - 2, CELL - 2); + ctx.shadowBlur = 0; + + ctx.globalAlpha = 1; + + if (g.life >= g.maxLife) glows.splice(i, 1); + } + + requestAnimationFrame(animateGrid); + } + + resizeCanvas(); + animateGrid(); + + var resizeTimeout; + window.addEventListener('resize', function () { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(resizeCanvas, 150); + }); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // SCROLL REVEAL — IntersectionObserver + // ═══════════════════════════════════════════════════════════════════════════ + + var revealEls = document.querySelectorAll('.nx-reveal, .nx-reveal-scale'); + if ('IntersectionObserver' in window) { + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + entry.target.classList.add('is-visible'); + observer.unobserve(entry.target); + } + }); + }, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' }); + + revealEls.forEach(function (el) { + observer.observe(el); + }); + } else { + // Fallback: show all immediately + revealEls.forEach(function (el) { el.classList.add('is-visible'); }); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // COUNTER ANIMATION — Stat cards + // ═══════════════════════════════════════════════════════════════════════════ + + function animateCounter(el, target, format, suffix, duration) { + var start = 0; + var startTime = null; + + function step(timestamp) { + if (!startTime) startTime = timestamp; + var progress = Math.min((timestamp - startTime) / duration, 1); + // Ease out cubic + var eased = 1 - Math.pow(1 - progress, 3); + var current = Math.floor(eased * target); + + if (format.indexOf('M') !== -1) { + // e.g. "1.5M" or "15.8M" + var decimals = target >= 10e6 ? 1 : 1; + el.textContent = (current / 1e6).toFixed(decimals) + 'M' + suffix; + } else if (format.indexOf('K') !== -1) { + // e.g. "85K" or "304K" + el.textContent = Math.floor(current / 1e3) + 'K' + suffix; + } else { + el.textContent = current + suffix; + } + + if (progress < 1) requestAnimationFrame(step); + } + requestAnimationFrame(step); + } + + // Trigger counters when stats become visible + var statCards = document.querySelectorAll('.stat-card'); + if (statCards.length && 'IntersectionObserver' in window) { + var statsObserver = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + var numEl = entry.target.querySelector('.number'); + if (numEl && !numEl._animated) { + numEl._animated = true; + var target = parseInt(numEl.getAttribute('data-target'), 10) || 0; + var suffix = numEl.getAttribute('data-suffix') || ''; + var format = numEl.getAttribute('data-format') || 'num'; + animateCounter(numEl, target, format, suffix, 2000); + } + statsObserver.unobserve(entry.target); + } + }); + }, { threshold: 0.5 }); + + statCards.forEach(function (card) { statsObserver.observe(card); }); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // TYPEWRITER EFFECT + // ═══════════════════════════════════════════════════════════════════════════ + + var typewriterEl = document.getElementById('typewriterText'); + if (typewriterEl) { + var phrases = [ + 'POS + Inventario + CFDI 4.0 + Contabilidad', + 'Catalogo TecDoc: 1.5M+ partes, 304K aftermarket', + '15.8M cross-references OEM ↔ aftermarket', + 'Chatbot IA con voz, foto y diagnostico', + 'WhatsApp Business integrado', + 'Busca por VIN, placas o numero de parte', + 'Marketplace B2B: bodegas ↔ talleres', + 'PWA + Android + modo kiosko + offline', + ]; + var phraseIdx = 0; + var charIdx = 0; + var isDeleting = false; + var typingSpeed = 50; + + function typeStep() { + var current = phrases[phraseIdx]; + if (!isDeleting) { + typewriterEl.textContent = current.substring(0, charIdx + 1); + charIdx++; + if (charIdx >= current.length) { + isDeleting = true; + setTimeout(typeStep, 2000); // Pause before deleting + return; + } + setTimeout(typeStep, typingSpeed); + } else { + typewriterEl.textContent = current.substring(0, charIdx); + charIdx--; + if (charIdx < 0) { + isDeleting = false; + charIdx = 0; + phraseIdx = (phraseIdx + 1) % phrases.length; + setTimeout(typeStep, 400); + return; + } + setTimeout(typeStep, 30); + } + } + + // Start after hero reveals + setTimeout(typeStep, 1200); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // BRAND MARQUEE — Load from API + duplicate for seamless loop + // ═══════════════════════════════════════════════════════════════════════════ + + var marqueeContainer = document.getElementById('brandsMarquee'); + + function buildMarquee(brands) { + var html = ''; + brands.forEach(function (b) { + html += '' + escHtml(b.name_brand) + ''; + }); + // Duplicate for seamless loop + marqueeContainer.innerHTML = html + html; + } + + function escHtml(s) { + var d = document.createElement('div'); + d.textContent = s || ''; + return d.innerHTML; + } + + // Fallback brands in case API fails + var fallbackBrands = [ + 'Toyota', 'Nissan', 'Ford', 'Volkswagen', 'Honda', 'Chevrolet', + 'Hyundai', 'Kia', 'Mazda', 'BMW', 'Mercedes-Benz', 'Audi', + 'Renault', 'Jeep', 'Dodge', 'Ram', 'Subaru', 'Mitsubishi', + 'Suzuki', 'Peugeot', 'Volvo', 'Fiat', 'Chrysler', 'Acura', + 'Infiniti', 'Lexus', 'Lincoln', 'Buick', 'GMC', 'Cadillac', + 'Porsche', 'Mini', 'Seat', 'Alfa Romeo', 'Land Rover', 'Jaguar' + ]; + + fetch('/api/catalog/brands') + .then(function (r) { return r.json(); }) + .then(function (brands) { + if (brands && brands.length > 0) { + buildMarquee(brands); + } else { + buildMarquee(fallbackBrands.map(function (n, i) { return { id_brand: i, name_brand: n }; })); + } + }) + .catch(function () { + buildMarquee(fallbackBrands.map(function (n, i) { return { id_brand: i, name_brand: n }; })); + }); + + // ── Live hero stats from API ── + // The landing has 4 stat cards with data-format tags that identify them. + // The counter animation runs on whatever data-target is set at observe + // time, so we update all 4 targets BEFORE the IntersectionObserver fires. + fetch('/api/catalog/stats') + .then(function (r) { return r.json(); }) + .then(function (d) { + // Maps data-format (the card's identifier) → the JSON key to pull. + var statMap = [ + { format: '1.5M', key: 'parts' }, // OEM parts + { format: '304K', key: 'aftermarket_parts' }, // Aftermarket + { format: '15.8M', key: 'cross_references' }, // Cross-refs + { format: 'num', key: 'brands' }, // Brand count + ]; + statMap.forEach(function (s) { + var el = document.querySelector('[data-format="' + s.format + '"]'); + var value = d[s.key]; + if (el && typeof value === 'number' && value > 0) { + el.setAttribute('data-target', value); + // If the animation already ran (cached, instant DOM), snap to the + // real value so users see the live number instead of the stale + // hardcoded default. + if (el._animated) { + if (s.format.indexOf('M') !== -1) { + el.textContent = (value / 1e6).toFixed(1) + 'M+'; + } else if (s.format.indexOf('K') !== -1) { + el.textContent = Math.floor(value / 1e3) + 'K+'; + } else { + el.textContent = String(value); + } + } + } + }); + }) + .catch(function () { /* fallback: hardcoded data-target values stay */ }); + + // ═══════════════════════════════════════════════════════════════════════════ + // SMOOTH SCROLL for nav links + // ═══════════════════════════════════════════════════════════════════════════ + + document.querySelectorAll('.header-nav a[href^="#"]').forEach(function (a) { + a.addEventListener('click', function (e) { + e.preventDefault(); + var target = document.querySelector(a.getAttribute('href')); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); + + // ═══════════════════════════════════════════════════════════════════════════ + // HEADER — Solid on scroll + // ═══════════════════════════════════════════════════════════════════════════ + + var header = document.querySelector('.site-header'); + if (header) { + window.addEventListener('scroll', function () { + if (window.scrollY > 80) { + header.style.background = 'var(--glass-bg-strong)'; + } else { + header.style.background = 'var(--glass-bg)'; + } + }, { passive: true }); + } + +})(); diff --git a/dashboard/server.py b/dashboard/server.py index 9802745..cde9367 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -11,8 +11,9 @@ import uuid import urllib.request from datetime import datetime, timedelta -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'pos')) +_base = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services +sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL) from config import DB_URL from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth from services.translations import translate_part_name, translate_category @@ -225,6 +226,10 @@ def public_catalog(): def catalog_public_js(): return send_from_directory('.', 'catalog-public.js') +@app.route('/landing.js') +def landing_js(): + return send_from_directory('.', 'landing.js') + @app.route('/static/') def static_files(path): return send_from_directory('static', path) @@ -372,8 +377,10 @@ NORTH_AMERICA_BRANDS = REGION_BRANDS['north-america'] @app.route('/api/catalog/brands') def api_catalog_brands(): + from services.catalog_modes import get_brands_for_mode, normalize_mode region = request.args.get('region', 'north-america') year_id = request.args.get('year_id', type=int) + mode = normalize_mode(request.args.get('mode')) session = Session() try: params = {} @@ -382,7 +389,18 @@ def api_catalog_brands(): year_filter = " AND mye.year_id = :year_id" params['year_id'] = year_id - if region == 'all': + # 'local' mode overrides the region filter — curated bodega list only. + if mode == 'local': + params['brands'] = list(get_brands_for_mode('local')) + rows = session.execute(text(""" + SELECT DISTINCT b.id_brand, b.name_brand + FROM brands b + JOIN models m ON m.brand_id = b.id_brand + JOIN model_year_engine mye ON mye.model_id = m.id_model + WHERE b.name_brand = ANY(:brands)""" + year_filter + """ + ORDER BY b.name_brand + """), params).mappings().all() + elif region == 'all': rows = session.execute(text(""" SELECT DISTINCT b.id_brand, b.name_brand FROM brands b @@ -472,11 +490,39 @@ def api_catalog_engines(): session.close() +def _nexpart_master_conn(): + """Return a raw psycopg2 connection for passing to services.catalog_service + Nexpart functions (they expect .cursor() / .commit() / .close() DBAPI). + + Uses SQLAlchemy's connection pool so we don't open a new socket per call. + """ + return engine.raw_connection() + + @app.route('/api/catalog/categories') def api_catalog_categories(): + """Categories for a vehicle. + + OEM mode: TecDoc part_categories (integer ids). + Local mode: 14 Nexpart top-level groups filtered by what has parts for + this vehicle (strings as slugs). + """ + from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) + mode = normalize_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 + + if mode == 'local': + from services.catalog_service import get_nexpart_groups_for_vehicle + conn = _nexpart_master_conn() + try: + data = get_nexpart_groups_for_vehicle(conn, mye_id) + finally: + conn.close() + return jsonify({'data': data, 'mode': 'local'}) + + # OEM mode (original behavior) session = Session() try: rows = session.execute(text(""" @@ -502,10 +548,33 @@ def api_catalog_categories(): @app.route('/api/catalog/groups') def api_catalog_groups(): + """Subgroups for a vehicle + parent category. + + OEM mode: TecDoc part_groups (integer category_id). + Local mode: Nexpart subgroups within a Nexpart group (category_slug string). + """ + from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) category_id = request.args.get('category_id', type=int) - if not mye_id or not category_id: - return jsonify({'error': 'mye_id and category_id required'}), 400 + category_slug = request.args.get('category_slug') + mode = normalize_mode(request.args.get('mode')) + if not mye_id: + return jsonify({'error': 'mye_id required'}), 400 + + if mode == 'local': + if not category_slug: + return jsonify({'error': 'category_slug required for local mode'}), 400 + from services.catalog_service import get_nexpart_subgroups_for_vehicle + conn = _nexpart_master_conn() + try: + data = get_nexpart_subgroups_for_vehicle(conn, mye_id, category_slug) + finally: + conn.close() + return jsonify({'data': data, 'mode': 'local'}) + + # OEM mode + if not category_id: + return jsonify({'error': 'category_id required for oem mode'}), 400 session = Session() try: rows = session.execute(text(""" @@ -526,33 +595,275 @@ def api_catalog_groups(): session.close() -@app.route('/api/catalog/parts') -def api_catalog_parts(): +@app.route('/api/catalog/part-types') +def api_catalog_part_types(): + """Distinct part types within a vehicle+group (3rd subcategory level). + + OEM mode: distinct name_part values within a TecDoc part_group_id. + Local mode: Nexpart Part Types within a Nexpart group + subgroup. + """ + from services.translations import translate_part_name + from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) group_id = request.args.get('group_id', type=int) - if not mye_id or not group_id: - return jsonify({'error': 'mye_id and group_id required'}), 400 + group_slug = request.args.get('group_slug') + subgroup_slug = request.args.get('subgroup_slug') + mode = normalize_mode(request.args.get('mode')) + if not mye_id: + return jsonify({'error': 'mye_id required'}), 400 + + if mode == 'local': + if not group_slug or not subgroup_slug: + return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400 + from services.catalog_service import get_nexpart_part_types_for_vehicle + conn = _nexpart_master_conn() + try: + data = get_nexpart_part_types_for_vehicle(conn, mye_id, group_slug, subgroup_slug) + finally: + conn.close() + return jsonify({'data': data, 'mode': 'local'}) + + # OEM mode + if not group_id: + return jsonify({'error': 'group_id required for oem mode'}), 400 + session = Session() + try: + rows = session.execute(text(""" + SELECT + p.name_part AS slug, + COALESCE(p.name_es, p.name_part) AS display_name, + COUNT(*) AS variants, + (ARRAY_AGG(p.image_url) FILTER (WHERE p.image_url IS NOT NULL))[1] AS sample_image + FROM vehicle_parts vp + JOIN parts p ON p.id_part = vp.part_id + WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id + GROUP BY p.name_part, COALESCE(p.name_es, p.name_part) + ORDER BY variants DESC, display_name ASC + """), {'mye_id': mye_id, 'group_id': group_id}).mappings().all() + return jsonify({'data': [{ + 'slug': r['slug'], + 'name': translate_part_name(r['display_name']), + 'variant_count': r['variants'], + 'sample_image': r['sample_image'], + } for r in rows]}) + finally: + session.close() + + +@app.route('/api/catalog/shop-supplies/groups') +def api_shop_supplies_groups(): + """Public Shop Supplies tab: vehicle-independent groups.""" + from services.catalog_service import get_shop_supplies_groups + return jsonify({'data': get_shop_supplies_groups()}) + + +@app.route('/api/catalog/shop-supplies/subgroups') +def api_shop_supplies_subgroups(): + group_slug = request.args.get('group_slug') + if not group_slug: + return jsonify({'error': 'group_slug required'}), 400 + from services.catalog_service import get_shop_supplies_subgroups + conn = _nexpart_master_conn() + try: + return jsonify({'data': get_shop_supplies_subgroups(conn, group_slug)}) + finally: + conn.close() + + +@app.route('/api/catalog/shop-supplies/part-types') +def api_shop_supplies_part_types(): + group_slug = request.args.get('group_slug') + subgroup_slug = request.args.get('subgroup_slug') + if not group_slug or not subgroup_slug: + return jsonify({'error': 'group_slug and subgroup_slug required'}), 400 + from services.catalog_service import get_shop_supplies_part_types + conn = _nexpart_master_conn() + try: + return jsonify({'data': get_shop_supplies_part_types(conn, group_slug, subgroup_slug)}) + finally: + conn.close() + + +@app.route('/api/catalog/shop-supplies/parts') +def api_shop_supplies_parts(): + group_slug = request.args.get('group_slug') + subgroup_slug = request.args.get('subgroup_slug') + part_type_slug = request.args.get('part_type_slug') + page = max(1, request.args.get('page', 1, type=int)) + per_page = min(request.args.get('per_page', 30, type=int), 100) + if not group_slug or not subgroup_slug or not part_type_slug: + return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400 + from services.catalog_service import get_shop_supplies_parts + conn = _nexpart_master_conn() + try: + result = get_shop_supplies_parts( + conn, group_slug, subgroup_slug, part_type_slug, + tenant_conn=None, branch_id=None, + page=page, per_page=per_page, + ) + return jsonify(result) + finally: + conn.close() + + +@app.route('/api/catalog/parts') +def api_catalog_parts(): + from services.catalog_modes import ( + normalize_mode, + LOCAL_PRIORITY_MANUFACTURERS_TIER1, + LOCAL_PRIORITY_MANUFACTURERS_TIER2, + ) + mye_id = request.args.get('mye_id', type=int) + group_id = request.args.get('group_id', type=int) + part_type = request.args.get('part_type') + + # Nexpart navigation slugs (Local mode, chosen via new Nexpart hierarchy) + nexpart_group = request.args.get('nexpart_group') + nexpart_subgroup = request.args.get('nexpart_subgroup') + nexpart_part_type = request.args.get('nexpart_part_type') + + if not mye_id: + return jsonify({'error': 'mye_id required'}), 400 page = max(1, request.args.get('page', 1, type=int)) per_page = min(request.args.get('per_page', 30, type=int), 100) offset = (page - 1) * per_page + mode = normalize_mode(request.args.get('mode')) + + # ─── Nexpart-nav dispatch (delegates to POS service layer) ─── + # Public catalog has no tenant context — pass tenant_conn=None which + # the service gracefully handles (no local stock / price enrichment). + if mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type: + from services.catalog_service import get_parts_for_nexpart_triple + conn = _nexpart_master_conn() + try: + result = get_parts_for_nexpart_triple( + conn, mye_id, + nexpart_group, nexpart_subgroup, nexpart_part_type, + tenant_conn=None, branch_id=None, + page=page, per_page=per_page, + ) + return jsonify(result) + finally: + conn.close() + + # Below here: legacy group_id based flow (OEM or legacy Local) + if not group_id: + return jsonify({'error': 'group_id required'}), 400 + session = Session() try: + # Optional 3rd-level Part Type filter (applies to both OEM and Local modes) + pt_clause = " AND p.name_part = :part_type" if part_type else "" + + if mode == 'local': + # Aftermarket-oriented listing, prioritized + stock-aware. + count_params = {'mye_id': mye_id, 'group_id': group_id} + if part_type: + count_params['part_type'] = part_type + total = session.execute(text(""" + SELECT COUNT(*) + FROM vehicle_parts vp + JOIN parts p ON p.id_part = vp.part_id + JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part + JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id + WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), count_params).scalar() + + fetch_params = { + 'mye_id': mye_id, + 'group_id': group_id, + 'tier1': list(LOCAL_PRIORITY_MANUFACTURERS_TIER1), + 'tier2': list(LOCAL_PRIORITY_MANUFACTURERS_TIER2), + 'limit': per_page, + 'offset': offset, + } + if part_type: + fetch_params['part_type'] = part_type + + rows = session.execute(text(""" + WITH aftermarket_for_vehicle AS ( + SELECT DISTINCT + ap.id_aftermarket_parts, + ap.oem_part_id, + ap.part_number, + COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name, + m.name_manufacture, + p.oem_part_number, + COALESCE(p.name_es, p.name_part) AS oem_name, + COALESCE(p.description_es, p.description) AS oem_desc, + p.image_url AS oem_image + FROM vehicle_parts vp + JOIN parts p ON p.id_part = vp.part_id + JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part + JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id + WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause + """ + ), + stock_per_oem AS ( + SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock + FROM warehouse_inventory + WHERE stock_quantity > 0 + GROUP BY part_id + ) + SELECT afv.id_aftermarket_parts, afv.oem_part_id, afv.part_number, afv.am_name, + afv.name_manufacture, afv.oem_part_number, afv.oem_name, afv.oem_desc, afv.oem_image, + COALESCE(s.bodega_count, 0) AS bodega_count, + s.min_price AS warehouse_price, + COALESCE(s.total_stock, 0) AS warehouse_stock, + CASE + WHEN UPPER(afv.name_manufacture) = ANY(:tier1) THEN 1 + WHEN UPPER(afv.name_manufacture) = ANY(:tier2) THEN 2 + ELSE 3 + END AS tier + FROM aftermarket_for_vehicle afv + LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id + ORDER BY tier ASC, + (COALESCE(s.bodega_count, 0) > 0) DESC, + afv.name_manufacture ASC, + afv.am_name ASC + LIMIT :limit OFFSET :offset + """), fetch_params).mappings().all() + + items = [{ + 'id_part': r['oem_part_id'], + 'id_aftermarket': r['id_aftermarket_parts'], + 'oem_part_number': r['oem_part_number'], + 'part_number': r['part_number'], + 'name': translate_part_name(r['am_name'] or r['oem_name']), + 'description': r['oem_desc'], + 'image_url': r['oem_image'], + 'manufacturer': r['name_manufacture'], + 'priority_tier': r['tier'], + 'bodega_count': r['bodega_count'], + 'warehouse_stock': r['warehouse_stock'], + 'warehouse_price': float(r['warehouse_price']) if r['warehouse_price'] is not None else None, + 'in_stock_network': r['bodega_count'] > 0, + } for r in rows] + + total_pages = max(1, (total + per_page - 1) // per_page) + return jsonify({'data': items, 'mode': 'local', 'pagination': { + 'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages + }}) + + # OEM mode (original behavior) + oem_count_params = {'mye_id': mye_id, 'group_id': group_id} + oem_fetch_params = {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset} + if part_type: + oem_count_params['part_type'] = part_type + oem_fetch_params['part_type'] = part_type total = session.execute(text(""" SELECT COUNT(*) FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id - WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id - """), {'mye_id': mye_id, 'group_id': group_id}).scalar() + WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), oem_count_params).scalar() rows = session.execute(text(""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, p.description, p.description_es, p.image_url FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id - WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id + WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause + """ ORDER BY p.name_part LIMIT :limit OFFSET :offset - """), {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset}).mappings().all() + """), oem_fetch_params).mappings().all() items = [{ 'id_part': r['id_part'], @@ -563,7 +874,7 @@ def api_catalog_parts(): } for r in rows] total_pages = max(1, (total + per_page - 1) // per_page) - return jsonify({'data': items, 'pagination': { + return jsonify({'data': items, 'mode': 'oem', 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages }}) finally: @@ -660,6 +971,7 @@ def api_catalog_search(): if is_part_number: clean_q = q.replace(' ', '').upper() + # Search OEM part numbers first rows = session.execute(text(""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, p.image_url @@ -668,6 +980,28 @@ def api_catalog_search(): ORDER BY p.oem_part_number LIMIT :limit """), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all() + + # If no OEM match, search aftermarket + cross-reference numbers + if not rows: + rows = session.execute(text(""" + SELECT DISTINCT p.id_part, p.oem_part_number, p.name_part, p.name_es, + p.image_url + FROM parts p + JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part + WHERE REPLACE(UPPER(ap.part_number), ' ', '') LIKE :q + ORDER BY p.oem_part_number + LIMIT :limit + """), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all() + if not rows: + rows = session.execute(text(""" + SELECT DISTINCT p.id_part, p.oem_part_number, p.name_part, p.name_es, + p.image_url + FROM parts p + JOIN part_cross_references cr ON cr.part_id = p.id_part + WHERE REPLACE(UPPER(cr.cross_reference_number), ' ', '') LIKE :q + ORDER BY p.oem_part_number + LIMIT :limit + """), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all() else: tsquery = ' & '.join(q.split()) rows = session.execute(text(""" @@ -723,19 +1057,46 @@ def api_catalog_search(): @app.route('/api/catalog/stats') def api_catalog_stats(): + """Public stats endpoint consumed by the landing page hero cards. + + Returns live counts from the master catalog so the landing never shows + stale numbers. Counts are fast (pg_class reltuples for the big tables) + and cached in-process for 10 minutes. + """ + # In-process cache — counts barely change and are called on every + # landing pageview; no reason to hit the DB every time. + import time as _t + now = _t.time() + cache = getattr(api_catalog_stats, '_cache', None) + if cache and cache[0] > now: + return jsonify(cache[1]) + session = Session() try: + # Use reltuples for large tables (parts, aftermarket_parts, + # part_cross_references) — exact COUNT(*) on 1M+ row tables is slow + # and the landing doesn't need exact accuracy, just "big numbers". row = session.execute(text(""" SELECT (SELECT COUNT(*) FROM brands) AS brands, (SELECT COUNT(*) FROM models) AS models, (SELECT COUNT(*) FROM model_year_engine) AS vehicles, - (SELECT COUNT(*) FROM parts) AS parts + (SELECT reltuples::bigint FROM pg_class WHERE relname = 'parts') AS parts, + (SELECT reltuples::bigint FROM pg_class WHERE relname = 'aftermarket_parts') AS aftermarket_parts, + (SELECT reltuples::bigint FROM pg_class WHERE relname = 'part_cross_references') AS cross_references, + (SELECT COUNT(*) FROM manufacturers) AS manufacturers """)).mappings().first() - return jsonify({ - 'brands': row['brands'], 'models': row['models'], - 'vehicles': row['vehicles'], 'parts': row['parts'] - }) + data = { + 'brands': row['brands'] or 0, + 'models': row['models'] or 0, + 'vehicles': row['vehicles'] or 0, + 'parts': row['parts'] or 0, + 'aftermarket_parts': row['aftermarket_parts'] or 0, + 'cross_references': row['cross_references'] or 0, + 'manufacturers': row['manufacturers'] or 0, + } + api_catalog_stats._cache = (now + 600, data) + return jsonify(data) finally: session.close() @@ -3581,9 +3942,13 @@ def api_pos_search_parts(): # Store Dashboard Endpoints # ============================================================================ -# Old page routes removed (demo, bodega, pitch, login, tienda) +# Old page routes removed (demo, bodega, login, tienda) # APIs below are kept for backward compatibility +@app.route('/pitch') +def pitch_deck(): + return send_from_directory(os.path.join(_base, '..', 'pitch'), 'deck.html') + @app.route('/api/tienda/stats') def api_tienda_stats(): diff --git a/dashboard/static/css/tokens.css b/dashboard/static/css/tokens.css index cad8bfb..ea2695a 100644 --- a/dashboard/static/css/tokens.css +++ b/dashboard/static/css/tokens.css @@ -558,6 +558,153 @@ } +/* ========================================================================== + GLASSMORPHISM TOKENS + ========================================================================== */ + +[data-theme="industrial"] { + --glass-bg: rgba(26, 26, 26, 0.70); + --glass-bg-strong: rgba(26, 26, 26, 0.85); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-blur: 16px; + --glass-highlight: rgba(245, 166, 35, 0.06); + + --glow-color: rgba(245, 166, 35, 0.40); + --glow-color-soft: rgba(245, 166, 35, 0.15); + --glow-color-strong: rgba(245, 166, 35, 0.60); + + --gradient-accent: linear-gradient(135deg, #F5A623 0%, #e8951a 50%, #d4850f 100%); + --gradient-text: linear-gradient(135deg, #F5A623 0%, #FFD080 50%, #F5A623 100%); + + --canvas-grid-color: rgba(255, 255, 255, 0.06); + --canvas-star-color: rgba(245, 166, 35, 0.30); + --canvas-glow-color: rgba(245, 166, 35, 0.08); +} + +[data-theme="modern"] { + --glass-bg: rgba(248, 249, 255, 0.70); + --glass-bg-strong: rgba(248, 249, 255, 0.85); + --glass-border: rgba(26, 26, 46, 0.08); + --glass-blur: 16px; + --glass-highlight: rgba(255, 107, 53, 0.04); + + --glow-color: rgba(255, 107, 53, 0.35); + --glow-color-soft: rgba(255, 107, 53, 0.12); + --glow-color-strong: rgba(255, 107, 53, 0.55); + + --gradient-accent: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FF6B35 100%); + --gradient-text: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #e85520 100%); + + --canvas-grid-color: rgba(26, 26, 46, 0.05); + --canvas-star-color: rgba(255, 107, 53, 0.20); + --canvas-glow-color: rgba(255, 107, 53, 0.06); +} + + +/* ========================================================================== + ANIMATION KEYFRAMES + ========================================================================== */ + +@keyframes nx-fade-up { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes nx-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes nx-scale-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes nx-marquee { + 0% { transform: translateX(0); } + 100% { transform: translateX(-50%); } +} + +@keyframes nx-glow-pulse { + 0%, 100% { box-shadow: 0 0 20px var(--glow-color-soft); } + 50% { box-shadow: 0 0 40px var(--glow-color); } +} + +@keyframes nx-shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +@keyframes nx-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +@keyframes nx-border-glow { + 0%, 100% { border-color: var(--color-border); } + 50% { border-color: var(--color-border-accent); } +} + +@keyframes nx-typewriter-cursor { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + + +/* ========================================================================== + SCROLL REVEAL UTILITIES + ========================================================================== */ + +.nx-reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out); +} + +.nx-reveal.is-visible { + opacity: 1; + transform: translateY(0); +} + +.nx-reveal-scale { + opacity: 0; + transform: scale(0.95); + transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out); +} + +.nx-reveal-scale.is-visible { + opacity: 1; + transform: scale(1); +} + +/* Stagger children */ +.nx-stagger > .nx-reveal:nth-child(1) { transition-delay: 0ms; } +.nx-stagger > .nx-reveal:nth-child(2) { transition-delay: 80ms; } +.nx-stagger > .nx-reveal:nth-child(3) { transition-delay: 160ms; } +.nx-stagger > .nx-reveal:nth-child(4) { transition-delay: 240ms; } +.nx-stagger > .nx-reveal:nth-child(5) { transition-delay: 320ms; } +.nx-stagger > .nx-reveal:nth-child(6) { transition-delay: 400ms; } + + +/* ========================================================================== + GLASS UTILITIES + ========================================================================== */ + +.nx-glass { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border); +} + +.nx-glass-strong { + background: var(--glass-bg-strong); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--glass-border); +} + + /* ========================================================================== END OF TOKENS FILE nexus-autoparts-design/tokens/tokens.css diff --git a/pos/app.py b/pos/app.py index 7fb6ef3..c6ff072 100644 --- a/pos/app.py +++ b/pos/app.py @@ -54,6 +54,9 @@ def create_app(): from blueprints.marketplace_bp import marketplace_bp app.register_blueprint(marketplace_bp) + from blueprints.peer_bp import peer_bp + app.register_blueprint(peer_bp) + # Health check @app.route('/pos/health') def health(): @@ -112,6 +115,10 @@ def create_app(): def pos_fleet(): return render_template('fleet.html') + @app.route('/pos/quotations') + def pos_quotations(): + return render_template('quotations.html') + @app.route('/pos/whatsapp') def pos_whatsapp(): return render_template('whatsapp.html') diff --git a/pos/blueprints/catalog_bp.py b/pos/blueprints/catalog_bp.py index 96a48ed..071fea7 100644 --- a/pos/blueprints/catalog_bp.py +++ b/pos/blueprints/catalog_bp.py @@ -64,10 +64,12 @@ def _master_only(fn): @catalog_bp.route('/brands', methods=['GET']) @require_auth('catalog.view') def brands(): + from services.catalog_modes import normalize_mode year_id = request.args.get('year_id', type=int) + mode = normalize_mode(request.args.get('mode')) def _do(master): - data = catalog_service.get_brands(master, year_id=year_id) - return jsonify({'data': data}) + data = catalog_service.get_brands(master, year_id=year_id, mode=mode) + return jsonify({'data': data, 'mode': mode}) return _master_only(_do) @@ -125,41 +127,191 @@ def engines(): @catalog_bp.route('/categories', methods=['GET']) @require_auth('catalog.view') def categories(): + """Categories for a vehicle. + + OEM mode: TecDoc part_categories (id_part_category, name). + Local mode: 14 Nexpart top-level groups, filtered by what's available + for this vehicle. Returns 'slug' (string) instead of integer id. + """ + from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) + mode = normalize_mode(request.args.get('mode')) if not mye_id: return jsonify({'error': 'mye_id required'}), 400 def _do(master): - data = catalog_service.get_categories(master, mye_id) - return jsonify({'data': data}) + if mode == 'local': + data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id) + else: + data = catalog_service.get_categories(master, mye_id) + return jsonify({'data': data, 'mode': mode}) return _master_only(_do) @catalog_bp.route('/groups', methods=['GET']) @require_auth('catalog.view') def groups(): + """Subgroups for a vehicle + parent category. + + OEM mode: TecDoc part_groups within a TecDoc part_category (integer ids). + Local mode: Nexpart subgroups within a Nexpart group (string slugs). + """ + from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) category_id = request.args.get('category_id', type=int) - if not mye_id or not category_id: - return jsonify({'error': 'mye_id and category_id required'}), 400 + category_slug = request.args.get('category_slug') + mode = normalize_mode(request.args.get('mode')) + if not mye_id: + return jsonify({'error': 'mye_id required'}), 400 def _do(master): - data = catalog_service.get_groups(master, mye_id, category_id) - return jsonify({'data': data}) + if mode == 'local': + if not category_slug: + return jsonify({'error': 'category_slug required for local mode'}), 400 + data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug) + else: + if not category_id: + return jsonify({'error': 'category_id required for oem mode'}), 400 + data = catalog_service.get_groups(master, mye_id, category_id) + return jsonify({'data': data, 'mode': mode}) return _master_only(_do) # ─── Parts with stock enrichment (master + tenant) ─── +@catalog_bp.route('/part-types', methods=['GET']) +@require_auth('catalog.view') +def part_types(): + """Distinct part types (3rd subcategory level) for a vehicle + group/subgroup. + + OEM mode: distinct name_part values within a TecDoc part_group_id. + Local mode: Nexpart Part Types within a Nexpart group + subgroup. + """ + from services.catalog_modes import normalize_mode + mye_id = request.args.get('mye_id', type=int) + group_id = request.args.get('group_id', type=int) + group_slug = request.args.get('group_slug') # parent Nexpart group + subgroup_slug = request.args.get('subgroup_slug') # current Nexpart subgroup + mode = normalize_mode(request.args.get('mode')) + if not mye_id: + return jsonify({'error': 'mye_id required'}), 400 + def _do(master): + if mode == 'local': + if not group_slug or not subgroup_slug: + return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400 + data = catalog_service.get_nexpart_part_types_for_vehicle( + master, mye_id, group_slug, subgroup_slug + ) + else: + if not group_id: + return jsonify({'error': 'group_id required for oem mode'}), 400 + data = catalog_service.get_part_types(master, mye_id, group_id) + return jsonify({'data': data, 'mode': mode}) + return _master_only(_do) + + +@catalog_bp.route('/shop-supplies/groups', methods=['GET']) +@require_auth('catalog.view') +def shop_supplies_groups(): + """Vehicle-independent groups (Chemicals + Tires/Tools).""" + def _do(master): + data = catalog_service.get_shop_supplies_groups() + return jsonify({'data': data}) + return _master_only(_do) + + +@catalog_bp.route('/shop-supplies/subgroups', methods=['GET']) +@require_auth('catalog.view') +def shop_supplies_subgroups(): + group_slug = request.args.get('group_slug') + if not group_slug: + return jsonify({'error': 'group_slug required'}), 400 + def _do(master): + data = catalog_service.get_shop_supplies_subgroups(master, group_slug) + return jsonify({'data': data}) + return _master_only(_do) + + +@catalog_bp.route('/shop-supplies/part-types', methods=['GET']) +@require_auth('catalog.view') +def shop_supplies_part_types(): + group_slug = request.args.get('group_slug') + subgroup_slug = request.args.get('subgroup_slug') + if not group_slug or not subgroup_slug: + return jsonify({'error': 'group_slug and subgroup_slug required'}), 400 + def _do(master): + data = catalog_service.get_shop_supplies_part_types(master, group_slug, subgroup_slug) + return jsonify({'data': data}) + return _master_only(_do) + + +@catalog_bp.route('/shop-supplies/parts', methods=['GET']) +@require_auth('catalog.view') +def shop_supplies_parts(): + group_slug = request.args.get('group_slug') + subgroup_slug = request.args.get('subgroup_slug') + part_type_slug = request.args.get('part_type_slug') + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 30, type=int) + if not group_slug or not subgroup_slug or not part_type_slug: + return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400 + def _do(master, tenant, branch_id): + result = catalog_service.get_shop_supplies_parts( + master, group_slug, subgroup_slug, part_type_slug, + tenant, branch_id, page, per_page, + ) + return jsonify(result) + return _with_conns(_do) + + @catalog_bp.route('/parts', methods=['GET']) @require_auth('catalog.view') def parts(): + """Parts list for the deepest navigation level. + + Three call shapes (the endpoint chooses based on which params are present): + + A) OEM mode legacy: + ?mode=oem&mye_id=&group_id=&part_type=... + B) Local mode legacy (TecDoc-style): + ?mode=local&mye_id=&group_id=&part_type=... + C) Local mode Nexpart navigation (NEW): + ?mode=local&mye_id=&nexpart_group=&nexpart_subgroup=&nexpart_part_type= + """ + from services.catalog_modes import normalize_mode mye_id = request.args.get('mye_id', type=int) group_id = request.args.get('group_id', type=int) + part_type = request.args.get('part_type') # optional 3rd-level (legacy) + + # Nexpart navigation slugs (Local mode only) + nexpart_group = request.args.get('nexpart_group') + nexpart_subgroup = request.args.get('nexpart_subgroup') + nexpart_part_type = request.args.get('nexpart_part_type') + page = request.args.get('page', 1, type=int) per_page = request.args.get('per_page', 30, type=int) - if not mye_id or not group_id: - return jsonify({'error': 'mye_id and group_id required'}), 400 + mode = normalize_mode(request.args.get('mode')) + + if not mye_id: + return jsonify({'error': 'mye_id required'}), 400 + + use_nexpart_nav = mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type + + if not use_nexpart_nav and not group_id: + return jsonify({'error': 'group_id (or nexpart_group + subgroup + part_type) required'}), 400 + def _do(master, tenant, branch_id): - result = catalog_service.get_parts(master, mye_id, group_id, tenant, branch_id, page, per_page) + if use_nexpart_nav: + result = catalog_service.get_parts_for_nexpart_triple( + master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type, + tenant, branch_id, page, per_page, + ) + elif mode == 'local': + result = catalog_service.get_parts_local( + master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type, + ) + else: + result = catalog_service.get_parts( + master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type, + ) return jsonify(result) return _with_conns(_do) diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index 158c076..ee66d88 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -158,6 +158,61 @@ def create_employee(): return jsonify({'id': emp_id, 'message': 'Employee created'}), 201 +@config_bp.route('/employees/', methods=['PUT']) +@require_auth('config.edit') +def update_employee(emp_id): + """Update an existing employee's name, email, role, branch, discount, active status. + If PIN is provided, it gets re-hashed. Otherwise PIN stays unchanged.""" + import bcrypt + data = request.get_json() or {} + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + # Check employee exists + cur.execute("SELECT id FROM employees WHERE id = %s", (emp_id,)) + if not cur.fetchone(): + cur.close(); conn.close() + return jsonify({'error': 'Employee not found'}), 404 + + # Build SET clause dynamically — only update provided fields + updates = [] + params = [] + field_map = { + 'name': 'name', 'email': 'email', 'phone': 'phone', + 'role': 'role', 'branch_id': 'branch_id', + 'max_discount_pct': 'max_discount_pct', 'is_active': 'is_active', + } + for json_key, col in field_map.items(): + if json_key in data: + updates.append(f"{col} = %s") + params.append(data[json_key]) + + # PIN update (only if provided and non-empty) + if data.get('pin') and len(str(data['pin'])) >= 4: + pin_hash = bcrypt.hashpw(str(data['pin']).encode(), bcrypt.gensalt()).decode() + updates.append("pin = %s") + params.append(pin_hash) + updates.append("password_hash = %s") + params.append(pin_hash) + + if not updates: + cur.close(); conn.close() + return jsonify({'error': 'Nothing to update'}), 400 + + params.append(emp_id) + cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params) + + from services.audit import log_action + log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id, + new_value={k: v for k, v in data.items() if k != 'pin'}) + + conn.commit() + cur.close() + conn.close() + return jsonify({'ok': True, 'message': 'Employee updated'}) + + @config_bp.route('/currency', methods=['GET']) @require_auth() def get_currency(): @@ -244,6 +299,42 @@ def get_business(): }) +@config_bp.route('/business', methods=['PUT']) +@require_auth('config.edit') +def update_business(): + """Save tenant business info to tenant_config.""" + data = request.get_json() or {} + field_map = { + 'razon_social': 'tenant_razon_social', + 'nombre': 'tenant_nombre', + 'rfc': 'tenant_rfc', + 'regimen_fiscal': 'tenant_regimen_fiscal', + 'direccion': 'tenant_direccion', + 'telefono': 'tenant_telefono', + 'email': 'tenant_email', + # Tax params + 'tax_iva': 'tax_iva', + 'tax_ieps': 'tax_ieps', + 'invoice_serie': 'invoice_serie', + 'invoice_folio': 'invoice_folio', + 'default_currency': 'default_currency', + 'default_payment_method': 'default_payment_method', + } + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + for field, key in field_map.items(): + val = data.get(field) + if val is not None: + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES (%s, %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (key, str(val).strip())) + conn.commit() + cur.close() + conn.close() + return jsonify({'ok': True}) + + @config_bp.route('/theme', methods=['GET']) @require_auth() def get_theme(): diff --git a/pos/blueprints/marketplace_bp.py b/pos/blueprints/marketplace_bp.py index 4db5a4a..1e8258a 100644 --- a/pos/blueprints/marketplace_bp.py +++ b/pos/blueprints/marketplace_bp.py @@ -1,360 +1,336 @@ -# /home/Autopartes/pos/blueprints/marketplace_bp.py -"""Marketplace B2B: bodegas publish inventory, talleres/refaccionarias browse and order.""" +""" +Nexus Marketplace B2B — REST endpoints (Phase 1). +Routes: + Bodegas + GET /pos/api/marketplace/bodegas list verified bodegas + GET /pos/api/marketplace/bodegas/ bodega detail + + Inventory + POST /pos/api/marketplace/inventory/upload bulk CSV upload (seller) + GET /pos/api/marketplace/inventory/search browse (text/brand/city filters) + GET /pos/api/marketplace/inventory/part/ bodegas stocking this part + + Purchase Orders + POST /pos/api/marketplace/orders create draft + GET /pos/api/marketplace/orders/mine buyer's PO list + GET /pos/api/marketplace/orders/inbox seller's incoming PO list + GET /pos/api/marketplace/orders/ full detail + POST /pos/api/marketplace/orders//transition state change + +NOTE: this replaces an earlier stub that referenced now-unused tables +(marketplace_orders, marketplace_order_items, tenants.is_seller flag). +The Phase 1 schema uses bodegas + purchase_orders + purchase_order_items. +""" + +from functools import wraps from flask import Blueprint, request, jsonify, g + from middleware import require_auth -from tenant_db import get_master_conn, get_tenant_conn +from tenant_db import get_tenant_conn, get_master_conn +from services import marketplace_service as mkt + marketplace_bp = Blueprint('marketplace', __name__, url_prefix='/pos/api/marketplace') -@marketplace_bp.route('/sellers', methods=['GET']) -@require_auth() -def list_sellers(): - """List active sellers/bodegas.""" +# ─── Role loader + checker ──────────────────────────────────────────────── + +def _load_marketplace_profile(): + """Fetch the caller's marketplace_role + bodega_id from the tenant DB + and attach to flask.g. Call AFTER @require_auth. Idempotent.""" + if hasattr(g, 'marketplace_loaded'): + return + g.marketplace_role = 'buyer' + g.marketplace_bodega_id = None + try: + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute( + "SELECT marketplace_role, bodega_id FROM employees WHERE id = %s", + (g.employee_id,), + ) + row = cur.fetchone() + if row: + g.marketplace_role = row[0] or 'buyer' + g.marketplace_bodega_id = row[1] + cur.close() + conn.close() + except Exception as e: + print(f'[marketplace] failed to load role: {e}') + g.marketplace_loaded = True + + +def require_marketplace_role(*allowed_roles): + """Decorator: only allow users whose marketplace_role is in the allowed list. + Must be applied AFTER @require_auth().""" + def decorator(f): + @wraps(f) + def wrapped(*args, **kwargs): + _load_marketplace_profile() + if g.marketplace_role not in allowed_roles: + return jsonify({ + 'error': f'Marketplace role {g.marketplace_role} cannot access this endpoint', + 'required': list(allowed_roles), + }), 403 + return f(*args, **kwargs) + return wrapped + return decorator + + +def _with_master(f): + """Open a master connection, run f(master_conn), always close.""" conn = get_master_conn() - cur = conn.cursor() - cur.execute(""" - SELECT id, name, subdomain, rfc - FROM tenants - WHERE is_active = true AND is_seller = true - ORDER BY name - """) - sellers = [] - for r in cur.fetchall(): - sellers.append({'id': r[0], 'name': r[1], 'subdomain': r[2], 'rfc': r[3]}) - cur.close() - conn.close() - return jsonify({'data': sellers}) + try: + return f(conn) + finally: + conn.close() -@marketplace_bp.route('/search', methods=['GET']) +# ═══════════════════════════════════════════════════════════════════════════ +# BODEGAS +# ═══════════════════════════════════════════════════════════════════════════ + +@marketplace_bp.route('/whoami', methods=['GET']) +@require_auth() +def whoami(): + """Return the current user's marketplace profile (role, bodega_id, etc.).""" + _load_marketplace_profile() + return jsonify({ + 'employee_id': g.employee_id, + 'employee_name': g.employee_name, + 'tenant_id': g.tenant_id, + 'marketplace_role': g.marketplace_role, + 'bodega_id': g.marketplace_bodega_id, + }) + + +@marketplace_bp.route('/bodegas', methods=['GET']) +@require_auth() +def list_bodegas(): + verified_only = request.args.get('verified_only', 'true').lower() != 'false' + city = request.args.get('city') + def _do(master): + return jsonify({'data': mkt.list_bodegas(master, verified_only=verified_only, city=city)}) + return _with_master(_do) + + +@marketplace_bp.route('/bodegas/', methods=['GET']) +@require_auth() +def get_bodega(bodega_id): + def _do(master): + b = mkt.get_bodega(master, bodega_id) + if not b: + return jsonify({'error': 'Bodega not found'}), 404 + return jsonify(b) + return _with_master(_do) + + +# ═══════════════════════════════════════════════════════════════════════════ +# INVENTORY +# ═══════════════════════════════════════════════════════════════════════════ + +@marketplace_bp.route('/inventory/upload', methods=['POST']) +@require_auth() +@require_marketplace_role('seller', 'admin') +def upload_inventory(): + """CSV bulk upload for a bodega's warehouse inventory. + + Body options: + multipart/form-data with file field 'file' + OR + application/json with {bodega_id, csv} (admin override) + """ + # Sellers upload to THEIR bodega; admin can upload to any. + if g.marketplace_role == 'seller': + bodega_id = g.marketplace_bodega_id + if not bodega_id: + return jsonify({'error': 'Seller has no bodega_id assigned'}), 400 + else: + body = request.get_json(silent=True) or {} + bodega_id = int(body.get('bodega_id') or 0) + if not bodega_id: + return jsonify({'error': 'bodega_id required for admin upload'}), 400 + + # Read CSV from either multipart file or JSON body + csv_text = None + if 'file' in request.files: + csv_text = request.files['file'].read().decode('utf-8', errors='ignore') + else: + body = request.get_json(silent=True) or {} + csv_text = body.get('csv') + if not csv_text: + return jsonify({'error': 'CSV payload required (file upload or JSON csv field)'}), 400 + + def _do(master): + result = mkt.upload_inventory_csv(master, bodega_id, csv_text) + return jsonify(result) + return _with_master(_do) + + +@marketplace_bp.route('/inventory/search', methods=['GET']) @require_auth() def search_inventory(): - """Search across ALL seller tenant inventories. - - Query params: - q: search term (required, min 2 chars) - seller_id: optional filter by specific seller - page: page number (default 1) - per_page: results per page (default 50, max 200) - """ - q = request.args.get('q', '').strip() - if len(q) < 2: - return jsonify({'error': 'Search query must be at least 2 characters'}), 400 - - seller_id = request.args.get('seller_id') - page = int(request.args.get('page', 1)) - per_page = min(int(request.args.get('per_page', 50)), 200) - offset = (page - 1) * per_page - - # Get all seller tenants - master = get_master_conn() - mcur = master.cursor() - - if seller_id: - mcur.execute(""" - SELECT id, name, db_name FROM tenants - WHERE is_active = true AND is_seller = true AND id = %s - """, (seller_id,)) - else: - mcur.execute(""" - SELECT id, name, db_name FROM tenants - WHERE is_active = true AND is_seller = true - ORDER BY name - """) - - sellers = mcur.fetchall() - mcur.close() - master.close() - - results = [] - search_pattern = f'%{q}%' - - for s_id, s_name, db_name in sellers: - try: - conn = get_tenant_conn(s_id) - cur = conn.cursor() - cur.execute(""" - SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate, i.unit, - COALESCE(s.stock, 0) AS stock - FROM inventory i - LEFT JOIN ( - SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock - FROM inventory_operations GROUP BY inventory_id - ) s ON s.inventory_id = i.id - WHERE i.is_active = true - AND COALESCE(s.stock, 0) > 0 - AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s) - ORDER BY i.name - LIMIT %s - """, (search_pattern, search_pattern, search_pattern, per_page)) - - for r in cur.fetchall(): - results.append({ - 'seller_id': s_id, - 'seller_name': s_name, - 'part_number': r[0], - 'name': r[1], - 'brand': r[2], - 'price': float(r[3]) if r[3] else 0, - 'tax_rate': float(r[4]) if r[4] else 0.16, - 'unit': r[5] or 'PZA', - 'stock': r[6], - }) - cur.close() - conn.close() - except Exception: - # Skip tenants with connection issues - continue - - # Sort all results by name, then paginate - results.sort(key=lambda x: x['name']) - total = len(results) - paged = results[offset:offset + per_page] - - return jsonify({ - 'data': paged, - 'pagination': { - 'page': page, - 'per_page': per_page, - 'total': total, - 'pages': (total + per_page - 1) // per_page if per_page else 1, - } - }) + q = request.args.get('q') + brand = request.args.get('brand') + city = request.args.get('city') + limit = min(request.args.get('limit', 50, type=int), 200) + def _do(master): + data = mkt.search_inventory(master, query=q, brand=brand, city=city, limit=limit) + return jsonify({'data': data, 'count': len(data)}) + return _with_master(_do) -@marketplace_bp.route('/order', methods=['POST']) +@marketplace_bp.route('/inventory/part/', methods=['GET']) @require_auth() +def bodegas_with_part(part_id): + def _do(master): + data = mkt.get_bodegas_with_part(master, part_id) + return jsonify({'data': data, 'count': len(data)}) + return _with_master(_do) + + +# ═══════════════════════════════════════════════════════════════════════════ +# PURCHASE ORDERS +# ═══════════════════════════════════════════════════════════════════════════ + +@marketplace_bp.route('/orders', methods=['POST']) +@require_auth() +@require_marketplace_role('buyer', 'admin') def create_order(): - """Create a marketplace order from buyer to seller. + """Create a new PO in draft status. Body: - seller_id: int (required) - items: [{ part_number, part_name, quantity, unit_price }] (required) - notes: str (optional) + { + "bodega_id": 1, + "items": [{"part_id": 123, "quantity": 2, "unit_price": 150}, ...], + "delivery_method": "pickup", + "delivery_address": "...", + "buyer_notes": "..." + } """ - data = request.get_json() or {} - seller_id = data.get('seller_id') - items = data.get('items', []) + body = request.get_json() or {} + bodega_id = int(body.get('bodega_id') or 0) + items = body.get('items') or [] - if not seller_id: - return jsonify({'error': 'seller_id required'}), 400 + if not bodega_id: + return jsonify({'error': 'bodega_id required'}), 400 if not items: - return jsonify({'error': 'items required (non-empty array)'}), 400 + return jsonify({'error': 'At least one item required'}), 400 - buyer_id = g.tenant_id - - # Get buyer and seller names - master = get_master_conn() - mcur = master.cursor() - mcur.execute("SELECT name FROM tenants WHERE id = %s", (buyer_id,)) - buyer_row = mcur.fetchone() - mcur.execute("SELECT name FROM tenants WHERE id = %s AND is_seller = true AND is_active = true", (seller_id,)) - seller_row = mcur.fetchone() - mcur.close() - - if not buyer_row: - master.close() - return jsonify({'error': 'Buyer tenant not found'}), 404 - if not seller_row: - master.close() - return jsonify({'error': 'Seller not found or not active'}), 404 - - buyer_name = buyer_row[0] - seller_name = seller_row[0] - - # Calculate total - total = 0 - for item in items: - qty = item.get('quantity', 0) - price = item.get('unit_price', 0) - item['subtotal'] = round(qty * price, 2) - total += item['subtotal'] - - mcur2 = master.cursor() - mcur2.execute(""" - INSERT INTO marketplace_orders (buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, total, notes) - VALUES (%s, %s, %s, %s, %s, %s) RETURNING id - """, (buyer_id, seller_id, buyer_name, seller_name, round(total, 2), data.get('notes'))) - order_id = mcur2.fetchone()[0] - - for item in items: - mcur2.execute(""" - INSERT INTO marketplace_order_items (order_id, part_number, part_name, quantity, unit_price, subtotal) - VALUES (%s, %s, %s, %s, %s, %s) - """, (order_id, item.get('part_number'), item.get('part_name'), - item.get('quantity', 0), item.get('unit_price', 0), item.get('subtotal', 0))) - - master.commit() - mcur2.close() - master.close() - - return jsonify({'id': order_id, 'total': round(total, 2), 'message': 'Order created'}), 201 + def _do(master): + try: + po_id = mkt.create_po_draft( + master, + buyer_tenant_id=g.tenant_id, + buyer_user_id=g.employee_id, + buyer_display_name=g.employee_name, + buyer_phone=body.get('buyer_phone'), + buyer_email=body.get('buyer_email'), + bodega_id=bodega_id, + items=items, + delivery_method=body.get('delivery_method', 'pickup'), + delivery_address=body.get('delivery_address'), + buyer_notes=body.get('buyer_notes'), + ) + return jsonify({'ok': True, 'po_id': po_id}), 201 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + return _with_master(_do) -@marketplace_bp.route('/orders', methods=['GET']) +@marketplace_bp.route('/orders/mine', methods=['GET']) @require_auth() -def list_orders(): - """List marketplace orders (as buyer or seller). +def my_orders(): + """Buyer view: POs this tenant (or user) created.""" + only_mine = request.args.get('only_mine', 'true').lower() != 'false' + def _do(master): + data = mkt.list_pos_for_buyer( + master, + buyer_tenant_id=g.tenant_id, + buyer_user_id=g.employee_id if only_mine else None, + ) + return jsonify({'data': data, 'count': len(data)}) + return _with_master(_do) - Query params: - role: 'buyer' or 'seller' (default: both) - status: filter by status - page: page number - per_page: results per page - """ - tenant_id = g.tenant_id - role = request.args.get('role', '') - status = request.args.get('status', '') - page = int(request.args.get('page', 1)) - per_page = min(int(request.args.get('per_page', 50)), 200) - offset = (page - 1) * per_page - master = get_master_conn() - mcur = master.cursor() - - where_clauses = [] - params = [] - - if role == 'buyer': - where_clauses.append("buyer_tenant_id = %s") - params.append(tenant_id) - elif role == 'seller': - where_clauses.append("seller_tenant_id = %s") - params.append(tenant_id) +@marketplace_bp.route('/orders/inbox', methods=['GET']) +@require_auth() +@require_marketplace_role('seller', 'admin') +def seller_inbox(): + """Seller view: incoming POs for this bodega.""" + if g.marketplace_role == 'seller': + bodega_id = g.marketplace_bodega_id else: - where_clauses.append("(buyer_tenant_id = %s OR seller_tenant_id = %s)") - params.extend([tenant_id, tenant_id]) - - if status: - where_clauses.append("status = %s") - params.append(status) - - where = " AND ".join(where_clauses) - - mcur.execute(f"SELECT count(*) FROM marketplace_orders WHERE {where}", params) - total = mcur.fetchone()[0] - - mcur.execute(f""" - SELECT id, buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, - total, status, notes, created_at, updated_at - FROM marketplace_orders - WHERE {where} - ORDER BY created_at DESC - LIMIT %s OFFSET %s - """, params + [per_page, offset]) - - orders = [] - for r in mcur.fetchall(): - orders.append({ - 'id': r[0], 'buyer_tenant_id': r[1], 'seller_tenant_id': r[2], - 'buyer_name': r[3], 'seller_name': r[4], - 'total': float(r[5]) if r[5] else 0, - 'status': r[6], 'notes': r[7], - 'created_at': str(r[8]), 'updated_at': str(r[9]), - }) - - mcur.close() - master.close() - - return jsonify({ - 'data': orders, - 'pagination': { - 'page': page, 'per_page': per_page, - 'total': total, 'pages': (total + per_page - 1) // per_page if per_page else 1, - } - }) + bodega_id = int(request.args.get('bodega_id') or 0) + if not bodega_id: + return jsonify({'error': 'bodega_id required'}), 400 + def _do(master): + data = mkt.list_pos_for_seller(master, bodega_id) + return jsonify({'data': data, 'count': len(data)}) + return _with_master(_do) -@marketplace_bp.route('/orders//status', methods=['PUT']) +@marketplace_bp.route('/orders/', methods=['GET']) @require_auth() -def update_order_status(order_id): - """Update order status. Seller can confirm/ship/deliver/cancel. Buyer can cancel if pending. +def get_order(po_id): + """PO detail — buyer sees their tenant's POs, seller sees their bodega's.""" + _load_marketplace_profile() + def _do(master): + po = mkt.get_po_detail(master, po_id) + if not po: + return jsonify({'error': 'PO not found'}), 404 - Body: - status: 'confirmed' | 'shipped' | 'delivered' | 'cancelled' + # Authorization + if g.marketplace_role == 'seller': + if po['bodega_id'] != g.marketplace_bodega_id: + return jsonify({'error': 'Not authorized'}), 403 + elif g.marketplace_role == 'buyer': + if po['buyer_tenant_id'] != g.tenant_id: + return jsonify({'error': 'Not authorized'}), 403 + # admin sees all + + return jsonify(po) + return _with_master(_do) + + +@marketplace_bp.route('/orders//transition', methods=['POST']) +@require_auth() +def transition_order(po_id): + """Change a PO's status. Role determines which transitions are allowed. + + Body: {"new_status": "confirmed", "note": "optional note"} """ - data = request.get_json() or {} - new_status = data.get('status') - valid_statuses = ['confirmed', 'shipped', 'delivered', 'cancelled'] - if new_status not in valid_statuses: - return jsonify({'error': f'status must be one of: {", ".join(valid_statuses)}'}), 400 + _load_marketplace_profile() + body = request.get_json() or {} + new_status = body.get('new_status') + note = body.get('note') + if not new_status: + return jsonify({'error': 'new_status required'}), 400 - tenant_id = g.tenant_id + # Map marketplace_role to actor_kind for the state machine. + actor_kind = g.marketplace_role + if actor_kind == 'admin': + actor_kind = 'seller' # admin defaults to seller path in Phase 1 - master = get_master_conn() - mcur = master.cursor() - mcur.execute(""" - SELECT buyer_tenant_id, seller_tenant_id, status - FROM marketplace_orders WHERE id = %s - """, (order_id,)) - row = mcur.fetchone() + def _do(master): + po = mkt.get_po_detail(master, po_id) + if not po: + return jsonify({'error': 'PO not found'}), 404 + if g.marketplace_role == 'seller' and po['bodega_id'] != g.marketplace_bodega_id: + return jsonify({'error': 'Not authorized'}), 403 + if g.marketplace_role == 'buyer' and po['buyer_tenant_id'] != g.tenant_id: + return jsonify({'error': 'Not authorized'}), 403 - if not row: - mcur.close() - master.close() - return jsonify({'error': 'Order not found'}), 404 - - buyer_id, seller_id, current_status = row - - # Permission check - if tenant_id == buyer_id: - # Buyer can only cancel pending orders - if new_status != 'cancelled' or current_status != 'pending': - mcur.close() - master.close() - return jsonify({'error': 'Buyer can only cancel pending orders'}), 403 - elif tenant_id == seller_id: - # Seller can do any transition - pass - else: - mcur.close() - master.close() - return jsonify({'error': 'Not authorized for this order'}), 403 - - mcur.execute(""" - UPDATE marketplace_orders SET status = %s, updated_at = NOW() - WHERE id = %s - """, (new_status, order_id)) - master.commit() - mcur.close() - master.close() - - return jsonify({'id': order_id, 'status': new_status, 'message': 'Order updated'}) - - -@marketplace_bp.route('/orders//items', methods=['GET']) -@require_auth() -def get_order_items(order_id): - """Get items for a specific order.""" - tenant_id = g.tenant_id - - master = get_master_conn() - mcur = master.cursor() - - # Verify tenant is buyer or seller - mcur.execute(""" - SELECT buyer_tenant_id, seller_tenant_id FROM marketplace_orders WHERE id = %s - """, (order_id,)) - row = mcur.fetchone() - if not row or (row[0] != tenant_id and row[1] != tenant_id): - mcur.close() - master.close() - return jsonify({'error': 'Not authorized'}), 403 - - mcur.execute(""" - SELECT id, part_number, part_name, quantity, unit_price, subtotal - FROM marketplace_order_items WHERE order_id = %s ORDER BY id - """, (order_id,)) - - items = [] - for r in mcur.fetchall(): - items.append({ - 'id': r[0], 'part_number': r[1], 'part_name': r[2], - 'quantity': r[3], 'unit_price': float(r[4]) if r[4] else 0, - 'subtotal': float(r[5]) if r[5] else 0, - }) - mcur.close() - master.close() - return jsonify({'data': items}) + result = mkt.transition_po( + master, + po_id=po_id, + new_status=new_status, + actor_user_id=g.employee_id, + actor_kind=actor_kind, + note=note, + ) + if not result.get('ok'): + return jsonify(result), 400 + return jsonify(result) + return _with_master(_do) diff --git a/pos/blueprints/peer_bp.py b/pos/blueprints/peer_bp.py new file mode 100644 index 0000000..2c8a6a2 --- /dev/null +++ b/pos/blueprints/peer_bp.py @@ -0,0 +1,95 @@ +""" +Peer API — public endpoints for inter-instance communication. + +These endpoints do NOT require auth (they're called machine-to-machine by +other Nexus instances on the network). They expose read-only inventory data +so the marketplace can aggregate stock across the whole Nexus network. + +Routes: + GET /pos/api/peer/health — instance status + inventory count + GET /pos/api/peer/inventory — search this instance's inventory +""" + +from flask import Blueprint, request, jsonify, g +from tenant_db import get_tenant_conn +from services import peer_service + +peer_bp = Blueprint('peer', __name__, url_prefix='/pos/api/peer') + + +# ─── Which tenant to use for the peer endpoint? ────────────────────────── +# In production each instance serves one tenant. For the demo, we hardcode +# tenant_id=11 (the demo refaccionaria). This will be read from a config +# file in the future when each instance has exactly 1 active tenant. + +import os +import json + +def _get_local_tenant_id(): + """Read the local tenant ID from peers.json or fall back to 11.""" + try: + cfg_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'peers.json') + with open(cfg_path, 'r') as f: + cfg = json.load(f) + return cfg.get('tenant_id', 11) + except Exception: + return 11 + + +@peer_bp.route('/health', methods=['GET']) +def peer_health(): + """Public health check — no auth. Returns instance name + basic stats.""" + tenant_id = _get_local_tenant_id() + inventory_count = 0 + try: + conn = get_tenant_conn(tenant_id) + cur = conn.cursor() + cur.execute(""" + SELECT COUNT(*) FROM inventory i + WHERE i.is_active = TRUE + AND COALESCE((SELECT SUM(quantity) FROM inventory_operations WHERE inventory_id = i.id), 0) > 0 + """) + inventory_count = cur.fetchone()[0] + cur.close() + conn.close() + except Exception as e: + print(f'[peer] health check DB error: {e}') + + return jsonify({ + 'status': 'ok', + 'instance_name': peer_service.get_instance_name(), + 'instance_id': peer_service.get_instance_id(), + 'inventory_count': inventory_count, + 'peer_count': len(peer_service.get_peers()), + }) + + +@peer_bp.route('/inventory', methods=['GET']) +def peer_inventory(): + """Public inventory search — no auth. + + Called by other Nexus instances to see what this refaccionaria has in stock. + Returns minimal data: part number, name, brand, price, stock hint. + Does NOT expose exact stock quantities (competitive info). + + Query params: + q: search term (optional — without it, returns popular/all items) + limit: max results (default 50, max 200) + """ + q = request.args.get('q', '').strip() or None + limit = min(request.args.get('limit', 50, type=int), 200) + + tenant_id = _get_local_tenant_id() + try: + conn = get_tenant_conn(tenant_id) + data = peer_service.get_local_inventory(conn, query=q, limit=limit) + conn.close() + except Exception as e: + print(f'[peer] inventory query error: {e}') + data = [] + + return jsonify({ + 'instance_name': peer_service.get_instance_name(), + 'data': data, + 'count': len(data), + }) diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py index 1860ae0..b9376d8 100644 --- a/pos/blueprints/pos_bp.py +++ b/pos/blueprints/pos_bp.py @@ -505,7 +505,8 @@ def list_quotations(): where_clauses.append("q.status = %s") params.append(status) if g.branch_id: - where_clauses.append("q.branch_id = %s") + # Show both this branch's quotes AND branchless ones (e.g. WhatsApp) + where_clauses.append("(q.branch_id = %s OR q.branch_id IS NULL)") params.append(g.branch_id) where = " AND ".join(where_clauses) @@ -515,7 +516,7 @@ def list_quotations(): cur.execute(f""" SELECT q.id, q.customer_id, q.employee_id, q.subtotal, q.tax_total, - q.total, q.status, q.valid_until, q.created_at, + q.total, q.status, q.valid_until, q.created_at, q.notes, c.name as customer_name, e.name as employee_name FROM quotations q LEFT JOIN customers c ON q.customer_id = c.id @@ -527,6 +528,9 @@ def list_quotations(): quotations = [] for r in cur.fetchall(): + notes = r[9] or '' + source = 'whatsapp' if notes.startswith('WA:') else 'pos' + wa_phone = notes.replace('WA:', '') if source == 'whatsapp' else None quotations.append({ 'id': r[0], 'customer_id': r[1], 'employee_id': r[2], 'subtotal': float(r[3]) if r[3] else 0, @@ -534,7 +538,9 @@ def list_quotations(): 'total': float(r[5]) if r[5] else 0, 'status': r[6], 'valid_until': str(r[7]) if r[7] else None, 'created_at': str(r[8]), - 'customer_name': r[9], 'employee_name': r[10], + 'customer_name': r[10], 'employee_name': r[11], + 'source': source, + 'wa_phone': wa_phone, }) cur.close(); conn.close() @@ -546,6 +552,146 @@ def list_quotations(): }) +@pos_bp.route('/quotations/', methods=['DELETE']) +@require_auth('pos.sell') +def delete_quotation(quot_id): + """Delete a quotation and its items.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,)) + cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,)) + deleted = cur.rowcount + conn.commit() + cur.close() + conn.close() + if deleted == 0: + return jsonify({'error': 'Cotización no encontrada'}), 404 + return jsonify({'ok': True, 'deleted_id': quot_id}) + + +@pos_bp.route('/quotations//print', methods=['POST']) +@require_auth('pos.sell') +def print_quotation_ticket(quot_id): + """Generate a printable ticket for a quotation (ESC/POS or browser).""" + from flask import Response + from services.thermal_printer import generate_quotation_ticket + + body = request.get_json(silent=True) or {} + printer_type = body.get('printer_type', 'escpos_raw') + width = int(body.get('width', 80)) + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until, + q.created_at, q.notes, c.name as customer_name + FROM quotations q + LEFT JOIN customers c ON q.customer_id = c.id + WHERE q.id = %s + """, (quot_id,)) + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'Quotation not found'}), 404 + + notes = row[6] or '' + wa_phone = notes.replace('WA:', '') if notes.startswith('WA:') else None + + cur.execute(""" + SELECT part_number, name, quantity, unit_price, subtotal + FROM quotation_items WHERE quotation_id = %s ORDER BY id + """, (quot_id,)) + items = [{'part_number': r[0], 'name': r[1], 'quantity': r[2], + 'unit_price': float(r[3]) if r[3] else 0, + 'subtotal': float(r[4]) if r[4] else 0} for r in cur.fetchall()] + + business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''} + try: + cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'tenant_%'") + for rw in cur.fetchall(): + if rw[0] == 'tenant_nombre': business_info['name'] = rw[1] + elif rw[0] == 'tenant_rfc': business_info['rfc'] = rw[1] + elif rw[0] == 'tenant_direccion': business_info['address'] = rw[1] + except Exception: + pass + + cur.close(); conn.close() + + quote_data = { + 'id': row[0], + 'subtotal': float(row[1]) if row[1] else 0, + 'tax_total': float(row[2]) if row[2] else 0, + 'total': float(row[3]) if row[3] else 0, + 'valid_until': str(row[4]) if row[4] else None, + 'created_at': str(row[5]) if row[5] else '', + 'customer_name': row[7] or '', + 'wa_phone': wa_phone, + 'items': items, + } + + if printer_type == 'browser': + return jsonify(quote_data) + + raw = generate_quotation_ticket(quote_data, business_info, width=width) + return Response(raw, mimetype='application/octet-stream', + headers={'Content-Disposition': f'attachment; filename=cotizacion_{quot_id}.bin'}) + + +@pos_bp.route('/quotations/print-queue', methods=['GET']) +@require_auth('pos.sell') +def quotation_print_queue(): + """Return quotations that were confirmed via WhatsApp and haven't been + printed yet. The POS browser polls this endpoint and auto-prints. + + Returns: {data: [{id, total, customer_name, wa_phone, confirmed_at}]} + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + SELECT q.id, q.total, q.notes, q.created_at, + c.name as customer_name + FROM quotations q + LEFT JOIN customers c ON q.customer_id = c.id + WHERE q.status = 'converted' + AND q.notes LIKE 'WA:%%' + AND NOT EXISTS ( + SELECT 1 FROM tenant_config + WHERE key = 'printed_quote_' || q.id::text + ) + ORDER BY q.created_at DESC + LIMIT 10 + """) + rows = cur.fetchall() + data = [] + for r in rows: + notes = r[2] or '' + data.append({ + 'id': r[0], + 'total': float(r[1]) if r[1] else 0, + 'wa_phone': notes.replace('WA:', '') if notes.startswith('WA:') else None, + 'created_at': str(r[3]) if r[3] else '', + 'customer_name': r[4] or '', + }) + cur.close(); conn.close() + return jsonify({'data': data}) + + +@pos_bp.route('/quotations//mark-printed', methods=['POST']) +@require_auth('pos.sell') +def mark_quotation_printed(quot_id): + """Mark a quotation as printed so it doesn't appear in the print queue again.""" + 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 + """, (f'printed_quote_{quot_id}', 'true')) + conn.commit() + cur.close(); conn.close() + return jsonify({'ok': True}) + + @pos_bp.route('/quotations/', methods=['GET']) @require_auth('pos.view') def get_quotation(quot_id): diff --git a/pos/blueprints/whatsapp_bp.py b/pos/blueprints/whatsapp_bp.py index 77230b3..b393da3 100644 --- a/pos/blueprints/whatsapp_bp.py +++ b/pos/blueprints/whatsapp_bp.py @@ -19,6 +19,133 @@ from services import whatsapp_service whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp') +def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn): + """Search the refaccionaria's LOCAL inventory and build a WhatsApp reply. + + Returns: + (formatted_text, first_part_dict) — first_part_dict is used by the + quotation system to know what to add when the user says "cotizar". + first_part_dict has: inventory_id, part_number, name, brand, price, tax_rate + """ + if not tenant_conn: + return None, None + + try: + # Translate common English search terms to Spanish for local inventory + # (the AI sends search_query in English, but local inventory names + # are often in Spanish) + from services.translations import PART_TRANSLATIONS + search_terms = [search_query] + # Add the Spanish translation if we have one + for en, es in PART_TRANSLATIONS.items(): + if en.upper() in search_query.upper(): + search_terms.append(es) + break + + # Build ILIKE conditions for all search terms + conditions = [] + params = [] + for term in search_terms: + conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)") + like = f'%{term}%' + params.extend([like, like, like]) + + where_search = ' OR '.join(conditions) + + cur = tenant_conn.cursor() + cur.execute(f""" + SELECT i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3, + COALESCE(s.stock, 0) AS stock, + i.unit, i.location + FROM inventory i + LEFT JOIN ( + SELECT inventory_id, SUM(quantity) AS stock + FROM inventory_operations + GROUP BY inventory_id + ) s ON s.inventory_id = i.id + WHERE i.is_active = TRUE + AND ({where_search}) + ORDER BY + COALESCE(s.stock, 0) > 0 DESC, + i.name + LIMIT 10 + """, params) + + rows = cur.fetchall() + cur.close() + + if not rows: + return ('❌ No tenemos esa parte en inventario actualmente.\n' + '_Puedes preguntar por otra parte o visitarnos en tienda._'), None + + # Split into in-stock and out-of-stock + in_stock = [r for r in rows if r[6] > 0] + out_stock = [r for r in rows if r[6] <= 0] + + # Build the first-part dict for quotation tracking + # Use the first in-stock part, or first out-of-stock if none available + best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None) + first_part = None + if best: + first_part = { + 'inventory_id': None, # we'd need the id — fetch it + 'part_number': best[0], + 'name': best[1], + 'brand': best[2] or '', + 'price': float(best[3]) if best[3] else 0, + 'tax_rate': 0.16, + 'stock': best[6], + 'unit': best[7] or 'PZA', + } + # Fetch the inventory ID for the quotation item FK + try: + cur2 = tenant_conn.cursor() + cur2.execute("SELECT id FROM inventory WHERE part_number = %s AND is_active = TRUE LIMIT 1", + (best[0],)) + inv_row = cur2.fetchone() + if inv_row: + first_part['inventory_id'] = inv_row[0] + cur2.close() + except Exception: + pass + + lines = [] + + if in_stock: + lines.append('✅ *Tenemos en stock:*') + lines.append('') + for r in in_stock: + part_num, name, brand, p1, p2, p3, stock, unit, location = r + brand_str = f'*{brand}*' if brand else '' + price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio' + lines.append(f' • {brand_str} {name}') + lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)') + lines.append('') + else: + lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*') + lines.append('') + for r in out_stock[:5]: + part_num, name, brand, p1, p2, p3, stock, unit, location = r + brand_str = f'*{brand}*' if brand else '' + price_str = f'${float(p1):,.2f}' if p1 else '' + lines.append(f' • {brand_str} {name} #{part_num} {price_str}') + lines.append('') + lines.append('_Podemos pedirlo — consulta tiempo de entrega._') + + # Vehicle context + if vehicle and vehicle.get('brand'): + v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}" + lines.append(f'🚗 Vehículo: {v_str.strip()}') + + lines.append('\n📞 _Escribe "cotizar" para agregar a tu cotización._') + + return '\n'.join(lines), first_part + + except Exception as e: + print(f"[WA-AI] Enrichment error: {e}") + return None, None + + @whatsapp_bp.route('/status', methods=['GET']) @require_auth() def status(): @@ -45,7 +172,14 @@ def logout(): @whatsapp_bp.route('/webhook', methods=['POST']) def webhook(): - """Receive messages from Baileys bridge (public, no auth).""" + """Receive messages from Baileys bridge (public, no auth). + + Flow: + 1. Persist the incoming message to the tenant's whatsapp_messages log. + 2. Build inventory context for the AI (what this tenant has in stock). + 3. Ask the chatbot for a reply, enriched with that context. + 4. Send the reply back via the Baileys bridge. + """ data = request.get_json(force=True, silent=True) or {} if data.get('event') != 'messages.upsert': @@ -55,30 +189,205 @@ def webhook(): if not msg.get('phone') or msg.get('from_me'): return jsonify({'ok': True}) - # Save to DB if tenant connection available + # Reuse one tenant connection for the whole webhook path — we need it + # for persistence AND for the inventory-context lookup. + # TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives. + tenant_id = 11 + tenant_conn = None + inventory_context = None try: - # Try to get a tenant connection (use default tenant for webhook) - conn = get_tenant_conn(11) # TODO: resolve tenant from phone number - cur = conn.cursor() - cur.execute(""" - INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id) - VALUES (%s, 'incoming', %s, %s) - ON CONFLICT DO NOTHING - """, (msg['phone'], msg['text'], msg['message_id'])) - conn.commit() - cur.close() - conn.close() - except Exception: - pass + tenant_conn = get_tenant_conn(tenant_id) - # Auto-reply with AI chatbot - if msg.get('text'): + # 1. Log the incoming message (with contact display name) + cur = tenant_conn.cursor() + cur.execute(""" + INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name) + VALUES (%s, 'incoming', %s, %s, %s) + ON CONFLICT DO NOTHING + """, (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None)) + tenant_conn.commit() + cur.close() + + # 2. Build inventory context once per webhook call so the chatbot + # can say things like "tengo 5 Bosch BP-123 por $450". try: - from services.ai_chat import chat - ai_resp = chat(msg['text']) - reply = ai_resp.get('message', '') + from services.ai_chat import get_inventory_context + inventory_context = get_inventory_context(tenant_conn) + except Exception as e: + print(f"[WA-AI] inventory_context failed: {e}") + inventory_context = None + except Exception as e: + print(f"[WA-AI] tenant connection failed: {e}") + + # 3. Dispatch by media kind + quotation commands + reply = None + reply_to = msg.get('jid') or msg['phone'] + media_kind = msg.get('media_kind', 'text') + clean_phone = msg.get('phone', '') + + # ── Check for quotation commands FIRST (before AI) ── + if media_kind == 'text' and msg.get('text'): + from services.wa_quotation import ( + detect_quote_intent, get_open_quotation, create_quotation, + add_item_to_quotation, get_quotation_detail, format_quotation_wa, + clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part, + ) + has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone)) + intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open) + + if intent == 'add': + last_part = get_last_shown_part(clean_phone) + if not last_part: + reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.' + elif tenant_conn: + qid = get_open_quotation(tenant_conn, clean_phone) + if not qid: + qid = create_quotation(tenant_conn, clean_phone) + add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1) + detail = get_quotation_detail(tenant_conn, qid) + item_count = len(detail['items']) if detail else 0 + reply = ( + f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n' + f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n' + f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._' + ) + + elif intent == 'send': + if tenant_conn: + qid = get_open_quotation(tenant_conn, clean_phone) + if qid: + detail = get_quotation_detail(tenant_conn, qid) + reply = format_quotation_wa(detail) + if not reply: + reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.' + else: + reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.' + + elif intent == 'clear': + if tenant_conn: + clear_quotation(tenant_conn, clean_phone) + reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.' + + elif intent == 'confirm': + if tenant_conn: + qid = confirm_quotation(tenant_conn, clean_phone) + if qid: + reply = ( + f'✅ *Pedido confirmado!*\n\n' + f'Tu cotización #{qid} fue registrada.\n' + f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n' + f'¡Gracias por tu compra! 🙏' + ) + else: + reply = '⚠️ No tienes una cotización abierta para confirmar.' + + if intent is not None: + # It was a quote command — send reply and skip the AI if reply: - whatsapp_service.send_message(msg['phone'], reply) + result = whatsapp_service.send_message(reply_to, reply) + if tenant_conn: + try: + cur_save = tenant_conn.cursor() + cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply)) + tenant_conn.commit() + cur_save.close() + except Exception: + pass + # Clean up and return early + if tenant_conn: + try: tenant_conn.close() + except Exception: pass + return jsonify({'ok': True}) + + try: + if media_kind == 'image' and msg.get('media_base64'): + from services.ai_chat import chat_with_image + # Prompt: use the caption if provided, else default to + # "identify this part" which chat_with_image handles gracefully. + prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.' + ai_resp = chat_with_image( + user_message=prompt, + image_base64=msg['media_base64'], + inventory_context=inventory_context, + ) + reply = ai_resp.get('message', '') or '' + print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...") + + elif media_kind == 'audio' and msg.get('media_base64'): + # Voice note handling — transcribe first, then chat(). + # See services.whisper_local for the transcriber. + try: + from services.whisper_local import transcribe_audio_base64 + transcript = transcribe_audio_base64( + msg['media_base64'], + mimetype=msg.get('media_mimetype') or 'audio/ogg', + ) + except ImportError: + transcript = None + print("[WA-AI] whisper_local not installed — voice notes skipped") + except Exception as e: + transcript = None + print(f"[WA-AI] Whisper transcription failed: {e}") + + if transcript: + print(f"[WA-AI] Voice note transcribed: {transcript[:100]}") + from services.ai_chat import chat + ai_resp = chat(transcript, inventory_context=inventory_context) + reply = ai_resp.get('message', '') or '' + # Prefix the reply so the sender knows we understood the voice note + if reply: + reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}' + else: + reply = ('Recibi tu nota de voz pero no pude transcribirla. ' + 'Puedes escribirme el mensaje?') + + elif msg.get('text'): + # Plain text message — standard chatbot flow + from services.ai_chat import chat + ai_resp = chat(msg['text'], inventory_context=inventory_context) + reply = ai_resp.get('message', '') or '' + + # Enrich: if the AI returned a search_query, look up real parts + # from the catalog and append them to the WhatsApp reply. + search_q = ai_resp.get('search_query') + vehicle = ai_resp.get('vehicle') + if search_q and reply: + try: + enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn) + if enrichment: + reply = reply + '\n\n' + enrichment + # Track the found part so "cotizar" can add it + if found_part: + from services.wa_quotation import set_last_shown_part + set_last_shown_part(clean_phone, found_part) + except Exception as enrich_err: + print(f"[WA-AI] Enrichment failed: {enrich_err}") + + # Send reply if we produced one + if reply: + result = whatsapp_service.send_message(reply_to, reply) + print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}") + + # Save the bot's reply to DB so it shows in the WhatsApp UI + if tenant_conn: + try: + cur2 = tenant_conn.cursor() + cur2.execute(""" + INSERT INTO whatsapp_messages (phone, direction, message_text) + VALUES (%s, 'outgoing', %s) + """, (msg['phone'], reply)) + tenant_conn.commit() + cur2.close() + except Exception as db_err: + print(f"[WA-AI] Failed to save bot reply to DB: {db_err}") + + except Exception as e: + print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}") + + # 4. Clean up the connection + if tenant_conn is not None: + try: + tenant_conn.close() except Exception: pass @@ -119,14 +428,37 @@ def conversations(): try: conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() + # Clean up phone format: strip @lid and @s.whatsapp.net suffixes + # so all variants of the same number are grouped together. cur.execute(""" - SELECT phone, MAX(message_text) as last_message, MAX(created_at) as last_at, COUNT(*) as msg_count - FROM whatsapp_messages - GROUP BY phone + WITH cleaned AS ( + SELECT + REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') AS clean_phone, + message_text, + direction, + created_at, + push_name + FROM whatsapp_messages + ) + SELECT clean_phone, + (ARRAY_AGG(message_text ORDER BY created_at DESC))[1] AS last_message, + (ARRAY_AGG(direction ORDER BY created_at DESC))[1] AS last_direction, + MAX(created_at) AS last_at, + COUNT(*) AS msg_count, + (ARRAY_AGG(push_name ORDER BY created_at DESC) FILTER (WHERE push_name IS NOT NULL AND push_name != ''))[1] AS contact_name + FROM cleaned + GROUP BY clean_phone ORDER BY MAX(created_at) DESC LIMIT 50 """) - convos = [{'phone': r[0], 'last_message': r[1], 'last_at': str(r[2]), 'count': r[3]} for r in cur.fetchall()] + convos = [{ + 'phone': r[0], + 'last_message': r[1] or '', + 'last_direction': r[2] or 'incoming', + 'last_at': str(r[3]), + 'count': r[4], + 'contact_name': r[5] or '', + } for r in cur.fetchall()] cur.close() conn.close() return jsonify({'conversations': convos}) @@ -134,22 +466,68 @@ def conversations(): return jsonify({'conversations': [], 'error': str(e)}) -@whatsapp_bp.route('/conversations/', methods=['GET']) +@whatsapp_bp.route('/conversations/', methods=['GET']) @require_auth() def conversation_messages(phone): + # Strip @lid or @s.whatsapp.net suffix for DB lookup + clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '') try: conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() + # Match all variants of this phone number cur.execute(""" SELECT id, direction, message_text, created_at FROM whatsapp_messages - WHERE phone = %s + WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s ORDER BY created_at LIMIT 100 - """, (phone,)) - msgs = [{'id': r[0], 'direction': r[1], 'text': r[2], 'date': str(r[3])} for r in cur.fetchall()] + """, (clean_phone,)) + msgs = [{ + 'id': r[0], + 'direction': r[1], + 'message_text': r[2] or '', + 'created_at': str(r[3]), + } for r in cur.fetchall()] cur.close() conn.close() return jsonify({'messages': msgs}) except Exception as e: return jsonify({'messages': [], 'error': str(e)}) + + +@whatsapp_bp.route('/conversations/', methods=['DELETE']) +@require_auth() +def delete_conversation(phone): + """Delete all messages for a phone number.""" + clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '') + try: + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + DELETE FROM whatsapp_messages + WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s + """, (clean_phone,)) + deleted = cur.rowcount + conn.commit() + cur.close() + conn.close() + return jsonify({'ok': True, 'deleted': deleted}) + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@whatsapp_bp.route('/conversations', methods=['DELETE']) +@require_auth() +def delete_all_conversations(): + """Delete ALL whatsapp messages.""" + try: + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute("DELETE FROM whatsapp_messages") + deleted = cur.rowcount + conn.commit() + cur.close() + conn.close() + return jsonify({'ok': True, 'deleted': deleted}) + except Exception as e: + return jsonify({'error': str(e)}), 500 diff --git a/pos/peers.json b/pos/peers.json new file mode 100644 index 0000000..0c10716 --- /dev/null +++ b/pos/peers.json @@ -0,0 +1,19 @@ +{ + "instance_name": "Refaccionaria Demo", + "instance_id": "refac-demo-001", + "tenant_id": 11, + "peers": [ + { + "name": "Refaccionaria B", + "url": "http://192.168.1.20:5001", + "enabled": true + }, + { + "name": "Refaccionaria C", + "url": "http://192.168.1.30:5001", + "enabled": true + } + ], + "peer_timeout_seconds": 3, + "notes": "Edit the 'peers' list with the actual IPs of the other instances on your network. Each instance has its own copy of this file with different peers." +} diff --git a/pos/services/ai_chat.py b/pos/services/ai_chat.py index e1f6cf4..6b319f5 100644 --- a/pos/services/ai_chat.py +++ b/pos/services/ai_chat.py @@ -9,8 +9,20 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" # ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago. # El modelo DEBE terminar en ":free" para garantizar costo $0. -# Alternativas gratuitas: "meta-llama/llama-4-scout:free", "google/gemma-3-27b-it:free" -MODEL = "qwen/qwen3.6-plus-preview:free" +MODEL = "qwen/qwen3.6-plus:free" + +# Fallback chain: si el modelo principal tiene rate limit (429) o 404 +# (deprecated), intenta los siguientes. Todos :free. Mezclamos proveedores +# distintos porque los rate limits aplican por-proveedor. +# Lista actualizada 2026-04-09 después de que qwen3.6-plus fue deprecated. +FALLBACK_MODELS = [ + "openai/gpt-oss-120b:free", # OpenInference — gran cobertura + "google/gemma-4-31b-it:free", # Google — nuevo, 262K ctx + "qwen/qwen3-next-80b-a3b-instruct:free", # Alibaba — 262K ctx + "z-ai/glm-4.5-air:free", # Z.AI + "google/gemma-3-27b-it:free", # Google — backup vision + "meta-llama/llama-3.3-70b-instruct:free", # Meta — último fallback +] def _validate_model(model_id): """Ensure only free models are used. Raises if model is not free.""" @@ -318,15 +330,155 @@ def classify_part(part_number): return {"name": None, "brand": None, "vehicle": None, "category": None} +# ═══════════════════════════════════════════════════════════════════════════ +# RESPONSE CACHE — reduces OpenRouter calls for repeated questions +# ═══════════════════════════════════════════════════════════════════════════ +# Keyed by a normalized form of the user message. TTL 1 hour. Bypasses +# caching for messages containing VINs or specific part numbers (where the +# answer depends on the exact string). + +import hashlib as _hashlib +import re as _re +import time as _time_chat + +_RESPONSE_CACHE = {} # key → (expires_at, response_dict) +_CACHE_TTL_SECONDS = 3600 # 1 hour +_CACHE_MAX_SIZE = 1000 +_CACHE_HITS = 0 +_CACHE_MISSES = 0 + +# Stopwords that add noise but no meaning — stripped from cache keys. +_CACHE_STOPWORDS = { + 'necesito', 'necesitas', 'me', 'das', 'dame', 'tienes', 'tiene', 'hay', + 'quiero', 'quisiera', 'puedes', 'puede', 'favor', 'por', 'porfavor', + 'hola', 'buenos', 'dias', 'tardes', 'noches', 'holaa', + 'i', 'need', 'want', 'do', 'you', 'have', 'please', +} + +# Patterns that disable caching — if the message contains any of these, we +# never cache the response because the answer is specific to that exact input. +# Rules designed to minimize false positives against normal Spanish queries +# like "necesito balatas para corolla 2018". +_CACHE_BYPASS_PATTERNS = [ + # 17-char VIN (strict, no spaces, alphanumeric except I/O/Q) + _re.compile(r'\b[A-HJ-NPR-Z0-9]{17}\b'), + # Long numeric (12+ digits — too long to be a year/model code) + _re.compile(r'\b\d{12,}\b'), + # Mexican license plate: 3 letters + 3-4 digits + _re.compile(r'\b[A-Z]{3}[-\s]?\d{3,4}\b'), + # OEM with REQUIRED dash/slash separator(s), letters+digits on both sides, + # and a total length that makes it unlikely to be a brand+year collision. + # Example matches: "4G0-857-951-A", "0 986 4B7 013" (after normalizing). + _re.compile(r'\b[A-Z0-9]{2,}[-/][A-Z0-9]{2,}([-/][A-Z0-9]+)+\b'), +] + + +def _should_bypass_cache(message: str) -> bool: + """True if the message has VIN / part number / plate — don't cache.""" + if not message: + return True + upper = message.upper() + for pat in _CACHE_BYPASS_PATTERNS: + if pat.search(upper): + return True + return False + + +def _normalize_for_cache(message: str) -> str: + """Lowercase, strip punctuation, collapse whitespace, drop stopwords.""" + if not message: + return '' + s = message.lower().strip() + s = _re.sub(r'[¿?¡!.,;:()\[\]{}\'"]+', ' ', s) + s = _re.sub(r'\s+', ' ', s).strip() + tokens = [t for t in s.split() if t and t not in _CACHE_STOPWORDS] + return ' '.join(tokens) + + +def _cache_key(user_message: str, inventory_context: str | None) -> str | None: + """Build a stable cache key for (message, inventory_context). + + Returns None if the message should bypass the cache. + """ + if _should_bypass_cache(user_message): + return None + normalized = _normalize_for_cache(user_message) + if not normalized: + return None + # Hash the inventory context so same-tenant-same-question cache hits, + # different-tenant-same-question does NOT (inventory context differs). + ctx_hash = _hashlib.md5((inventory_context or '').encode()).hexdigest()[:12] + return f"{normalized}::{ctx_hash}" + + +def _cache_get(key: str): + global _CACHE_HITS, _CACHE_MISSES + if not key: + _CACHE_MISSES += 1 + return None + entry = _RESPONSE_CACHE.get(key) + if not entry: + _CACHE_MISSES += 1 + return None + expires_at, data = entry + if _time_chat.time() > expires_at: + _RESPONSE_CACHE.pop(key, None) + _CACHE_MISSES += 1 + return None + _CACHE_HITS += 1 + return data + + +def _cache_set(key: str, data: dict): + if not key or not data: + return + _RESPONSE_CACHE[key] = (_time_chat.time() + _CACHE_TTL_SECONDS, data) + # Bounded cache — evict oldest entries if we grow past the limit + if len(_RESPONSE_CACHE) > _CACHE_MAX_SIZE: + oldest_keys = sorted( + _RESPONSE_CACHE.items(), key=lambda kv: kv[1][0] + )[:200] + for k, _v in oldest_keys: + _RESPONSE_CACHE.pop(k, None) + + +def chat_cache_stats() -> dict: + """Diagnostic helper: hit rate and cache size.""" + total = _CACHE_HITS + _CACHE_MISSES + hit_rate = (_CACHE_HITS * 100 / total) if total else 0 + return { + 'entries': len(_RESPONSE_CACHE), + 'hits': _CACHE_HITS, + 'misses': _CACHE_MISSES, + 'hit_rate_pct': round(hit_rate, 1), + 'ttl_seconds': _CACHE_TTL_SECONDS, + } + + +def chat_cache_clear(): + """Manual cache invalidation — e.g. after inventory bulk changes.""" + _RESPONSE_CACHE.clear() + + def chat(user_message, conversation_history=None, inventory_context=None): """Send a message to the AI and get a response with search suggestions. + Caches responses for repeated identical questions (subject to bypass + rules — messages with VINs / part numbers / plates are never cached). + Args: user_message: The user's chat message. conversation_history: Previous messages in the conversation. inventory_context: Optional inventory summary string to inject into the system prompt. """ - _validate_model(MODEL) # Block paid models + # Cache lookup — only when there's no conversation history (stateless) + cache_key = None + if not conversation_history: + cache_key = _cache_key(user_message, inventory_context) + cached = _cache_get(cache_key) + if cached is not None: + print(f"[AI] Cache HIT for '{user_message[:40]}...'") + return cached system_content = SYSTEM_PROMPT if inventory_context: @@ -337,10 +489,11 @@ def chat(user_message, conversation_history=None, inventory_context=None): messages.extend(conversation_history) messages.append({"role": "user", "content": user_message}) - import time - max_retries = 3 + last_error = None - for attempt in range(max_retries): + # Try each model in the fallback chain on 429 (rate limit) + for model_id in FALLBACK_MODELS: + _validate_model(model_id) # Block paid models try: resp = requests.post( OPENROUTER_URL, @@ -349,23 +502,32 @@ def chat(user_message, conversation_history=None, inventory_context=None): "Content-Type": "application/json", }, json={ - "model": MODEL, + "model": model_id, "messages": messages, - "max_tokens": 500, + "max_tokens": 800, "temperature": 0.3, }, - timeout=20, + timeout=25, ) if resp.status_code == 429: - # Rate limited — wait and retry - wait = (attempt + 1) * 5 # 5s, 10s, 15s - if attempt < max_retries - 1: - time.sleep(wait) - continue - return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None} - resp.raise_for_status() + print(f"[AI] Rate limited on {model_id}, trying next model...") + last_error = "rate_limit" + continue + if resp.status_code >= 400: + print(f"[AI] HTTP {resp.status_code} on {model_id}: {resp.text[:200]}") + last_error = f"http_{resp.status_code}" + continue data = resp.json() - content = data["choices"][0]["message"]["content"] + choice = data.get("choices", [{}])[0] + content = choice.get("message", {}).get("content", "").strip() + finish = choice.get("finish_reason", "") + + if not content: + print(f"[AI] Empty response from {model_id} (finish={finish})") + last_error = "empty_response" + continue + + print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)") # Try to parse JSON response try: @@ -376,14 +538,27 @@ def chat(user_message, conversation_history=None, inventory_context=None): parsed = json.loads(json_str) else: parsed = json.loads(stripped) + # Successful JSON response — cache it + if cache_key: + _cache_set(cache_key, parsed) return parsed except (json.JSONDecodeError, IndexError): - return {"message": content, "search_query": None, "vehicle": None} + fallback = {"message": content, "search_query": None, "vehicle": None} + # Cache the fallback too — the model gave us a real answer, + # it just wasn't JSON. Next hit saves the API call. + if cache_key: + _cache_set(cache_key, fallback) + return fallback except Exception as e: - if attempt < max_retries - 1: - continue - return { - "message": f"Error de conexion: {str(e)}", - "search_query": None, - "vehicle": None, - } + print(f"[AI] Error with {model_id}: {e}") + last_error = str(e) + continue + + # All models exhausted — DON'T cache errors, we want retries next time + if last_error == "rate_limit": + return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None} + return { + "message": f"Error de conexion: {last_error}", + "search_query": None, + "vehicle": None, + } diff --git a/pos/services/catalog_modes.py b/pos/services/catalog_modes.py new file mode 100644 index 0000000..187f094 --- /dev/null +++ b/pos/services/catalog_modes.py @@ -0,0 +1,129 @@ +""" +Catalog modes — OEM vs Local bodega filtering for brand lists. + +Two catalog modes coexist: + + - 'oem' : Full TecDoc catalog (36+ vehicle brands from Apify import). + Use this for any customer-facing "find your exact OEM part" flow. + + - 'local' : Curated list of vehicle brands that local bodegas in Mexico + actually service. Used while the TecDoc/Apify import is paused + or to simplify navigation for customers who only care about + what's available locally. + +Both modes use the SAME navigation hierarchy (brand > model > year > engine > +category > parts). Only the initial brand list is filtered. + +Edit LOCAL_BODEGA_BRANDS below to add/remove brands as the bodega network grows. +Brand names must match the `brands.name_brand` column in nexus_autoparts +(case-sensitive, uppercase). +""" + +# ─── OEM mode — full North America coverage (imported from TecDoc) ────────── +OEM_BRANDS_NA = ( + 'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER', + 'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI', + 'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA', + 'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE', + 'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA', + 'VOLVO', 'VW', +) + +# ─── Local mode — brands actually stocked by Mexican bodegas ──────────────── +# Popular Mexican market passenger cars + light trucks. Edit as needed. +LOCAL_BODEGA_BRANDS = ( + 'NISSAN', # Tsuru, Sentra, Versa, March, Tiida, Navara + 'VW', # Jetta, Pointer, Vento, Gol, Polo, Beetle + 'CHEVROLET', # Aveo, Chevy, Spark, Beat, Sonic, Sail + 'FORD', # Fiesta, Focus, EcoSport, Ranger, Figo + 'TOYOTA', # Corolla, Yaris, Hilux, Avanza, Tacoma + 'HONDA', # Civic, City, CR-V, Fit, HR-V + 'DODGE', # Attitude, Neon, Journey + 'CHRYSLER', + 'RAM', # Pickups + 'HYUNDAI', # Accent, Grand i10, Tucson, Elantra + 'KIA', # Rio, Forte, Sportage, Sorento + 'MAZDA', # 2, 3, CX-5, CX-30 + 'MITSUBISHI', # Lancer, L200, Outlander + 'RENAULT', # Logan, Sandero, Duster, Stepway + 'SEAT', # Ibiza, Leon, Arona + 'FIAT', # Uno, Palio, Mobi + 'SUZUKI', # Swift, Vitara, Ignis, Ertiga + 'JEEP', # Compass, Wrangler, Grand Cherokee, Renegade + 'GMC', # Sierra, Terrain + 'BUICK', # Encore, Enclave (GM) +) + + +def get_brands_for_mode(mode): + """Return the tuple of allowed brand names for a given catalog mode. + + Args: + mode: 'oem' or 'local'. Anything else defaults to 'oem'. + + Returns: + A tuple of uppercase brand name strings. + """ + if mode == 'local': + return LOCAL_BODEGA_BRANDS + return OEM_BRANDS_NA + + +def normalize_mode(raw): + """Normalize a raw mode string from a query param or header.""" + if not raw: + return 'oem' + cleaned = str(raw).strip().lower() + return 'local' if cleaned == 'local' else 'oem' + + +# ─── Local mode — priority aftermarket manufacturer brands ───────────────── +# Ordered list. Brands earlier in the list are shown first and get the top +# "priority" badge in the UI. Match `manufacturers.name_manufacture` (uppercase). +# +# Tier 1 (most trusted / most stocked in Mexican bodegas) — shown first. +# Tier 2 (also popular but not always on every shelf) — shown second. +# Anything not in either list is "other" and shown last. +LOCAL_PRIORITY_MANUFACTURERS_TIER1 = ( + 'BOSCH', # Universal — ignition, sensors, filters, wipers + 'GATES', # Bandas / timing belts + 'MONROE', # Amortiguadores + 'DENSO', # Ignition, cooling, AC + 'MANN-FILTER', # Filtros + 'MAHLE', # Filtros, pistones, termostatos + 'NGK', # Bujias + 'BREMBO', # Frenos premium + 'KYB', # Amortiguadores + 'SKF', # Rodamientos +) + +LOCAL_PRIORITY_MANUFACTURERS_TIER2 = ( + 'DELPHI', + 'VALEO', + 'HELLA', + 'DAYCO', + 'SACHS', + 'CHAMPION', + 'WAGNER', + 'FRAM', + 'NSK', +) + +# Combined flat tuple (Tier1 followed by Tier2) — used for SQL IN clauses +# and for determining "any priority" status. +LOCAL_PRIORITY_MANUFACTURERS = LOCAL_PRIORITY_MANUFACTURERS_TIER1 + LOCAL_PRIORITY_MANUFACTURERS_TIER2 + + +def get_priority_tier(manufacturer_name): + """Return 1 for tier 1, 2 for tier 2, 3 for everything else. + + Used both by the sort order and by the UI to render a priority badge. + """ + if not manufacturer_name: + return 3 + name = manufacturer_name.upper() + if name in LOCAL_PRIORITY_MANUFACTURERS_TIER1: + return 1 + if name in LOCAL_PRIORITY_MANUFACTURERS_TIER2: + return 2 + return 3 diff --git a/pos/services/catalog_service.py b/pos/services/catalog_service.py index e994942..bc1d9d9 100644 --- a/pos/services/catalog_service.py +++ b/pos/services/catalog_service.py @@ -42,19 +42,22 @@ def _clean_model_name(name): # VEHICLE HIERARCHY NAVIGATION # ───────────────────────────────────────────────────────────────────────────── -NORTH_AMERICA_BRANDS = ( - 'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER', - 'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI', - 'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA', - 'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE', - 'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA', - 'VOLVO', 'VW', -) +from services.catalog_modes import get_brands_for_mode + +# Legacy alias — kept for backwards compatibility with any existing imports. +# Prefer `catalog_modes.OEM_BRANDS_NA` in new code. +NORTH_AMERICA_BRANDS = get_brands_for_mode('oem') -def get_brands(master_conn, year_id=None): - """Get vehicle brands available in Mexico/USA/Canada that have MYE entries. - If year_id is provided, only brands that have models for that year.""" +def get_brands(master_conn, year_id=None, mode='oem'): + """Get vehicle brands that have MYE entries, filtered by catalog mode. + + Args: + master_conn: Connection to the nexus_autoparts master DB. + year_id: Optional — only return brands with models for that year. + mode: 'oem' (full TecDoc coverage) or 'local' (curated bodega list). + """ + allowed = list(get_brands_for_mode(mode)) cur = master_conn.cursor() if year_id: cur.execute(""" @@ -64,7 +67,7 @@ def get_brands(master_conn, year_id=None): JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE b.name_brand = ANY(%s) AND mye.year_id = %s ORDER BY b.name_brand - """, (list(NORTH_AMERICA_BRANDS), year_id)) + """, (allowed, year_id)) else: cur.execute(""" SELECT DISTINCT b.id_brand, b.name_brand @@ -73,7 +76,7 @@ def get_brands(master_conn, year_id=None): JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE b.name_brand = ANY(%s) ORDER BY b.name_brand - """, (list(NORTH_AMERICA_BRANDS),)) + """, (allowed,)) rows = cur.fetchall() cur.close() return [{'id_brand': r[0], 'name_brand': r[1]} for r in rows] @@ -189,6 +192,509 @@ def get_categories(master_conn, mye_id): return [{'id_part_category': r[0], 'name': translate_category(r[1]), 'part_count': r[2]} for r in rows] +# ───────────────────────────────────────────────────────────────────────────── +# NEXPART HIERARCHY (Local mode) — filtered by what TecDoc has for this vehicle +# ───────────────────────────────────────────────────────────────────────────── + +# ─── In-memory cache for vehicle → Nexpart classification ───────────────── +# Key: mye_id (int). Value: (expires_at_timestamp, classified_dict). +# TTL is short (5 min) because catalog data rarely changes but we don't +# want stale data lingering across sessions. Single-process cache — +# Gunicorn workers each have their own, which is fine for this workload. +import time as _time +_CLASSIFY_CACHE = {} +_CLASSIFY_TTL_SECONDS = 300 + + +def _classify_cache_get(mye_id): + entry = _CLASSIFY_CACHE.get(mye_id) + if entry is None: + return None + expires_at, data = entry + if _time.time() > expires_at: + _CLASSIFY_CACHE.pop(mye_id, None) + return None + return data + + +def _classify_cache_set(mye_id, data): + _CLASSIFY_CACHE[mye_id] = (_time.time() + _CLASSIFY_TTL_SECONDS, data) + # Simple unbounded-growth protection: if cache grows past 500 entries, + # evict the oldest half. Real production would use an LRU library. + if len(_CLASSIFY_CACHE) > 500: + sorted_keys = sorted(_CLASSIFY_CACHE.items(), key=lambda kv: kv[1][0]) + for k, _v in sorted_keys[:250]: + _CLASSIFY_CACHE.pop(k, None) + + +def classify_cache_clear(): + """Manual cache invalidation — call after catalog import.""" + _CLASSIFY_CACHE.clear() + + +def classify_cache_stats(): + """Diagnostic helper for the cache state.""" + now = _time.time() + alive = sum(1 for expires, _ in _CLASSIFY_CACHE.values() if expires > now) + return { + 'total_entries': len(_CLASSIFY_CACHE), + 'alive': alive, + 'expired': len(_CLASSIFY_CACHE) - alive, + 'ttl_seconds': _CLASSIFY_TTL_SECONDS, + } + + +def _classify_vehicle_parts(master_conn, mye_id): + """Classify all TecDoc parts for a vehicle into Nexpart triples. + + Runs the matcher once per distinct part name, builds a nested dict: + { + "Brake System...": { + "Front Friction, Drums & Rotors": { + "Front Disc Brake Rotor": [oem_part_id, ...], + ... + }, + ... + }, + ... + } + + Drops parts whose name has no Nexpart equivalent (UNMAPPED_STRATEGY=drop). + Used by all 3 Nexpart-filtered functions below — cached by mye_id so + one navigation sequence (categories → subgroups → part types → parts) + does the classification work exactly once. + """ + # Cache hit — skip the query and matcher entirely + cached = _classify_cache_get(mye_id) + if cached is not None: + return cached + + from services.nexpart_taxonomy import tecdoc_to_nexpart + + cur = master_conn.cursor() + cur.execute(""" + SELECT p.id_part, p.name_part + FROM vehicle_parts vp + JOIN parts p ON p.id_part = vp.part_id + WHERE vp.model_year_engine_id = %s + """, (mye_id,)) + rows = cur.fetchall() + cur.close() + + classified = {} + for part_id, name_part in rows: + triple = tecdoc_to_nexpart(name_part) + if not triple: + continue # drop unmapped (Decision 2) + group, subgroup, part_type = triple + classified.setdefault(group, {}) \ + .setdefault(subgroup, {}) \ + .setdefault(part_type, []) \ + .append(part_id) + + _classify_cache_set(mye_id, classified) + return classified + + +def get_nexpart_groups_for_vehicle(master_conn, mye_id): + """Local mode: return Nexpart top-level groups that have parts for this vehicle. + + Output shape mirrors get_categories() but uses `slug` (string) instead of + integer category_id. Empty groups are dropped so the user only sees + categories with at least one matched part. + """ + from services.nexpart_taxonomy import ( + NEXPART_TAXONOMY, + translate_taxonomy_node, + ) + + classified = _classify_vehicle_parts(master_conn, mye_id) + + result = [] + # Iterate in canonical Nexpart order so the UI is stable + for group in NEXPART_TAXONOMY.keys(): + if group not in classified: + continue + # Count distinct part_types matched in this group across all subgroups + part_count = sum( + len(parts) + for subgroup_dict in classified[group].values() + for parts in subgroup_dict.values() + ) + result.append({ + 'slug': group, + 'name': translate_taxonomy_node(group), + 'name_en': group, + 'part_count': part_count, + }) + return result + + +def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug): + """Local mode: return Nexpart subgroups within a group that have vehicle parts.""" + from services.nexpart_taxonomy import ( + NEXPART_TAXONOMY, + translate_taxonomy_node, + ) + + classified = _classify_vehicle_parts(master_conn, mye_id) + group_data = classified.get(group_slug, {}) + if not group_data: + return [] + + # Iterate in the canonical order from NEXPART_TAXONOMY for stability + canonical_order = list(NEXPART_TAXONOMY.get(group_slug, {}).keys()) + + result = [] + for subgroup in canonical_order: + if subgroup not in group_data: + continue + part_count = sum(len(p) for p in group_data[subgroup].values()) + result.append({ + 'slug': subgroup, + 'name': translate_taxonomy_node(subgroup), + 'name_en': subgroup, + 'part_count': part_count, + }) + return result + + +# ═══════════════════════════════════════════════════════════════════════════ +# SHOP SUPPLIES — vehicle-independent parts (Network Shop Supplies tab) +# ═══════════════════════════════════════════════════════════════════════════ +# These live under 2 Nexpart groups that don't require a vehicle selection: +# - Chemicals, Waxes & Lubricants (oils, fluids, additives) +# - Tires, Wheels, Tools & Accessory Parts (TPMS, lug nuts, universal clips) +# +# The navigation skips the Year→Make→Model→Engine chain and goes directly +# to group selection. The query scans `parts` globally without joining +# `vehicle_parts` (which is HUGE), so it's fast. + +# The 2 Nexpart groups that are safely vehicle-independent. +_SHOP_SUPPLIES_GROUPS = ( + "Chemicals, Waxes & Lubricants", + "Tires, Wheels, Tools & Accessory Parts", +) + +# Maps each Nexpart Part Type in the universal groups to a list of SQL ILIKE +# patterns that match the actual TecDoc name_part values. This inverts the +# forward matcher (which goes TecDoc → Nexpart) — here we're asking "which +# TecDoc part names should be classified into this Nexpart Part Type?" +# +# Built by inspecting real name_part values in the parts table. Grow this +# map when you see shop supplies that are missing from the results. +SHOP_SUPPLIES_PATTERNS = { + # Chemicals, Waxes & Lubricants + "Engine Oil": ["Engine Oil"], + "Automatic Transmission Fluid": ["Automatic Transmission Fluid", "Transmission Oil"], + + # Tires & Wheels (TPMS + lug hardware) + "TPMS Sensor": ["TPMS%", "Tire Pressure Sensor%"], + "TPMS Programmable Sensor": ["%TPMS%Programmable%"], + "TPMS Sensor Service Kit": ["%TPMS%Service Kit%", "%TPMS%Repair Kit%"], + "TPMS Sensor Valve Assembly": ["%TPMS%Valve%"], + "TPMS Valve Kit": ["%TPMS%Valve Kit%", "Valve, tyre pressure control system"], + "TPMS Programmable Sensor Valve Kit": ["%TPMS%Programmable%Valve%"], + "Wheel Lug Nut": ["Wheel Nut"], + "Wheel Lug Stud": ["Wheel Bolt", "Wheel Stud"], + + # Bumper & License Plate (universal clips) + "Bumper Clip": ["%Clip%bumper%", "%bumper%Clip%"], + "Bumper Cover Retainer": ["%bumper cover%", "Retainer%bumper%"], + "Bumper Cover Trim Panel Retainer": ["%Retainer%trim%", "%bumper trim%"], + "License Plate Bracket Rivet": ["%license plate%rivet%", "%plate%bracket%"], + + # Hood, Fender & Body Parts (universal clips) + "Cowl Panel Retainer": ["%cowl%retainer%", "Clip, cowl%"], + "Door Sill Plate Clip": ["%door sill%clip%", "Clip, sill%"], + "Exterior Molding Clip": ["%Clip%trim%", "%trim%strip%Clip%"], + "Interior Panel Clip": ["Clip, trim%"], + "Rocker Panel Molding Retainer": ["%rocker%retainer%"], + "Spoiler Clip": ["Clip, spoiler%", "%spoiler%Clip%"], + "Undercar Shield Clip": ["%undercar shield%", "%underbody%Clip%"], + + # Tools, Jacks, Hardware & Manuals — mostly not present in TecDoc + "Cooling System Flush Gun Kit": ["%cooling system flush%"], + "Molding Clip": ["Clip, moulding%", "Clip, molding%"], + "Multi-Purpose Retainer": ["Retainer%", "Clip, mounting%"], + "Interior Panel Retainer": ["Retainer%panel%", "Clip, interior panel%"], + + # Interior & Steering Wheel — mostly connectors (sparse in TecDoc) + "Accelerator Pedal Sensor Connector": ["%accelerator pedal%connector%"], + "Clutch Pedal Position Switch Connector": ["%clutch pedal%connector%"], + "Console Trim Panel Clip": ["%console%clip%"], + + # Electronics Audio/Visual & Mirrors + "Antenna Mast": ["%antenna mast%", "%antenna%"], + "Interior Rear View Mirror Connector": ["%rear view mirror%connector%"], + "Interior Rear View Mirror Mounting Base": ["%mirror%mounting base%"], + "Keyless Entry Transmitter Cover": ["%keyless%cover%"], + "Lane Departure System Camera": ["%lane departure%"], +} + + +def _shop_supplies_count_by_part_type(master_conn, part_type_names): + """Given a list of Nexpart Part Type names (the Shop Supplies-eligible ones), + return {part_type: total_count} using the SHOP_SUPPLIES_PATTERNS map. + + Uses one query per Part Type because the patterns are OR'd via ILIKE and + we need a per-PT count. Still fast because patterns are indexed via + trigram if enabled, or just full-scan on 1.5M rows (~500ms total). + """ + result = {} + cur = master_conn.cursor() + for pt in part_type_names: + patterns = SHOP_SUPPLIES_PATTERNS.get(pt) + if not patterns: + continue + # Build a WHERE clause with multiple ILIKE ORs + like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns)) + cur.execute( + f"SELECT COUNT(*) FROM parts WHERE {like_parts}", + patterns, + ) + count = cur.fetchone()[0] or 0 + if count > 0: + result[pt] = count + cur.close() + return result + + +def _shop_supplies_oem_ids(master_conn, part_type_name, limit=5000): + """Return the OEM id_part values that match a Shop Supplies Part Type.""" + patterns = SHOP_SUPPLIES_PATTERNS.get(part_type_name) + if not patterns: + return [] + cur = master_conn.cursor() + like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns)) + cur.execute( + f"SELECT id_part FROM parts WHERE {like_parts} LIMIT %s", + patterns + [limit], + ) + ids = [row[0] for row in cur.fetchall()] + cur.close() + return ids + + +def get_shop_supplies_groups(): + """Return the 2 Nexpart groups that don't require a vehicle. + + Unlike the vehicle-scoped get_nexpart_groups_for_vehicle(), this returns + ALL subgroups of these groups regardless of whether there are matching + parts in the DB — that check happens at the subgroup level to avoid + scanning `parts` multiple times. + """ + from services.nexpart_taxonomy import ( + NEXPART_TAXONOMY, + translate_taxonomy_node, + ) + result = [] + for group in _SHOP_SUPPLIES_GROUPS: + if group not in NEXPART_TAXONOMY: + continue + subgroup_count = len(NEXPART_TAXONOMY[group]) + part_type_count = sum(len(pts) for pts in NEXPART_TAXONOMY[group].values()) + result.append({ + 'slug': group, + 'name': translate_taxonomy_node(group), + 'name_en': group, + 'part_count': part_type_count, # count of distinct Part Types, not parts + 'subgroup_count': subgroup_count, + }) + return result + + +def get_shop_supplies_subgroups(master_conn, group_slug): + """Return subgroups in a Shop Supplies group that have actual TecDoc parts.""" + from services.nexpart_taxonomy import ( + NEXPART_TAXONOMY, + translate_taxonomy_node, + ) + if group_slug not in _SHOP_SUPPLIES_GROUPS: + return [] + if group_slug not in NEXPART_TAXONOMY: + return [] + + subgroups = NEXPART_TAXONOMY[group_slug] + # Count each Part Type via the SHOP_SUPPLIES_PATTERNS map (ILIKE-based + # inverse search that handles naming gaps between Nexpart and TecDoc). + all_part_types = [pt for pts in subgroups.values() for pt in pts] + counts_by_pt = _shop_supplies_count_by_part_type(master_conn, all_part_types) + + result = [] + for sg_name, pt_list in subgroups.items(): + total = sum(counts_by_pt.get(pt, 0) for pt in pt_list) + if total == 0: + continue + result.append({ + 'slug': sg_name, + 'name': translate_taxonomy_node(sg_name), + 'name_en': sg_name, + 'part_count': total, + }) + return result + + +def get_shop_supplies_part_types(master_conn, group_slug, subgroup_slug): + """Return Part Types within a Shop Supplies subgroup that have TecDoc parts.""" + from services.nexpart_taxonomy import ( + NEXPART_TAXONOMY, + translate_taxonomy_node, + ) + if group_slug not in _SHOP_SUPPLIES_GROUPS: + return [] + subgroups = NEXPART_TAXONOMY.get(group_slug, {}) + part_types = subgroups.get(subgroup_slug, []) + if not part_types: + return [] + + counts_by_pt = _shop_supplies_count_by_part_type(master_conn, part_types) + + # Also fetch a sample image for each matched Part Type + cur = master_conn.cursor() + result = [] + for pt in part_types: + cnt = counts_by_pt.get(pt, 0) + if cnt == 0: + continue + patterns = SHOP_SUPPLIES_PATTERNS.get(pt, []) + if patterns: + like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns)) + cur.execute( + f"SELECT image_url FROM parts WHERE ({like_parts}) AND image_url IS NOT NULL LIMIT 1", + patterns, + ) + row = cur.fetchone() + sample_image = row[0] if row else None + else: + sample_image = None + result.append({ + 'slug': pt, + 'name': translate_taxonomy_node(pt), + 'name_en': pt, + 'variant_count': cnt, + 'sample_image': sample_image, + }) + cur.close() + return result + + +def get_shop_supplies_parts(master_conn, group_slug, subgroup_slug, part_type_slug, + tenant_conn, branch_id, page=1, per_page=30): + """Return paginated parts (with aftermarket enrichment) for a Shop Supplies triple. + + Same output shape as get_parts_for_nexpart_triple() — reuses get_parts_local + with an explicit OEM part ID list. + """ + from services.nexpart_taxonomy import NEXPART_TAXONOMY + + if group_slug not in _SHOP_SUPPLIES_GROUPS: + return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'} + + # Validate that the requested part type exists in the taxonomy + valid_pts = NEXPART_TAXONOMY.get(group_slug, {}).get(subgroup_slug, []) + if part_type_slug not in valid_pts: + return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'} + + # Fetch OEM IDs via the inverse-pattern map (handles TecDoc/Nexpart name gaps) + oem_part_ids = _shop_supplies_oem_ids(master_conn, part_type_slug) + if not oem_part_ids: + return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'} + + # Reuse the aftermarket-enriched query path + return get_parts_local( + master_conn, mye_id=None, group_id=None, + tenant_conn=tenant_conn, branch_id=branch_id, + page=page, per_page=per_page, + oem_part_ids=oem_part_ids, + ) + + +def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug, + part_type_slug, tenant_conn, branch_id, + page=1, per_page=30): + """Local mode: fetch parts (aftermarket-prioritized) for a Nexpart triple. + + Steps: + 1. Classify the vehicle's parts to find which OEM id_part values + map to (group, subgroup, part_type). + 2. Delegate to get_parts_local() with the resulting OEM part IDs. + + Returns the same shape as get_parts_local(). + """ + classified = _classify_vehicle_parts(master_conn, mye_id) + part_ids = ( + classified + .get(group_slug, {}) + .get(subgroup_slug, {}) + .get(part_type_slug, []) + ) + if not part_ids: + return { + 'data': [], + 'pagination': _pagination(page, per_page, 0), + 'mode': 'local', + } + return get_parts_local( + master_conn, mye_id=None, group_id=None, + tenant_conn=tenant_conn, branch_id=branch_id, + page=page, per_page=per_page, + oem_part_ids=part_ids, + ) + + +def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup_slug): + """Local mode: return Nexpart part types within a subgroup that have vehicle parts. + + Output shape matches get_part_types() so the frontend can render with + minimal branching: each item has slug + name + variant_count + sample_image. + """ + from services.nexpart_taxonomy import ( + NEXPART_TAXONOMY, + translate_taxonomy_node, + ) + + classified = _classify_vehicle_parts(master_conn, mye_id) + subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {}) + if not subgroup_data: + return [] + + # Pull a sample image for each part type — single query, all part_ids at once + all_part_ids = [ + pid + for pids in subgroup_data.values() + for pid in pids + ] + image_map = {} + if all_part_ids: + cur = master_conn.cursor() + cur.execute(""" + SELECT id_part, image_url + FROM parts + WHERE id_part = ANY(%s) AND image_url IS NOT NULL + """, (all_part_ids,)) + for pid, url in cur.fetchall(): + image_map[pid] = url + cur.close() + + canonical_order = NEXPART_TAXONOMY.get(group_slug, {}).get(subgroup_slug, []) + + result = [] + for pt in canonical_order: + if pt not in subgroup_data: + continue + part_ids = subgroup_data[pt] + sample_image = next((image_map[pid] for pid in part_ids if pid in image_map), None) + result.append({ + 'slug': pt, + 'name': translate_taxonomy_node(pt), + 'name_en': pt, + 'variant_count': len(part_ids), + 'sample_image': sample_image, + }) + return result + + def get_groups(master_conn, mye_id, category_id): """Get part groups (subcategories) for this vehicle + category, with part counts.""" cur = master_conn.cursor() @@ -209,16 +715,62 @@ def get_groups(master_conn, mye_id, category_id): return [{'id_part_group': r[0], 'name': r[1], 'part_count': r[2]} for r in rows] +def get_part_types(master_conn, mye_id, group_id): + """Get distinct part types within a vehicle+group (Nexpart-style 3rd subcategory level). + + A "part type" is a unique part name within a group — e.g. within "Brake System" + group, the types might be "Brake Disc", "Brake Pad", "Brake Caliper", each with + multiple OEM/aftermarket variants. + + Returns: [{name, slug, variant_count, sample_image}] + - name: display name (Spanish if available, else original) + - slug: URL-safe key used to filter parts (the original English name_part) + - variant_count: how many distinct OEM parts exist for this type + - sample_image: image URL of the first variant (for thumbnail) + """ + cur = master_conn.cursor() + # Use ORIGINAL name_part as the slug (matches DB column for filtering), + # but display the Spanish translation if available. + cur.execute(""" + SELECT + p.name_part AS slug, + COALESCE(p.name_es, p.name_part) AS display_name, + COUNT(*) AS variants, + (ARRAY_AGG(p.image_url) FILTER (WHERE p.image_url IS NOT NULL))[1] AS sample_image + FROM vehicle_parts vp + JOIN parts p ON p.id_part = vp.part_id + WHERE vp.model_year_engine_id = %s + AND p.group_id = %s + GROUP BY p.name_part, COALESCE(p.name_es, p.name_part) + ORDER BY variants DESC, display_name ASC + """, (mye_id, group_id)) + rows = cur.fetchall() + cur.close() + return [ + { + 'slug': r[0], + 'name': translate_part_name(r[1]), + 'variant_count': r[2], + 'sample_image': r[3], + } + for r in rows + ] + + # ───────────────────────────────────────────────────────────────────────────── # PARTS LIST + DETAIL (with stock enrichment) # ───────────────────────────────────────────────────────────────────────────── -def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per_page=30): +def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per_page=30, part_type=None): """Get parts for a vehicle + part group, enriched with local stock + bodega indicator. 1. Fetch parts from nexus_autoparts (vehicle_parts + parts) — paginated 2. For each OEM number, look up tenant inventory for local stock 3. For each part_id, check warehouse_inventory for bodega availability + + Optional part_type filter (string): when provided, only returns parts whose + name_part matches exactly. Used for the 3rd subcategory level (Nexpart-style). + Returns: {data: [...], pagination: {...}} """ per_page = min(per_page, 100) @@ -226,13 +778,20 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per cur = master_conn.cursor() + extra_where = "" + extra_params_count = (mye_id, group_id) + extra_params_fetch = (mye_id, group_id, per_page, offset) + if part_type: + extra_where = " AND p.name_part = %s" + extra_params_count = (mye_id, group_id, part_type) + extra_params_fetch = (mye_id, group_id, part_type, per_page, offset) + # Count total (bounded — uses indexed mye_id + group_id join) cur.execute(""" SELECT COUNT(*) FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id - WHERE vp.model_year_engine_id = %s AND p.group_id = %s - """, (mye_id, group_id)) + WHERE vp.model_year_engine_id = %s AND p.group_id = %s""" + extra_where, extra_params_count) total = cur.fetchone()[0] # Fetch page of parts @@ -241,10 +800,10 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per p.description, p.description_es, p.image_url FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id - WHERE vp.model_year_engine_id = %s AND p.group_id = %s + WHERE vp.model_year_engine_id = %s AND p.group_id = %s""" + extra_where + """ ORDER BY p.name_part LIMIT %s OFFSET %s - """, (mye_id, group_id, per_page, offset)) + """, extra_params_fetch) rows = cur.fetchall() if not rows: @@ -289,6 +848,185 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per return {'data': items, 'pagination': _pagination(page, per_page, total)} +def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id, + page=1, per_page=30, part_type=None, oem_part_ids=None): + """Local catalog mode: show aftermarket parts instead of OEM. + + Two filtering modes: + A) `oem_part_ids` provided → fetch aftermarket equivalents for that + specific list of OEM IDs. Used by get_parts_for_nexpart_triple() + (Nexpart navigation in Local mode). + B) `oem_part_ids` is None → use (mye_id, group_id, optional part_type) + to find OEM parts via vehicle_parts join. Legacy path for the + TecDoc-style Local navigation. + + Flow (mode B; mode A skips step 1 since IDs are already known): + 1. Find OEM parts for the vehicle+group. + 2. For each OEM part, pull all aftermarket equivalents. + 3. Join manufacturers to get brand name. + 4. Join warehouse_inventory to check bodega availability. + 5. Sort by priority tier, then in-stock first, then manufacturer name. + 6. Paginate. + + Returns: + {data: [...], pagination: {...}, mode: 'local'} + Each part item: manufacturer, priority_tier, in_stock_network, + warehouse_price, plus the standard fields. + """ + from services.catalog_modes import ( + LOCAL_PRIORITY_MANUFACTURERS_TIER1, + LOCAL_PRIORITY_MANUFACTURERS_TIER2, + get_priority_tier, + ) + + per_page = min(per_page, 100) + offset = (page - 1) * per_page + + cur = master_conn.cursor() + + tier1 = list(LOCAL_PRIORITY_MANUFACTURERS_TIER1) + tier2 = list(LOCAL_PRIORITY_MANUFACTURERS_TIER2) + + # ─── Build the WHERE clause for the OEM-side filter ─── + if oem_part_ids is not None: + # Mode A: explicit OEM ID list (Nexpart navigation) + where_clause = "p.id_part = ANY(%s)" + where_params_count = (oem_part_ids,) + from_join_count = """ + FROM parts p + JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part + JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id + """ + else: + # Mode B: vehicle+group filter (legacy TecDoc navigation) + from_join_count = """ + FROM vehicle_parts vp + JOIN parts p ON p.id_part = vp.part_id + JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part + JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id + """ + where_clause = "vp.model_year_engine_id = %s AND p.group_id = %s" + where_params_count = (mye_id, group_id) + if part_type: + where_clause += " AND p.name_part = %s" + where_params_count = (mye_id, group_id, part_type) + + # Count total aftermarket parts + cur.execute( + "SELECT COUNT(*) " + from_join_count + " WHERE " + where_clause, + where_params_count, + ) + total = cur.fetchone()[0] + + # Priority-sorted fetch — same WHERE clause as the COUNT, plus tiers + paging. + fetch_params = list(where_params_count) + [tier1, tier2, per_page, offset] + + cur.execute(""" + WITH aftermarket_for_vehicle AS ( + SELECT DISTINCT + ap.id_aftermarket_parts, + ap.oem_part_id, + ap.part_number, + COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name, + ap.price_usd, + m.name_manufacture, + p.oem_part_number, + COALESCE(p.name_es, p.name_part) AS oem_name, + COALESCE(p.description_es, p.description) AS oem_desc, + p.image_url AS oem_image + """ + from_join_count + """ + WHERE """ + where_clause + """ + ), + stock_per_oem AS ( + SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock + FROM warehouse_inventory + WHERE stock_quantity > 0 + GROUP BY part_id + ) + SELECT afv.id_aftermarket_parts, + afv.oem_part_id, + afv.part_number, + afv.am_name, + afv.price_usd, + afv.name_manufacture, + afv.oem_part_number, + afv.oem_name, + afv.oem_desc, + afv.oem_image, + COALESCE(s.bodega_count, 0) AS bodega_count, + s.min_price AS warehouse_price, + COALESCE(s.total_stock, 0) AS warehouse_stock, + CASE + WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 1 + WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 2 + ELSE 3 + END AS tier + FROM aftermarket_for_vehicle afv + LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id + ORDER BY tier ASC, + (COALESCE(s.bodega_count, 0) > 0) DESC, + afv.name_manufacture ASC, + afv.am_name ASC + LIMIT %s OFFSET %s + """, fetch_params) + + rows = cur.fetchall() + cur.close() + + if not rows: + return {'data': [], 'pagination': _pagination(page, per_page, total), 'mode': 'local'} + + # Enrich with tenant local stock (look up by OEM part number). + # Use a different name to avoid shadowing the `oem_part_ids` parameter. + oem_numbers = list({r[6] for r in rows if r[6]}) + result_oem_ids = list({r[1] for r in rows if r[1]}) + local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, result_oem_ids) + + items = [] + for r in rows: + aft_id = r[0] + oem_part_id = r[1] + aft_number = r[2] + aft_name = r[3] + price_usd = r[4] + manufacturer = r[5] + oem_number = r[6] + oem_name = r[7] + oem_desc = r[8] + oem_image = r[9] + bodega_count = r[10] + warehouse_price = r[11] + warehouse_stock = r[12] + tier = r[13] + + # Tenant local stock (refaccionaria's own inventory) + local = local_map.get(oem_number) or local_map.get(f'cat:{oem_part_id}') + image_url = (local.get('image_url') if local else None) or oem_image + + items.append({ + # Keep fields compatible with OEM mode output so the frontend + # can render with minimal branching. + 'id_part': oem_part_id, # OEM id used for detail drill-down + 'id_aftermarket': aft_id, # aftermarket row id (for future use) + 'oem_part_number': oem_number, + 'part_number': aft_number, # aftermarket SKU + 'name': translate_part_name(aft_name or oem_name), + 'description': oem_desc, + 'image_url': image_url, + 'manufacturer': manufacturer, + 'priority_tier': tier, # 1, 2, or 3 + 'local_stock': local['stock'] if local else 0, + 'local_price': local['price_1'] if local else None, + 'bodega_count': bodega_count, + 'warehouse_stock': warehouse_stock, + 'warehouse_price': float(warehouse_price) if warehouse_price is not None else None, + 'in_stock_network': bodega_count > 0, + 'price_usd': float(price_usd) if price_usd is not None else None, + }) + + return {'data': items, 'pagination': _pagination(page, per_page, total), 'mode': 'local'} + + def get_part_detail(master_conn, part_id, tenant_conn, branch_id): """Get full detail for a single part: catalog info, local stock, bodegas, alternatives. @@ -538,7 +1276,13 @@ def _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, catalog_part_ids) Returns: dict keyed by oem_number (or 'cat:{id}') -> {stock, price_1, ...} Matches by: part_number = oem_number OR catalog_part_id = id + + Public-catalog-safe: when tenant_conn is None (public browsing, no tenant + context) returns an empty dict so the parts list still renders without + local stock/price enrichment. """ + if tenant_conn is None: + return {} if not oem_numbers and not catalog_part_ids: return {} diff --git a/pos/services/marketplace_service.py b/pos/services/marketplace_service.py new file mode 100644 index 0000000..93e4bf6 --- /dev/null +++ b/pos/services/marketplace_service.py @@ -0,0 +1,810 @@ +""" +Marketplace B2B — service layer for bodegas, warehouse inventory and +Purchase Orders (Phase 1). + +State machine: + draft → submitted → confirmed → ready → delivered → closed + ↘ rejected (terminal) + +Public API is grouped by concern: + - Bodegas: list_bodegas, get_bodega, verify_bodega + - Inventory: upload_inventory_csv, search_inventory + - POs: create_po_draft, submit_po, transition_po, + get_po_detail, list_pos_for_buyer, list_pos_for_seller + - Notifications: notify_po_status_change (used internally by transition_po) + +All DB calls take a `master_conn` (psycopg2 connection to nexus_autoparts). +The caller is responsible for committing and closing. +""" + +import csv +import io +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Optional + + +# ═══════════════════════════════════════════════════════════════════════════ +# STATE MACHINE +# ═══════════════════════════════════════════════════════════════════════════ + +PO_STATUSES = ('draft', 'submitted', 'confirmed', 'rejected', 'ready', 'delivered', 'closed') + +# Map: current_status → {new_status: {actor_kinds}} +# 'buyer' = user who created the PO; 'seller' = bodega owner/user +PO_TRANSITIONS = { + 'draft': {'submitted': {'buyer'}}, + 'submitted': {'confirmed': {'seller'}, 'rejected': {'seller'}}, + 'confirmed': {'ready': {'seller'}}, + 'ready': {'delivered': {'buyer', 'seller'}}, + 'delivered': {'closed': {'buyer', 'seller'}}, + # terminal: rejected, closed +} + + +def _is_valid_transition(from_status: str, to_status: str, actor_kind: str) -> bool: + allowed = PO_TRANSITIONS.get(from_status, {}).get(to_status) + if not allowed: + return False + return actor_kind in allowed + + +# ═══════════════════════════════════════════════════════════════════════════ +# BODEGAS +# ═══════════════════════════════════════════════════════════════════════════ + +def list_bodegas(master_conn, verified_only: bool = True, city: str = None) -> list[dict]: + """Return all bodegas, optionally filtered.""" + cur = master_conn.cursor() + clauses = [] + params = [] + if verified_only: + clauses.append("verified = TRUE") + if city: + clauses.append("LOWER(city) = LOWER(%s)") + params.append(city) + where = "WHERE " + " AND ".join(clauses) if clauses else "" + cur.execute(f""" + SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state, verified + FROM bodegas + {where} + ORDER BY name + """, params) + rows = cur.fetchall() + cur.close() + return [ + { + 'id_bodega': r[0], 'name': r[1], 'owner_name': r[2], + 'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6], + 'verified': r[7], + } + for r in rows + ] + + +def get_bodega(master_conn, bodega_id: int) -> Optional[dict]: + cur = master_conn.cursor() + cur.execute(""" + SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state, + address, verified, commission_pct + FROM bodegas WHERE id_bodega = %s + """, (bodega_id,)) + r = cur.fetchone() + cur.close() + if not r: + return None + return { + 'id_bodega': r[0], 'name': r[1], 'owner_name': r[2], + 'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6], + 'address': r[7], 'verified': r[8], 'commission_pct': float(r[9] or 0), + } + + +def create_bodega(master_conn, *, name: str, whatsapp_phone: str, + owner_name: str = None, email: str = None, + city: str = None, state: str = None, address: str = None) -> int: + """Register a new bodega (unverified by default). Admin verifies later.""" + cur = master_conn.cursor() + cur.execute(""" + INSERT INTO bodegas (name, owner_name, whatsapp_phone, email, city, state, address) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id_bodega + """, (name, owner_name, whatsapp_phone, email, city, state, address)) + bodega_id = cur.fetchone()[0] + cur.close() + return bodega_id + + +def verify_bodega(master_conn, bodega_id: int) -> bool: + cur = master_conn.cursor() + cur.execute(""" + UPDATE bodegas SET verified = TRUE, verified_at = NOW() WHERE id_bodega = %s + """, (bodega_id,)) + ok = cur.rowcount > 0 + cur.close() + return ok + + +# ═══════════════════════════════════════════════════════════════════════════ +# INVENTORY — warehouse_inventory CSV upload + search +# ═══════════════════════════════════════════════════════════════════════════ + +def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict: + """Bulk-upload a bodega's inventory from a CSV string. + + Expected columns (case-insensitive, whitespace-tolerant): + part_number, stock, price + Optional: + min_order, warehouse_location, currency + + Resolution rules: + - part_number matches `parts.oem_part_number` exactly (case-sensitive). + - Parts not found in the master catalog are skipped and reported. + - Existing rows for (bodega_id, part_id, warehouse_location) are updated + via UPSERT; new rows are inserted. + + Returns a summary dict: {ok, inserted, updated, skipped, errors} + """ + reader = csv.DictReader(io.StringIO(csv_text)) + # Normalize header names + fieldnames = [f.strip().lower() for f in (reader.fieldnames or [])] + + required = {'part_number', 'stock', 'price'} + missing = required - set(fieldnames) + if missing: + return { + 'ok': False, + 'error': f'Columnas faltantes en CSV: {", ".join(sorted(missing))}', + 'inserted': 0, 'updated': 0, 'skipped': 0, + } + + # Resolve bodega → its legacy user_id (warehouse_inventory still requires it) + cur = master_conn.cursor() + cur.execute("SELECT id_bodega FROM bodegas WHERE id_bodega = %s", (bodega_id,)) + if not cur.fetchone(): + cur.close() + return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'} + + inserted = 0 + updated = 0 + skipped = 0 + errors = [] + + for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers + norm = {k.strip().lower(): (v or '').strip() for k, v in row.items()} + part_number = norm.get('part_number', '') + stock_str = norm.get('stock', '0') + price_str = norm.get('price', '0') + + if not part_number: + errors.append(f'Fila {i}: part_number vacio') + skipped += 1 + continue + + try: + stock = int(stock_str) + price = float(price_str) + except ValueError: + errors.append(f'Fila {i}: stock o price invalido') + skipped += 1 + continue + + # Resolve part_number → part_id + cur.execute( + "SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1", + (part_number,) + ) + row_part = cur.fetchone() + if not row_part: + errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo') + skipped += 1 + continue + part_id = row_part[0] + + # Resolve user_id from the bodega (use bodega_id as fallback if null) + user_id = norm.get('user_id') or bodega_id # backward compat + try: + user_id = int(user_id) + except (ValueError, TypeError): + user_id = bodega_id + + location = norm.get('warehouse_location') or 'Principal' + currency = (norm.get('currency') or 'MXN').upper() + min_order = int(norm.get('min_order') or 1) + + # UPSERT on (user_id, part_id, warehouse_location) — the existing + # unique constraint. Don't block if user_id FK fails. + try: + cur.execute(""" + INSERT INTO warehouse_inventory + (user_id, part_id, price, stock_quantity, min_order_quantity, + warehouse_location, bodega_id, currency, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) + ON CONFLICT (user_id, part_id, warehouse_location) + DO UPDATE SET + price = EXCLUDED.price, + stock_quantity = EXCLUDED.stock_quantity, + min_order_quantity = EXCLUDED.min_order_quantity, + bodega_id = EXCLUDED.bodega_id, + currency = EXCLUDED.currency, + updated_at = NOW() + RETURNING (xmax = 0) AS inserted + """, (user_id, part_id, price, stock, min_order, location, bodega_id, currency)) + was_insert = cur.fetchone()[0] + if was_insert: + inserted += 1 + else: + updated += 1 + except Exception as e: + errors.append(f'Fila {i}: DB error: {str(e)[:100]}') + skipped += 1 + master_conn.rollback() # so next INSERTs can proceed + continue + + cur.close() + master_conn.commit() + + return { + 'ok': True, + 'inserted': inserted, + 'updated': updated, + 'skipped': skipped, + 'errors': errors[:20], # cap to avoid huge responses + 'total_errors': len(errors), + } + + +def search_inventory(master_conn, *, query: str = None, brand: str = None, + city: str = None, limit: int = 50) -> list[dict]: + """Browse warehouse_inventory filtered by query / brand / city. + + Returns parts WITH stock > 0 from VERIFIED bodegas only. + Aggregates identical parts across bodegas so the buyer sees each part once + with a list of bodegas that have it in stock. + """ + cur = master_conn.cursor() + + clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"] + params = [] + + if query: + clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)") + like = f'%{query}%' + params.extend([like, like, like]) + + if brand: + # Search by vehicle brand via vehicle_parts → model_year_engine → models → brands. + # Too slow for this MVP. Instead, match on aftermarket manufacturer name. + clauses.append(""" + EXISTS ( + SELECT 1 FROM aftermarket_parts ap + JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id + WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s) + ) + """) + params.append(brand) + + if city: + clauses.append("LOWER(b.city) = LOWER(%s)") + params.append(city) + + where_sql = " AND ".join(clauses) + + cur.execute(f""" + SELECT + p.id_part, + p.oem_part_number, + COALESCE(p.name_es, p.name_part) AS name, + p.image_url, + COUNT(DISTINCT b.id_bodega) AS bodega_count, + MIN(wi.price) AS min_price, + MAX(wi.price) AS max_price, + SUM(wi.stock_quantity) AS total_stock, + -- List of bodega names that have this part in stock + ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names + FROM warehouse_inventory wi + JOIN bodegas b ON b.id_bodega = wi.bodega_id + JOIN parts p ON p.id_part = wi.part_id + WHERE {where_sql} + GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url + ORDER BY total_stock DESC + LIMIT %s + """, params + [limit]) + + rows = cur.fetchall() + cur.close() + + return [ + { + 'id_part': r[0], + 'oem_part_number': r[1], + 'name': r[2], + 'image_url': r[3], + 'bodega_count': r[4], + 'min_price': float(r[5]) if r[5] is not None else None, + 'max_price': float(r[6]) if r[6] is not None else None, + 'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar', + 'bodega_names': r[8], # may expose; adjust if sensitive + } + for r in rows + ] + + +def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]: + """Return the list of verified bodegas that currently have a given OEM part + in stock. Used when the buyer wants to pick WHICH bodega to order from. + """ + cur = master_conn.cursor() + cur.execute(""" + SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone, + wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency + FROM warehouse_inventory wi + JOIN bodegas b ON b.id_bodega = wi.bodega_id + WHERE wi.part_id = %s AND wi.stock_quantity > 0 AND b.verified = TRUE + ORDER BY wi.price ASC + """, (part_id,)) + rows = cur.fetchall() + cur.close() + return [ + { + 'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3], + 'price': float(r[4]) if r[4] is not None else None, + 'stock_hint': 'En stock', # don't expose exact quantity + 'min_order': r[6] or 1, + 'currency': r[7] or 'MXN', + } + for r in rows + ] + + +# ═══════════════════════════════════════════════════════════════════════════ +# PURCHASE ORDERS +# ═══════════════════════════════════════════════════════════════════════════ + +def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int, + buyer_display_name: str, buyer_phone: str, buyer_email: str, + bodega_id: int, items: list, + delivery_method: str = 'pickup', + delivery_address: str = None, + buyer_notes: str = None) -> int: + """Create a PO in 'draft' status with its items. + + Args: + items: list of dicts with keys: part_id, quantity, unit_price (optional) + If unit_price is missing, it's pulled from warehouse_inventory. + + Returns the new po_id. + """ + if not items: + raise ValueError('A PO must have at least one item') + + cur = master_conn.cursor() + + # Create header + cur.execute(""" + INSERT INTO purchase_orders ( + buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email, + bodega_id, status, delivery_method, delivery_address, buyer_notes + ) VALUES (%s, %s, %s, %s, %s, %s, 'draft', %s, %s, %s) + RETURNING id_po + """, ( + buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email, + bodega_id, delivery_method, delivery_address, buyer_notes, + )) + po_id = cur.fetchone()[0] + + # Insert items + total = 0.0 + for item in items: + part_id = int(item['part_id']) + quantity = int(item['quantity']) + if quantity < 1: + continue + + # Lookup part info + price + cur.execute(""" + SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price + FROM parts p + LEFT JOIN warehouse_inventory wi + ON wi.part_id = p.id_part AND wi.bodega_id = %s + WHERE p.id_part = %s LIMIT 1 + """, (bodega_id, part_id)) + r = cur.fetchone() + if not r: + continue + oem, name, db_price = r + unit_price = float(item.get('unit_price') or db_price or 0) + subtotal = round(unit_price * quantity, 2) + total += subtotal + + cur.execute(""" + INSERT INTO purchase_order_items + (po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes'))) + + # Update header total + cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s", + (round(total, 2), po_id)) + + # Log initial status + cur.execute(""" + INSERT INTO po_status_history (po_id, from_status, to_status, actor_user_id, actor_kind, note) + VALUES (%s, NULL, 'draft', %s, 'buyer', 'PO creado') + """, (po_id, buyer_user_id)) + + cur.close() + master_conn.commit() + return po_id + + +def transition_po(master_conn, *, po_id: int, new_status: str, + actor_user_id: int, actor_kind: str, + note: str = None) -> dict: + """Transition a PO to a new status with full validation and notification. + + Returns: {ok, from_status, to_status, notified} or {ok: False, error} + """ + if new_status not in PO_STATUSES: + return {'ok': False, 'error': f'Invalid status: {new_status}'} + + cur = master_conn.cursor() + cur.execute("SELECT status FROM purchase_orders WHERE id_po = %s FOR UPDATE", (po_id,)) + row = cur.fetchone() + if not row: + cur.close() + return {'ok': False, 'error': f'PO {po_id} not found'} + + from_status = row[0] + if not _is_valid_transition(from_status, new_status, actor_kind): + cur.close() + return { + 'ok': False, + 'error': f'Transition {from_status}→{new_status} not allowed for {actor_kind}', + } + + # Timestamp columns per state + ts_field = { + 'submitted': 'submitted_at', + 'confirmed': 'confirmed_at', + 'ready': 'ready_at', + 'delivered': 'delivered_at', + 'closed': 'closed_at', + }.get(new_status) + + if ts_field: + cur.execute( + f"UPDATE purchase_orders SET status = %s, {ts_field} = NOW() WHERE id_po = %s", + (new_status, po_id), + ) + else: + cur.execute("UPDATE purchase_orders SET status = %s WHERE id_po = %s", + (new_status, po_id)) + + # Log history row + cur.execute(""" + INSERT INTO po_status_history + (po_id, from_status, to_status, actor_user_id, actor_kind, note) + VALUES (%s, %s, %s, %s, %s, %s) + """, (po_id, from_status, new_status, actor_user_id, actor_kind, note)) + + cur.close() + master_conn.commit() + + # Fire notifications — non-blocking (failures logged, not raised) + notified = [] + try: + notified = notify_po_status_change(master_conn, po_id, new_status) + except Exception as e: + print(f'[marketplace] notification failed for PO {po_id}: {e}') + + return { + 'ok': True, + 'from_status': from_status, + 'to_status': new_status, + 'notified': notified, + } + + +def get_po_detail(master_conn, po_id: int) -> Optional[dict]: + cur = master_conn.cursor() + cur.execute(""" + SELECT po.id_po, po.buyer_tenant_id, po.buyer_user_id, po.buyer_display_name, + po.buyer_phone, po.buyer_email, + po.bodega_id, b.name AS bodega_name, b.whatsapp_phone AS bodega_phone, + b.email AS bodega_email, + po.status, po.total_amount, po.currency, + po.buyer_notes, po.seller_notes, + po.delivery_method, po.delivery_address, + po.created_at, po.submitted_at, po.confirmed_at, po.ready_at, + po.delivered_at, po.closed_at + FROM purchase_orders po + JOIN bodegas b ON b.id_bodega = po.bodega_id + WHERE po.id_po = %s + """, (po_id,)) + r = cur.fetchone() + if not r: + cur.close() + return None + + po = { + 'id_po': r[0], 'buyer_tenant_id': r[1], 'buyer_user_id': r[2], + 'buyer_display_name': r[3], 'buyer_phone': r[4], 'buyer_email': r[5], + 'bodega_id': r[6], 'bodega_name': r[7], + 'bodega_phone': r[8], 'bodega_email': r[9], + 'status': r[10], + 'total_amount': float(r[11]) if r[11] is not None else 0.0, + 'currency': r[12], + 'buyer_notes': r[13], 'seller_notes': r[14], + 'delivery_method': r[15], 'delivery_address': r[16], + 'created_at': r[17].isoformat() if r[17] else None, + 'submitted_at': r[18].isoformat() if r[18] else None, + 'confirmed_at': r[19].isoformat() if r[19] else None, + 'ready_at': r[20].isoformat() if r[20] else None, + 'delivered_at': r[21].isoformat() if r[21] else None, + 'closed_at': r[22].isoformat() if r[22] else None, + } + + # Items + cur.execute(""" + SELECT id_po_item, part_id, oem_part_number, part_name, manufacturer, + quantity, unit_price, subtotal, confirmed_qty, notes + FROM purchase_order_items WHERE po_id = %s ORDER BY id_po_item + """, (po_id,)) + po['items'] = [ + { + 'id_po_item': ir[0], 'part_id': ir[1], 'oem_part_number': ir[2], + 'part_name': ir[3], 'manufacturer': ir[4], + 'quantity': ir[5], + 'unit_price': float(ir[6]) if ir[6] is not None else 0.0, + 'subtotal': float(ir[7]) if ir[7] is not None else 0.0, + 'confirmed_qty': ir[8], + 'notes': ir[9], + } + for ir in cur.fetchall() + ] + + # Status history + cur.execute(""" + SELECT from_status, to_status, actor_kind, note, created_at + FROM po_status_history WHERE po_id = %s ORDER BY created_at + """, (po_id,)) + po['history'] = [ + { + 'from_status': h[0], 'to_status': h[1], 'actor_kind': h[2], + 'note': h[3], 'at': h[4].isoformat() if h[4] else None, + } + for h in cur.fetchall() + ] + cur.close() + return po + + +def list_pos_for_buyer(master_conn, buyer_tenant_id: int, buyer_user_id: int = None, + limit: int = 50) -> list[dict]: + """Return POs created by a buyer (filtered by tenant or user).""" + cur = master_conn.cursor() + clauses = ['po.buyer_tenant_id = %s'] + params = [buyer_tenant_id] + if buyer_user_id is not None: + clauses.append('po.buyer_user_id = %s') + params.append(buyer_user_id) + where = ' AND '.join(clauses) + cur.execute(f""" + SELECT po.id_po, po.status, po.total_amount, po.currency, + po.bodega_id, b.name AS bodega_name, + po.created_at, po.submitted_at, + (SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count + FROM purchase_orders po + JOIN bodegas b ON b.id_bodega = po.bodega_id + WHERE {where} + ORDER BY po.created_at DESC + LIMIT %s + """, params + [limit]) + rows = cur.fetchall() + cur.close() + return [ + { + 'id_po': r[0], 'status': r[1], + 'total_amount': float(r[2]) if r[2] is not None else 0.0, + 'currency': r[3], + 'bodega_id': r[4], 'bodega_name': r[5], + 'created_at': r[6].isoformat() if r[6] else None, + 'submitted_at': r[7].isoformat() if r[7] else None, + 'item_count': r[8], + } + for r in rows + ] + + +def list_pos_for_seller(master_conn, bodega_id: int, limit: int = 50) -> list[dict]: + """Inbox: POs addressed to a seller (bodega).""" + cur = master_conn.cursor() + cur.execute(""" + SELECT po.id_po, po.status, po.total_amount, po.currency, + po.buyer_tenant_id, po.buyer_display_name, po.buyer_phone, + po.created_at, po.submitted_at, + (SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count + FROM purchase_orders po + WHERE po.bodega_id = %s AND po.status != 'draft' + ORDER BY + CASE po.status + WHEN 'submitted' THEN 1 + WHEN 'confirmed' THEN 2 + WHEN 'ready' THEN 3 + ELSE 4 + END, + po.submitted_at DESC + LIMIT %s + """, (bodega_id, limit)) + rows = cur.fetchall() + cur.close() + return [ + { + 'id_po': r[0], 'status': r[1], + 'total_amount': float(r[2]) if r[2] is not None else 0.0, + 'currency': r[3], + 'buyer_tenant_id': r[4], 'buyer_display_name': r[5], 'buyer_phone': r[6], + 'created_at': r[7].isoformat() if r[7] else None, + 'submitted_at': r[8].isoformat() if r[8] else None, + 'item_count': r[9], + } + for r in rows + ] + + +# ═══════════════════════════════════════════════════════════════════════════ +# NOTIFICATIONS — WhatsApp + Email +# ═══════════════════════════════════════════════════════════════════════════ + +# Per-status message templates. Each is a (subject, body) tuple. +# The body is plain text — same text goes to WA and email, with an optional +# HTML wrapper for email. +_PO_MESSAGE_TEMPLATES = { + 'submitted': ( + 'Nuevo pedido Nexus #{po_id}', + 'Tienes un nuevo pedido en Nexus Marketplace.\n\n' + 'Pedido: #{po_id}\n' + 'Comprador: {buyer_display_name}\n' + 'Total: ${total_amount:,.2f} {currency}\n' + 'Items: {item_count}\n\n' + 'Entra al marketplace para confirmar o rechazar.' + ), + 'confirmed': ( + 'Pedido #{po_id} confirmado por {bodega_name}', + 'Tu pedido fue confirmado.\n\n' + 'Pedido: #{po_id}\n' + 'Bodega: {bodega_name}\n' + 'Total: ${total_amount:,.2f} {currency}\n\n' + 'Te avisaremos cuando este listo para recoger / entregar.' + ), + 'rejected': ( + 'Pedido #{po_id} rechazado', + 'Tu pedido fue rechazado por {bodega_name}.\n\n' + 'Pedido: #{po_id}\n' + 'Puedes intentar con otra bodega en el marketplace.' + ), + 'ready': ( + 'Pedido #{po_id} listo', + 'Tu pedido esta listo.\n\n' + 'Pedido: #{po_id}\n' + 'Bodega: {bodega_name}\n' + 'Metodo: {delivery_method}\n\n' + 'Pasa a recogerlo o espera la entrega.' + ), + 'delivered': ( + 'Pedido #{po_id} entregado', + 'El pedido #{po_id} fue marcado como entregado.\n' + 'Gracias por usar Nexus Marketplace.' + ), + 'closed': ( + 'Pedido #{po_id} cerrado', + 'El pedido #{po_id} fue cerrado.' + ), +} + + +def notify_po_status_change(master_conn, po_id: int, new_status: str) -> list[str]: + """Send WhatsApp + email notification about a PO status change. + + Returns a list of channel names that were successfully notified + (e.g. ['whatsapp', 'email']). Failures are logged but not raised. + """ + template = _PO_MESSAGE_TEMPLATES.get(new_status) + if not template: + return [] # no message defined for this status + + po = get_po_detail(master_conn, po_id) + if not po: + return [] + + # Resolve context variables for the template + ctx = { + 'po_id': po_id, + 'buyer_display_name': po.get('buyer_display_name') or 'Cliente', + 'bodega_name': po.get('bodega_name') or 'Bodega', + 'total_amount': po.get('total_amount') or 0, + 'currency': po.get('currency') or 'MXN', + 'delivery_method': po.get('delivery_method') or 'pickup', + 'item_count': len(po.get('items') or []), + } + subject_tpl, body_tpl = template + try: + subject = subject_tpl.format(**ctx) + body = body_tpl.format(**ctx) + except (KeyError, ValueError) as e: + print(f'[marketplace] template format error for {new_status}: {e}') + return [] + + # Decide the recipient based on who should be notified for this status + # - submitted → notify seller (new order arrived) + # - confirmed/rejected/ready → notify buyer (status update) + # - delivered → notify both (handled as 2 sends) + # - closed → notify buyer + recipients = [] + if new_status == 'submitted': + recipients = [{ + 'kind': 'seller', + 'phone': po.get('bodega_phone'), + 'email': po.get('bodega_email'), + }] + elif new_status in ('confirmed', 'rejected', 'ready', 'closed'): + recipients = [{ + 'kind': 'buyer', + 'phone': po.get('buyer_phone'), + 'email': po.get('buyer_email'), + }] + elif new_status == 'delivered': + recipients = [ + {'kind': 'buyer', 'phone': po.get('buyer_phone'), 'email': po.get('buyer_email')}, + {'kind': 'seller', 'phone': po.get('bodega_phone'), 'email': po.get('bodega_email')}, + ] + + channels_used = [] + for recipient in recipients: + # WhatsApp + if recipient.get('phone'): + try: + from services import whatsapp_service + result = whatsapp_service.send_message(recipient['phone'], body) + if result and not result.get('error'): + channels_used.append(f"whatsapp:{recipient['kind']}") + except Exception as e: + print(f'[marketplace] WA send failed: {e}') + + # Email + if recipient.get('email'): + try: + sent = _send_email(recipient['email'], subject, body) + if sent: + channels_used.append(f"email:{recipient['kind']}") + except Exception as e: + print(f'[marketplace] email send failed: {e}') + + return channels_used + + +def _send_email(to_email: str, subject: str, body_text: str) -> bool: + """Send a plain-text email via SMTP (config in pos/config.py). + + Returns True if the mail was actually sent, False if SMTP is not + configured (silent no-op so dev environments don't crash). + """ + import config + if not config.SMTP_USER or not config.SMTP_PASS: + print('[marketplace] SMTP not configured — skipping email') + return False + + msg = MIMEMultipart('alternative') + msg['From'] = config.SMTP_FROM + msg['To'] = to_email + msg['Subject'] = subject + msg.attach(MIMEText(body_text, 'plain', 'utf-8')) + + with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as server: + server.starttls() + server.login(config.SMTP_USER, config.SMTP_PASS) + server.send_message(msg) + print(f'[marketplace] email sent to {to_email}: {subject}') + return True diff --git a/pos/services/nexpart_taxonomy.py b/pos/services/nexpart_taxonomy.py new file mode 100644 index 0000000..b3e1a22 --- /dev/null +++ b/pos/services/nexpart_taxonomy.py @@ -0,0 +1,745 @@ +""" +Nexpart Taxonomy — Universal parts classification used in Local catalog mode. + +Source of truth: /home/Autopartes/CapturasWeb/nexpart_hierarchy.txt +Total: 14 Groups → 103 Subgroups → 558 Part Types + +This module loads the Nexpart hierarchy from the .txt file and provides +helpers to: + 1. List all groups / subgroups / part types + 2. Map a TecDoc `parts.name_part` value to (group, subgroup, part_type) + 3. Translate any node name to Spanish using the existing translations.py + +Business decisions (locked in by user 2026-04-08): + 1. AMBIGUITY: first match wins (the order in nexpart_hierarchy.txt is + Nexpart's own canonical order, so the first match is also Nexpart's + primary classification). + 2. UNMAPPED: drop. Parts without a clean Nexpart match do NOT appear in + Local mode. Local mode is intentionally smaller and more consistent. + 3. LANGUAGE: bilingual via translations.py — single source of truth. + The hierarchy is stored in English; the UI translates each node + on-the-fly using `translate_taxonomy_node()`. +""" + +import os +import re +from typing import Optional + +# ============================================================================ +# CONSTANTS +# ============================================================================ + +UNMAPPED_STRATEGY = "drop" +LANGUAGE_STRATEGY = "bilingual_taxonomy" + +# Path to the source-of-truth hierarchy text file +_HIERARCHY_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "..", "CapturasWeb", "nexpart_hierarchy.txt" +) + + +# ============================================================================ +# HIERARCHY PARSER +# ============================================================================ + +# The list of valid groups, in canonical order (matches Nexpart's own order +# from the screenshots). Used to disambiguate "is this line a group header?" +# from "is this line a subgroup name?" — both can be capitalized. +_KNOWN_GROUPS = ( + "IGNITION & FILTERS", + "BELTS, HOSES, WATER PUMPS & COOLING SYSTEM PARTS", + "STARTING & CHARGING SYSTEM PARTS (ALTERNATORS, BATTERIES & CABLES)", + "BRAKE SYSTEM, WHEEL BEARINGS, STUDS, NUTS & HARDWARE", + "FUEL & EMISSIONS PARTS", + "HEATING & AIR CONDITIONING", + "ENGINE PARTS", + "DRIVETRAIN PARTS", + "STEERING & SUSPENSION PARTS", + "EXHAUST, CLUTCH & FLYWHEEL PARTS", + "WIPERS, LAMPS & FUSES", + "BODY PARTS, CABLES, CAPS, ELECTRICAL MOTORS, SWITCHES & OTHER MISCELLANEOUS PARTS", + "CHEMICALS, WAXES & LUBRICANTS", + "TIRES, WHEELS, TOOLS & ACCESSORY PARTS", +) + + +def _parse_hierarchy_file() -> dict: + """Parse nexpart_hierarchy.txt into a nested dict. + + Returns: + { + "Ignition & Filters": { + "Computers & Relays": ["Engine Control Module (ECM)", ...], + ... + }, + ... + } + """ + taxonomy = {} + current_group = None + current_subgroup = None + + if not os.path.exists(_HIERARCHY_PATH): + return taxonomy + + with open(_HIERARCHY_PATH, "r", encoding="utf-8") as f: + for line in f: + line = line.rstrip("\n") + + # Skip comments, blank lines, and decoration rules + if not line or line.startswith("#"): + continue + if set(line.strip()) <= {"═", " "}: + continue + if line.strip() == "SUMMARY": + break # End-of-file marker + + # Group header: ALL CAPS line that matches a known group + if line.strip().upper() in _KNOWN_GROUPS: + # Convert to title case for display, preserving the original + # casing from the .txt file (which already mixes Title Case) + current_group = line.strip().title() \ + .replace("Ac ", "AC ") \ + .replace("Pcv", "PCV") \ + .replace("Ecm", "ECM") \ + .replace("Cv ", "CV ") \ + .replace("Vvt", "VVT") \ + .replace("Tpms", "TPMS") \ + .replace("Hvac", "HVAC") \ + .replace("Abs ", "ABS ") \ + .replace("Egr", "EGR") + taxonomy.setdefault(current_group, {}) + current_subgroup = None + continue + + # Part type: lines with leading " - " + if line.lstrip().startswith("- "): + if current_group and current_subgroup: + pt = line.lstrip()[2:].strip() + taxonomy[current_group][current_subgroup].append(pt) + continue + + # Subgroup: a non-empty line that's not a comment, not a header, + # not a part type, and starts with a non-space character. + if line[0] not in (" ", "\t"): + current_subgroup = line.strip() + if current_group: + taxonomy[current_group].setdefault(current_subgroup, []) + + return taxonomy + + +# Load at import time +NEXPART_TAXONOMY = _parse_hierarchy_file() + + +# ============================================================================ +# FLAT INDEX FOR FAST LOOKUP +# ============================================================================ +# Building these once at import time means O(1) lookups during requests. + +def _build_indexes(): + """Build flat lookup tables from the nested taxonomy.""" + # part_type_lower → list of (group, subgroup, original_part_type) + # We use lowercase keys so the matcher is case-insensitive. + part_type_index = {} + all_part_types = [] # ordered list, in canonical Nexpart order + + for group, subgroups in NEXPART_TAXONOMY.items(): + for subgroup, part_types in subgroups.items(): + for pt in part_types: + key = pt.strip().lower() + part_type_index.setdefault(key, []).append((group, subgroup, pt)) + all_part_types.append((group, subgroup, pt)) + return part_type_index, all_part_types + + +_PART_TYPE_INDEX, _ALL_PART_TYPES = _build_indexes() + + +# ============================================================================ +# DECISION 1 — RESOLVE AMBIGUITY (first-match wins) +# ============================================================================ + +# Manual overrides for ambiguous part names. Key = lowercase TecDoc name +# (as fed to the matcher). Value = the subgroup WHERE the part should +# canonically live when a mechanic thinks about it. +# +# These beat the first-match rule. Add entries when you see that your users +# expect a part in a different subgroup than the one Nexpart's canonical +# order picks. Leave empty at start — grow incrementally from feedback. +# +# Example: a Mexican mechanic troubleshooting a failed emissions test will +# look for an O2 sensor under "Catalytic Converter" (system-level thinking), +# not "Emission Sensors, Relays, Solenoids & Switches" (component-level). +AMBIGUITY_OVERRIDES = { + # tecdoc name (lowercase) -> preferred subgroup name (exact string) + # (populated as real usage surfaces mismatches) + # 'oxygen sensor': 'Catalytic Converter', +} + + +def resolve_ambiguous_subgroup(tecdoc_name: str, candidates: list) -> tuple: + """Pick the canonical (group, subgroup, part_type) for an ambiguous name. + + Resolution order: + 1. AMBIGUITY_OVERRIDES dict — manual curation wins over everything. + 2. First-match in canonical Nexpart order (Decision 1 locked in). + + Search by the user still finds the part from anywhere via the flat + index; the override only affects which subgroup the part "lives in" + during hierarchical navigation. + + Args: + tecdoc_name: e.g. "Oxygen Sensor" + candidates: list of (group, subgroup, part_type) tuples + + Returns: + A single (group, subgroup, part_type) tuple. + """ + # 1. Manual override wins + key = (tecdoc_name or '').strip().lower() + preferred_subgroup = AMBIGUITY_OVERRIDES.get(key) + if preferred_subgroup: + for cand in candidates: + if cand[1] == preferred_subgroup: + return cand + # Override pointed to a subgroup not in the candidate set — + # log and fall through to first-match. + # (Using print to stay import-free; swap for logger if available.) + print(f"[taxonomy] AMBIGUITY_OVERRIDES['{key}'] = '{preferred_subgroup}' " + f"not in candidates {[c[1] for c in candidates]}; falling back") + + # 2. First-match in canonical order + return candidates[0] + + +# ============================================================================ +# DECISION 2 — UNMAPPED HANDLING (drop) +# ============================================================================ +# When a TecDoc name doesn't match any Nexpart Part Type, the matcher +# returns None and the caller filters it out of Local mode results. + + +# ============================================================================ +# CORE MATCHER: tecdoc_to_nexpart() +# ============================================================================ + +def tecdoc_to_nexpart(tecdoc_name: str) -> Optional[tuple]: + """Map a TecDoc part name to its Nexpart (group, subgroup, part_type). + + Matching strategy (in order of preference): + 1. Exact match (case-insensitive) on the full Part Type name. + 2. Substring match — TecDoc name CONTAINS a known Part Type. + Example: "Front Brake Pad Set" contains "Brake Pad Set" → match. + 3. Reverse substring — known Part Type contains the TecDoc name. + Example: TecDoc "Wiper" matches Nexpart "Wiper Arm". Less precise, + used as last resort. + + Args: + tecdoc_name: value from `parts.name_part` (English) + + Returns: + (group, subgroup, part_type) if matched, None otherwise. + Per Decision 2, callers should filter out None values. + """ + if not tecdoc_name: + return None + + name_lower = tecdoc_name.strip().lower() + if not name_lower: + return None + + # 1. Exact match + if name_lower in _PART_TYPE_INDEX: + candidates = _PART_TYPE_INDEX[name_lower] + return resolve_ambiguous_subgroup(tecdoc_name, candidates) + + # 2. Substring match (TecDoc contains Nexpart Part Type) + # Prefer the LONGEST match — more specific wins on a tie of position. + best_match = None + best_len = 0 + for pt_key, candidates in _PART_TYPE_INDEX.items(): + if pt_key in name_lower and len(pt_key) > best_len: + best_match = candidates + best_len = len(pt_key) + if best_match: + return resolve_ambiguous_subgroup(tecdoc_name, best_match) + + # 3. Reverse substring (Nexpart Part Type contains TecDoc) — last resort + for pt_key, candidates in _PART_TYPE_INDEX.items(): + if name_lower in pt_key and len(name_lower) >= 4: + # Min length 4 to avoid false matches on short words like "Cap" + return resolve_ambiguous_subgroup(tecdoc_name, candidates) + + return None + + +# ============================================================================ +# DECISION 3 — BILINGUAL VIA translations.py +# ============================================================================ + +# Curated translations for the 14 top-level groups + common subgroups. +# These are full-string (not substring) so they always win over the partial +# matcher in translations.py and produce clean Spanish display. +TAXONOMY_OVERRIDES_ES = { + # ─── Top-level groups (14) ─── + "Ignition & Filters": "Encendido y Filtros", + "Belts, Hoses, Water Pumps & Cooling System Parts": "Bandas, Mangueras, Bombas de Agua y Sistema de Enfriamiento", + "Starting & Charging System Parts (Alternators, Batteries & Cables)": "Sistema de Arranque y Carga (Alternadores, Baterías y Cables)", + "Brake System, Wheel Bearings, Studs, Nuts & Hardware": "Sistema de Frenos, Baleros, Birlos, Tuercas y Ferretería", + "Fuel & Emissions Parts": "Combustible y Emisiones", + "Heating & Air Conditioning": "Calefacción y Aire Acondicionado", + "Engine Parts": "Partes de Motor", + "Drivetrain Parts": "Tren Motriz", + "Steering & Suspension Parts": "Dirección y Suspensión", + "Exhaust, Clutch & Flywheel Parts": "Escape, Clutch y Volante", + "Wipers, Lamps & Fuses": "Limpiaparabrisas, Luces y Fusibles", + "Body Parts, Cables, Caps, Electrical Motors, Switches & Other Miscellaneous Parts": "Carrocería, Cables, Tapones, Motores Eléctricos, Switches y Misceláneos", + "Chemicals, Waxes & Lubricants": "Químicos, Ceras y Lubricantes", + "Tires, Wheels, Tools & Accessory Parts": "Llantas, Rines, Herramientas y Accesorios", + + # ─── Common subgroups (the most-used ones; expand as needed) ─── + "Filters & PCV": "Filtros y PCV", + "Spark Plugs & Glow Plugs": "Bujías", + "Tune-Up & Ignition Parts": "Afinación y Encendido", + "Belts, Tensioners & Pulleys": "Bandas, Tensores y Poleas", + "Radiators & Electric Fan Motors": "Radiadores y Motoventiladores", + "Thermostats, Housings & Radiator Caps": "Termostatos, Carcasas y Tapones de Radiador", + "Water Pumps, Fan Blades & Clutches": "Bombas de Agua, Aspas y Fan Clutches", + "Alternators & Voltage Regulators": "Alternadores y Reguladores de Voltaje", + "Batteries": "Baterías", + "Starters": "Marchas / Arrancadores", + "ABS Controls & Parts": "Controles y Partes de ABS", + "Front Friction, Drums & Rotors": "Frenos Delanteros: Pastillas, Tambores y Discos", + "Rear Friction, Drums & Rotors": "Frenos Traseros: Pastillas, Tambores y Discos", + "Front Wheel Bearings & Seals": "Baleros y Sellos de Rueda Delantera", + "Rear Wheel Bearings & Seals": "Baleros y Sellos de Rueda Trasera", + "Master Cylinders, Boosters & Switches": "Cilindros Maestros, Boosters y Switches", + "Fuel Pumps & Tanks": "Bombas y Tanques de Gasolina", + "Fuel Injection Parts, Mass Air Flow Sensors": "Inyección, Sensores MAF", + "Turbochargers & Superchargers": "Turbos y Compresores", + "AC Compressors, Kits & Parts": "Compresores de A/C y Kits", + "AC Condensers & Evaporators": "Condensadores y Evaporadores de A/C", + "Cams, Lifters & Timing Parts": "Árboles de Levas, Buzos y Distribución", + "Crankshafts & Bearings": "Cigüeñales y Metales", + "Pistons, Rings & Rods": "Pistones, Anillos y Bielas", + "Heads & Manifolds": "Cabezas y Múltiples", + "Engine Mounts & Other Miscellaneous Engine Parts": "Soportes de Motor y Otros", + "Driveshafts, U-Joints & CV (Constant Velocity) Parts": "Flechas, Crucetas y Juntas Homocinéticas", + "Automatic Transmission Seals": "Sellos de Transmisión Automática", + "Manual Transmission Seals": "Sellos de Transmisión Manual", + "Transmission & Parts": "Transmisión y Partes", + "Ball Joints & Control Arms": "Rótulas y Horquillas", + "Shock Absorbers & Struts": "Amortiguadores y Strut", + "Steering Linkages, Rods & Arms": "Direcciones, Bieletas y Brazos", + "Sway Bars, Stabilizer Bars, Strut Rods & Parts": "Barras Estabilizadoras y Tornillos", + "All Exhaust & Diagrams": "Sistema de Escape Completo", + "Catalytic Converter": "Convertidor Catalítico", + "Clutches & Clutch Kits": "Clutches y Kits", + "Manifolds & Headers": "Múltiples y Headers", + "Arms, Blades & Refills": "Brazos, Plumas y Repuestos", + "Headlamps & Flashers": "Faros y Direccionales", + "Exterior Lamps": "Luces Exteriores", + "Interior Lamps": "Luces Interiores", + "Wiper Motors & Washer Pumps": "Motores de Limpia y Bombas de Agua", + "Bumpers & License Plates": "Defensas y Placas", + "Door, Window & Tailgate Parts": "Puertas, Ventanas y Cajuela", + "Engine & Transmission Lubricants & Additives": "Aceites de Motor y Transmisión", + "Tires & Wheels": "Llantas y Rines", + "Tools, Jacks, Hardware & Manuals": "Herramientas, Gatos y Hardware", + + # ─── Remaining subgroups (phase 2 translation coverage) ─── + "Computers & Relays": "Computadoras y Relés", + "Ignition Wires": "Cables de Bujía", + "Miscellaneous Ignition Parts": "Conectores y Misceláneos de Encendido", + "Engine Coolant & Bypass Hoses": "Mangueras de Refrigerante y Bypass", + "Heater & Other Hoses": "Mangueras de Calefacción y Otras", + "Sensors, Switches & Relays": "Sensores, Switches y Relés", + "Starter Solenoids, Switches & Relays": "Solenoides de Marcha, Switches y Relés", + "Brake Cables, Studs, Nuts & Spindle Nuts": "Cables, Birlos y Tuercas de Freno", + "Front Brake Hardware & ABS Sensors": "Ferretería y Sensores ABS Delanteros", + "Front Calipers, Wheel Cylinders, Hoses": "Calipers, Cilindros y Mangueras Delanteras", + "Miscellaneous Disc Hardware": "Ferretería Misceláneo de Disco", + "Miscellaneous Drum Hardware": "Ferretería Misceláneo de Tambor", + "Miscellaneous Hydraulic Parts & Brake Specifications": "Hidráulica y Especificaciones de Freno", + "Rear Brake Hardware & ABS Sensors": "Ferretería y Sensores ABS Traseros", + "Rear Calipers, Wheel Cylinders, Hoses": "Calipers, Cilindros y Mangueras Traseras", + "Carburetors, Carburetor Kits & Components": "Carburadores, Kits y Componentes", + "EGR & Emissions Valves": "EGR y Válvulas de Emisiones", + "Emission Sensors, Relays, Solenoids & Switches": "Sensores de Emisiones, Relés, Solenoides y Switches", + "Fuel Injection Harnesses, Connectors & Miscellaneous Parts": "Arneses, Conectores e Inyección Misceláneos", + "Fuel Injection Sensors, Relays & Switches": "Sensores, Relés y Switches de Inyección", + "AC Accumulators, Receiver Driers & Valves": "Acumuladores, Secadores y Válvulas de A/C", + "AC Hose Assemblies & Fittings": "Mangueras y Conexiones de A/C", + "AC Relays & Switches": "Relés y Switches de A/C", + "AC, Heating & Ventilation Gaskets, O-Rings, Kits, Doors & Actuators": "Juntas, O-Rings, Puertas y Actuadores A/C", + "Blower Motors & Parts": "Motores de Ventilador y Partes", + "Heater Cores & Heater Control Valves": "Radiadores de Calefacción y Válvulas", + "Engine Block Parts": "Partes de Bloque de Motor", + "Engines & Kits": "Motores y Kits", + "Gasket Sets": "Juegos de Juntas", + "Individual Gaskets & Seals": "Juntas y Sellos Individuales", + "Intake & Exhaust Valves": "Válvulas de Admisión y Escape", + "Rockers & Push Rods": "Balancines y Varillas de Empuje", + "Vacuum & Oil Pumps": "Bombas de Vacío y Aceite", + "Axle & Differential Parts": "Partes de Eje y Diferencial", + "Electronics, Sensors, Relays & Miscellaneous Parts": "Electrónica, Sensores y Misceláneos", + "Manual Transmission Bearings": "Baleros de Transmisión Manual", + "Spindles & Hubs": "Husillos y Mazas", + "Transmission Kits & Gaskets": "Kits y Juntas de Transmisión", + "Alignment Kits & Tools": "Kits y Herramientas de Alineación", + "King Pins, Trailing Arms, Alignment & Other Chassis": "Pivotes, Brazos y Otros de Chasis", + "Power Steering Pumps, Hoses & Kits": "Bombas, Mangueras y Kits de Dirección Hidráulica", + "Rack & Pinion, Gear Box, Power Cylinder": "Cremallera, Caja de Dirección y Cilindro", + "Clutch Hydraulics": "Hidráulica de Clutch", + "Individual Exhaust Parts": "Partes de Escape Individuales", + "Miscellaneous Clutch Parts": "Partes Misceláneas de Clutch", + "Lighting Modules & Switches": "Módulos y Switches de Iluminación", + "Lighting Relays & Sensors": "Relés y Sensores de Luces", + "Caps": "Tapones", + "Cruise Control Parts": "Partes de Control de Crucero", + "Electrical Motors": "Motores Eléctricos", + "Glass": "Cristales", + "Hood & Tailgate Parts": "Partes de Cofre y Cajuela", + "Hoods Fenders & Body Parts": "Cofres, Salpicaderas y Carrocería", + "Lift Supports": "Amortiguadores de Cofre/Cajuela", + "Switches, Relays & Miscellaneous Parts": "Switches, Relés y Misceláneos", + "Wheel & Hardware": "Rines y Ferretería", + "Bumper & License Plate": "Defensas y Placas", + "Electronics Audio/Visual & Mirrors": "Electrónica, Audio y Espejos", + "Hood, Fender & Body Parts": "Cofre, Salpicaderas y Carrocería", + "Interior & Steering Wheel": "Interior y Volante", + + # ─── High-value part types (most-searched in real use) ─── + # Ignition & Filters + "Engine Control Module (ECM)": "Módulo de Control del Motor (ECM)", + "Ignition Relay": "Relé de Encendido", + "Transmission Control Module": "Módulo de Control de Transmisión", + "Engine Air Filter": "Filtro de Aire del Motor", + "Engine Oil Filter": "Filtro de Aceite del Motor", + "Engine Oil Filter Adapter": "Adaptador de Filtro de Aceite", + "Engine Oil Filter Housing": "Carcasa de Filtro de Aceite", + "Vapor Canister": "Canister de Vapor", + "Vapor Canister Purge Valve": "Válvula de Purga del Canister", + "Vapor Canister Purge Solenoid": "Solenoide de Purga del Canister", + "Spark Plug Set": "Juego de Bujías", + "Direct Ignition Coil": "Bobina de Encendido Directo", + "Ignition Coil": "Bobina de Encendido", + "Ignition Kit": "Kit de Encendido", + + # Belts / Cooling + "Engine Timing Belt": "Banda de Distribución", + "Engine Timing Belt Component Kit": "Kit de Componentes de Distribución", + "Engine Timing Belt Kit with Water Pump": "Kit de Distribución con Bomba de Agua", + "Engine Timing Chain": "Cadena de Distribución", + "Engine Timing Chain Guide": "Guía de Cadena de Distribución", + "Engine Timing Chain Tensioner": "Tensor de Cadena de Distribución", + "Accessory Drive Belt Tensioner Assembly": "Tensor de Banda Accesoria", + "Accessory Drive Belt Tensioner Pulley": "Polea Tensora de Banda Accesoria", + "Serpentine Belt": "Banda Serpentina", + "Radiator": "Radiador", + "Radiator Coolant Hose": "Manguera de Refrigerante del Radiador", + "Engine Coolant Reservoir": "Depósito de Refrigerante", + "Engine Water Pump": "Bomba de Agua del Motor", + "Engine Water Pump Gasket": "Junta de Bomba de Agua", + "Engine Water Pump Pulley": "Polea de Bomba de Agua", + "Engine Coolant Thermostat": "Termostato de Refrigerante", + "Engine Coolant Thermostat Housing": "Carcasa de Termostato", + "Engine Coolant Temperature Sensor": "Sensor de Temperatura de Refrigerante", + "Engine Cooling Fan": "Ventilador de Enfriamiento", + "Engine Cooling Fan Assembly": "Conjunto de Ventilador de Enfriamiento", + "HVAC Heater Hose": "Manguera de Calefacción HVAC", + + # Starting & Charging + "Alternator": "Alternador", + "Vehicle Battery": "Batería del Vehículo", + "Starter": "Marcha / Arrancador", + "Ignition Lock Cylinder": "Switch de Encendido (Cilindro)", + "Ignition Switch": "Switch de Encendido", + + # Brake System + "ABS Wheel Speed Sensor": "Sensor de Velocidad de Rueda ABS", + "Front Disc Brake Pad Set": "Juego de Pastillas Delanteras", + "Rear Disc Brake Pad Set": "Juego de Pastillas Traseras", + "Front Disc Brake Rotor": "Disco de Freno Delantero", + "Rear Disc Brake Rotor": "Disco de Freno Trasero", + "Front Disc Brake Caliper": "Caliper de Freno Delantero", + "Rear Disc Brake Caliper": "Caliper de Freno Trasero", + "Front Brake Hydraulic Hose": "Manguera Hidráulica Delantera", + "Rear Brake Hydraulic Hose": "Manguera Hidráulica Trasera", + "Brake Master Cylinder": "Cilindro Maestro de Frenos", + "Power Brake Booster": "Booster de Frenos", + "Front Wheel Bearing": "Balero de Rueda Delantera", + "Rear Wheel Bearing": "Balero de Rueda Trasera", + "Front Wheel Bearing and Hub Assembly": "Balero y Maza Delantera", + "Rear Wheel Bearing and Hub Assembly": "Balero y Maza Trasera", + "Wheel Lug Nut": "Tuerca de Rueda (Birlo)", + "Wheel Lug Stud": "Birlo de Rueda", + + # Fuel & Emissions + "Electric Fuel Pump": "Bomba Eléctrica de Gasolina", + "Fuel Pump Module Assembly": "Conjunto de Módulo de Bomba de Gasolina", + "Fuel Level Sensor": "Sensor de Nivel de Gasolina", + "Fuel Tank Cap": "Tapón de Tanque de Gasolina", + "Fuel Injector": "Inyector de Gasolina", + "Fuel Injector Set": "Juego de Inyectores", + "Fuel Injection Throttle Body": "Cuerpo de Aceleración", + "Mass Air Flow Sensor": "Sensor MAF (Flujo de Aire)", + "Oxygen Sensor": "Sensor de Oxígeno", + "Engine Camshaft Position Sensor": "Sensor de Posición de Árbol de Levas", + "Engine Crankshaft Position Sensor": "Sensor de Posición del Cigüeñal", + "Engine Knock Sensor": "Sensor de Detonación", + "Manifold Absolute Pressure Sensor": "Sensor MAP (Presión Absoluta)", + "Turbocharger": "Turbocargador", + + # Heating & AC + "A/C Compressor": "Compresor de A/C", + "A/C Condenser": "Condensador de A/C", + "A/C Evaporator Core": "Evaporador de A/C", + "A/C Expansion Valve": "Válvula de Expansión de A/C", + "A/C Receiver Drier/Desiccant Element": "Filtro Deshidratador de A/C", + "A/C Hose Assembly": "Manguera de A/C", + "HVAC Blower Motor": "Motor de Ventilador HVAC", + "HVAC Blower Motor Resistor": "Resistencia de Ventilador HVAC", + "HVAC Heater Core": "Radiador de Calefacción", + "HVAC Blend Door Actuator": "Actuador de Puerta de Mezcla", + + # Engine Parts + "Engine Camshaft": "Árbol de Levas", + "Engine Harmonic Balancer": "Damper / Polea del Cigüeñal", + "Engine Crankshaft Main Bearing Set": "Juego de Metales de Bancada", + "Engine Piston": "Pistón", + "Engine Piston Ring Set": "Juego de Anillos de Pistón", + "Engine Connecting Rod Bearing Set": "Juego de Metales de Biela", + "Engine Cylinder Head Gasket": "Junta de Cabeza de Cilindros", + "Engine Cylinder Head Bolt Set": "Juego de Tornillos de Cabeza", + "Engine Intake Manifold": "Múltiple de Admisión", + "Engine Intake Manifold Gasket": "Junta de Múltiple de Admisión", + "Engine Valve Cover": "Tapa de Válvulas", + "Engine Valve Cover Gasket": "Junta de Tapa de Válvulas", + "Engine Oil Pan": "Cárter de Aceite", + "Engine Oil Pan Gasket": "Junta de Cárter", + "Engine Oil Pump": "Bomba de Aceite", + "Engine Oil Pressure Sender": "Sensor de Presión de Aceite", + "Engine Oil Pressure Switch": "Switch de Presión de Aceite", + "Engine Mount": "Soporte de Motor", + "Engine Rocker Arm": "Balancín", + "Engine Exhaust Valve": "Válvula de Escape", + "Engine Intake Valve": "Válvula de Admisión", + "Engine Valve Spring": "Resorte de Válvula", + "Engine Valve Stem Oil Seal": "Sello de Válvula", + + # Drivetrain + "CV Axle Assembly": "Flecha Homocinética Completa", + "CV Axle Shaft": "Flecha Homocinética", + "Automatic Transmission Mount": "Soporte de Transmisión Automática", + "Automatic Transmission Oil Cooler": "Enfriador de Aceite de Transmisión", + "Automatic Transmission Oil Pan": "Cárter de Transmisión Automática", + "Manual Transmission Mount": "Soporte de Transmisión Manual", + "Transmission Filter Kit": "Kit de Filtro de Transmisión", + "Transmission Oil Pan": "Cárter de Transmisión", + "Spindle Nut": "Tuerca de Husillo", + "Vehicle Speed Sensor": "Sensor de Velocidad del Vehículo", + + # Steering & Suspension + "Suspension Ball Joint": "Rótula de Suspensión", + "Suspension Control Arm Bushing": "Buje de Horquilla", + "Suspension Control Arm and Ball Joint Assembly": "Horquilla con Rótula", + "Suspension Shock Absorber": "Amortiguador", + "Suspension Strut": "Strut de Suspensión", + "Suspension Strut Assembly": "Conjunto de Strut", + "Suspension Strut Mount": "Base de Strut", + "Suspension Stabilizer Bar Link": "Terminal de Barra Estabilizadora", + "Steering Tie Rod End": "Terminal de Dirección", + "Rack and Pinion Assembly": "Cremallera de Dirección", + "Steering Column": "Columna de Dirección", + + # Exhaust/Clutch + "Catalytic Converter": "Convertidor Catalítico", + "Catalytic Converter Gasket": "Junta de Convertidor Catalítico", + "Exhaust Manifold": "Múltiple de Escape", + "Exhaust Manifold Gasket": "Junta de Múltiple de Escape", + "Exhaust Muffler": "Mofle", + "Exhaust Muffler Assembly": "Conjunto de Mofle", + "Exhaust Pipe": "Tubo de Escape", + "Exhaust Clamp": "Abrazadera de Escape", + "Clutch Slave Cylinder": "Cilindro Esclavo de Clutch", + "Transmission Clutch Kit": "Kit de Clutch", + + # Wipers/Lamps + "Wiper Arm": "Brazo de Limpiaparabrisas", + "Wiper Blade": "Pluma Limpiaparabrisas", + "Wiper Motor": "Motor de Limpiaparabrisas", + "Wiper Switch": "Switch de Limpiaparabrisas", + "Headlight Bulb": "Foco de Faro", + "Tail Light Bulb": "Foco de Calavera", + "Brake Light Bulb": "Foco de Freno", + "Turn Signal Light Bulb": "Foco Direccional", + "Fog Light Bulb": "Foco Antiniebla", + "Back Up Light Bulb": "Foco de Reversa", + "License Plate Light Bulb": "Foco de Placa", + "Dome Light Bulb": "Foco de Domo", + "Washer Fluid Reservoir Cap": "Tapón de Depósito de Limpiaparabrisas", + "Headlight Switch": "Switch de Luces", + "Turn Signal Switch": "Switch de Direccionales", + "Multi-Function Switch": "Switch Multifunciones", + "Hazard Warning Switch": "Switch de Intermitentes", + + # Body / Electrical / Misc + "Door Lock Actuator": "Actuador de Cerradura", + "Door Lock Actuator Motor": "Motor de Actuador de Cerradura", + "Window Motor": "Motor de Ventana", + "Window Regulator": "Elevador de Ventana", + "Window Motor and Regulator Assembly": "Motor y Elevador de Ventana", + "Sunroof Motor": "Motor de Quemacocos", + "Exterior Door Handle": "Manija Exterior de Puerta", + "Interior Door Handle": "Manija Interior de Puerta", + "Door Mirror Glass": "Cristal de Espejo", + "Horn Relay": "Relé de Claxon", + "Liftgate Lift Support": "Amortiguador de Cajuela", + "Cruise Control Switch": "Switch de Control de Crucero", + "Engine Coolant Reservoir Cap": "Tapón de Depósito de Refrigerante", + "Engine Oil Filler Cap": "Tapón de Llenado de Aceite", + "Radiator Cap": "Tapón de Radiador", + "TPMS Sensor": "Sensor TPMS", + "TPMS Programmable Sensor": "Sensor TPMS Programable", + + # Chemicals / Tools + "Automatic Transmission Fluid": "Aceite de Transmisión Automática", + "Engine Oil": "Aceite de Motor", +} + + +def translate_taxonomy_node(english_name: str) -> str: + """Translate a Nexpart group / subgroup / part type to Spanish. + + STRICT lookup only — no partial substitution. The order: + 1. TAXONOMY_OVERRIDES_ES — full-string curated translations. + 2. PART_TRANSLATIONS exact match (from services.translations). + 3. Fallback: return the English original UNCHANGED. + + Why strict-only: partial substitution within a compound name produces + ugly hybrids ("Front Tambor de Freno", "Engine Filtro de Aceite"). + For taxonomy display we'd rather show clean English than dirty Spanish. + Untranslated entries are visible reminders to extend the override dict. + + Args: + english_name: the canonical English name (group, subgroup, or part type) + + Returns: + Spanish display string, or the English original if no exact match. + """ + if not english_name: + return english_name + + # 1. Curated overrides (highest priority) + if english_name in TAXONOMY_OVERRIDES_ES: + return TAXONOMY_OVERRIDES_ES[english_name] + + # 2. Exact match in PART_TRANSLATIONS + try: + from services.translations import PART_TRANSLATIONS + if english_name in PART_TRANSLATIONS: + return PART_TRANSLATIONS[english_name] + except ImportError: + pass + + # 3. Fallback — return English unchanged + return english_name + + +def list_untranslated_nodes() -> dict: + """Diagnostic helper: list every taxonomy node missing a Spanish entry. + + Useful for filling in TAXONOMY_OVERRIDES_ES incrementally — run this + in a one-off script to see exactly what still needs translation. + + Returns: + {"groups": [...], "subgroups": [...], "part_types": [...]} + """ + try: + from services.translations import PART_TRANSLATIONS + known = set(PART_TRANSLATIONS.keys()) | set(TAXONOMY_OVERRIDES_ES.keys()) + except ImportError: + known = set(TAXONOMY_OVERRIDES_ES.keys()) + + missing = {"groups": [], "subgroups": [], "part_types": []} + for group, subgroups in NEXPART_TAXONOMY.items(): + if group not in known: + missing["groups"].append(group) + for subgroup, part_types in subgroups.items(): + if subgroup not in known: + missing["subgroups"].append(subgroup) + for pt in part_types: + if pt not in known: + missing["part_types"].append(pt) + return missing + + +# ============================================================================ +# PUBLIC API — used by catalog_service / blueprints +# ============================================================================ + +def get_groups() -> list: + """Return the 14 top-level groups in canonical order. + + Each item: {"name": english, "name_es": spanish, "subgroup_count": int} + """ + return [ + { + "name": group, + "name_es": translate_taxonomy_node(group), + "subgroup_count": len(subgroups), + } + for group, subgroups in NEXPART_TAXONOMY.items() + ] + + +def get_subgroups(group_name: str) -> list: + """Return all subgroups for a given group. + + Each item: {"name": english, "name_es": spanish, "part_type_count": int} + """ + subgroups = NEXPART_TAXONOMY.get(group_name, {}) + return [ + { + "name": subgroup, + "name_es": translate_taxonomy_node(subgroup), + "part_type_count": len(part_types), + } + for subgroup, part_types in subgroups.items() + ] + + +def get_part_types(group_name: str, subgroup_name: str) -> list: + """Return all part types within a group + subgroup. + + Each item: {"name": english, "name_es": spanish} + """ + subgroups = NEXPART_TAXONOMY.get(group_name, {}) + part_types = subgroups.get(subgroup_name, []) + return [ + { + "name": pt, + "name_es": translate_taxonomy_node(pt), + } + for pt in part_types + ] + + +def stats() -> dict: + """Return totals — useful for healthcheck and debugging.""" + total_subgroups = sum(len(sg) for sg in NEXPART_TAXONOMY.values()) + total_part_types = sum( + len(pts) + for sg in NEXPART_TAXONOMY.values() + for pts in sg.values() + ) + return { + "groups": len(NEXPART_TAXONOMY), + "subgroups": total_subgroups, + "part_types": total_part_types, + "indexed_keys": len(_PART_TYPE_INDEX), + } diff --git a/pos/services/peer_service.py b/pos/services/peer_service.py new file mode 100644 index 0000000..104b253 --- /dev/null +++ b/pos/services/peer_service.py @@ -0,0 +1,240 @@ +""" +Peer-to-peer inventory service for multi-instance Nexus deployments. + +Each Nexus instance is autonomous (own DB, own POS) but can see inventory +from other instances on the network. The marketplace fans out to all peers +and merges results so users see stock from the whole Nexus network. + +Architecture: + - peers.json: config file listing known peer instances (name + URL) + - /pos/api/peer/inventory: public endpoint each instance exposes (no auth) + - search_all_peers(): fan-out query to all enabled peers + local DB + +For the demo (LAN), peers are static IPs in peers.json. +For production (clients on own networks), this will evolve into a central +hub model where each instance reports to a cloud server. +""" + +import json +import os +import requests +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Optional + +# ─── Config ────────────────────────────────────────────────────────────── + +_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'peers.json') +_config_cache = None + + +def _load_config(): + """Load peers.json, cached in memory after first read.""" + global _config_cache + if _config_cache is not None: + return _config_cache + try: + with open(_CONFIG_PATH, 'r') as f: + _config_cache = json.load(f) + except (FileNotFoundError, json.JSONDecodeError) as e: + print(f'[peer] Warning: could not load {_CONFIG_PATH}: {e}') + _config_cache = {'instance_name': 'Unknown', 'peers': [], 'peer_timeout_seconds': 3} + return _config_cache + + +def reload_config(): + """Force-reload peers.json (call after editing the file).""" + global _config_cache + _config_cache = None + return _load_config() + + +def get_instance_name() -> str: + return _load_config().get('instance_name', 'Unknown') + + +def get_instance_id() -> str: + return _load_config().get('instance_id', 'unknown') + + +def get_peers() -> list[dict]: + """Return list of enabled peers: [{name, url, enabled}]""" + cfg = _load_config() + return [p for p in cfg.get('peers', []) if p.get('enabled', True)] + + +def get_timeout() -> int: + return _load_config().get('peer_timeout_seconds', 3) + + +# ─── Local inventory query (what WE expose to peers) ───────────────────── + +def get_local_inventory(tenant_conn, query: str = None, limit: int = 50) -> list[dict]: + """Query this instance's inventory for the peer endpoint. + + Returns parts WITH stock > 0, with enough detail for the marketplace + to render results (part number, name, brand, price, stock hint). + No exact stock numbers — just 'En stock' (per business decision). + """ + cur = tenant_conn.cursor() + + # Build WHERE clause + clauses = ["COALESCE(s.stock, 0) > 0", "i.is_active = TRUE"] + params = [] + + if query: + clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s)") + like = f'%{query}%' + params.extend([like, like, like]) + + where = " AND ".join(clauses) + + cur.execute(f""" + SELECT i.id, i.part_number, i.name, i.brand, i.price_1, + COALESCE(s.stock, 0) AS stock, + i.unit, i.catalog_part_id + FROM inventory i + LEFT JOIN ( + SELECT inventory_id, SUM(quantity) AS stock + FROM inventory_operations + GROUP BY inventory_id + ) s ON s.inventory_id = i.id + WHERE {where} + ORDER BY i.name + LIMIT %s + """, params + [limit]) + + rows = cur.fetchall() + cur.close() + + return [ + { + 'id': r[0], + 'part_number': r[1], + 'name': r[2], + 'brand': r[3] or '', + 'price': float(r[4]) if r[4] else None, + 'stock_hint': 'En stock' if r[5] > 0 else 'Agotado', + 'unit': r[6] or 'PZA', + 'catalog_part_id': r[7], + } + for r in rows + ] + + +# ─── Peer fan-out query ────────────────────────────────────────────────── + +def _query_one_peer(peer: dict, query: str, limit: int) -> dict: + """Send a search request to one peer instance. Returns results or error.""" + url = peer['url'].rstrip('/') + '/pos/api/peer/inventory' + params = {'limit': limit} + if query: + params['q'] = query + try: + resp = requests.get(url, params=params, timeout=get_timeout()) + if resp.status_code == 200: + data = resp.json() + # Tag each result with the source instance name + items = data.get('data', []) + for item in items: + item['source_instance'] = peer['name'] + item['source_url'] = peer['url'] + return {'ok': True, 'name': peer['name'], 'data': items} + else: + return {'ok': False, 'name': peer['name'], 'error': f'HTTP {resp.status_code}'} + except requests.exceptions.Timeout: + return {'ok': False, 'name': peer['name'], 'error': 'timeout'} + except requests.exceptions.ConnectionError: + return {'ok': False, 'name': peer['name'], 'error': 'offline'} + except Exception as e: + return {'ok': False, 'name': peer['name'], 'error': str(e)[:100]} + + +def search_all_peers(tenant_conn, query: str = None, limit: int = 50) -> dict: + """Search local inventory + all enabled peers in parallel. + + Returns: + { + "local": { "name": "...", "data": [...] }, + "peers": [ + {"name": "Refac B", "data": [...], "ok": True}, + {"name": "Refac C", "data": [...], "ok": True}, + ... + ], + "merged": [...], # all results combined, local first + "total": N, + "errors": [...] # peers that failed + } + """ + peers = get_peers() + + # Local results + local_data = get_local_inventory(tenant_conn, query=query, limit=limit) + for item in local_data: + item['source_instance'] = get_instance_name() + item['source_url'] = 'local' + + # Fan-out to peers in parallel + peer_results = [] + errors = [] + + if peers: + with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor: + futures = { + executor.submit(_query_one_peer, p, query, limit): p + for p in peers + } + for future in as_completed(futures): + result = future.result() + if result['ok']: + peer_results.append(result) + else: + errors.append(result) + print(f'[peer] {result["name"]}: {result["error"]}') + + # Merge: local first, then peers (sorted by name within each source) + merged = list(local_data) + for pr in peer_results: + merged.extend(pr.get('data', [])) + + return { + 'local': { + 'name': get_instance_name(), + 'data': local_data, + 'count': len(local_data), + }, + 'peers': peer_results, + 'merged': merged, + 'total': len(merged), + 'errors': errors, + } + + +# ─── Health check for the peer network ─────────────────────────────────── + +def check_peer_health() -> list[dict]: + """Ping all peers and return status. Useful for the admin dashboard.""" + peers = get_peers() + results = [] + + def _ping(peer): + try: + url = peer['url'].rstrip('/') + '/pos/api/peer/health' + resp = requests.get(url, timeout=get_timeout()) + if resp.status_code == 200: + data = resp.json() + return { + 'name': peer['name'], + 'url': peer['url'], + 'status': 'online', + 'instance_name': data.get('instance_name'), + 'inventory_count': data.get('inventory_count'), + } + return {'name': peer['name'], 'url': peer['url'], 'status': f'error:{resp.status_code}'} + except Exception as e: + return {'name': peer['name'], 'url': peer['url'], 'status': f'offline:{str(e)[:50]}'} + + if peers: + with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor: + results = list(executor.map(_ping, peers)) + + return results diff --git a/pos/services/thermal_printer.py b/pos/services/thermal_printer.py index ec81b50..28b0b64 100644 --- a/pos/services/thermal_printer.py +++ b/pos/services/thermal_printer.py @@ -105,6 +105,93 @@ def generate_ticket(sale_data, business_info, width=80): return bytes(buf) +def generate_quotation_ticket(quote_data, business_info, width=80): + """Generate ESC/POS bytes for a quotation ticket. + + Args: + quote_data: dict with keys: id, items[{name, part_number, quantity, unit_price, subtotal}], + subtotal, tax_total, total, valid_until, customer_name, wa_phone, created_at + business_info: dict with name, rfc, address + width: 58 or 80 (mm) + + Returns: bytes ready to send to printer + """ + chars = 32 if width == 58 else 48 + buf = bytearray() + buf += INIT + + # Header + buf += ALIGN_CENTER + buf += LARGE_SIZE + buf += (business_info.get('name', 'NEXUS POS') + '\n').encode('cp437', errors='replace') + buf += NORMAL_SIZE + if business_info.get('rfc'): + buf += (business_info['rfc'] + '\n').encode('cp437', errors='replace') + if business_info.get('address'): + buf += (business_info['address'] + '\n').encode('cp437', errors='replace') + buf += b'\n' + + # Title + buf += BOLD_ON + DOUBLE_HEIGHT + buf += 'COTIZACION\n'.encode('cp437', errors='replace') + buf += NORMAL_SIZE + BOLD_OFF + buf += b'\n' + + # Folio + date + buf += ALIGN_LEFT + buf += BOLD_ON + buf += f'Folio: COT-{quote_data.get("id", "")}\n'.encode('cp437', errors='replace') + buf += BOLD_OFF + buf += f'Fecha: {str(quote_data.get("created_at", ""))[:10]}\n'.encode('cp437', errors='replace') + buf += f'Vigencia: {quote_data.get("valid_until", "7 dias")}\n'.encode('cp437', errors='replace') + if quote_data.get('customer_name'): + buf += f'Cliente: {quote_data["customer_name"]}\n'.encode('cp437', errors='replace') + if quote_data.get('wa_phone'): + buf += f'WhatsApp: {quote_data["wa_phone"]}\n'.encode('cp437', errors='replace') + buf += ('-' * chars + '\n').encode() + + # Column header + buf += BOLD_ON + hdr = _format_line('Cant Descripcion', 'Importe', chars) + buf += (hdr + '\n').encode('cp437', errors='replace') + buf += BOLD_OFF + buf += ('-' * chars + '\n').encode() + + # Items + for item in quote_data.get('items', []): + name = item.get('name', '')[:chars - 10] + part_no = item.get('part_number', '') + qty = item.get('quantity', 1) + subtotal = item.get('subtotal', 0) + buf += f'{qty}x {name}\n'.encode('cp437', errors='replace') + if part_no: + buf += f' #{part_no}\n'.encode('cp437', errors='replace') + buf += ALIGN_RIGHT + buf += f'${subtotal:,.2f}\n'.encode('cp437', errors='replace') + buf += ALIGN_LEFT + + buf += ('-' * chars + '\n').encode() + + # Totals + buf += ALIGN_RIGHT + buf += _total_line('Subtotal:', quote_data.get('subtotal', 0), chars).encode('cp437', errors='replace') + buf += _total_line('IVA:', quote_data.get('tax_total', 0), chars).encode('cp437', errors='replace') + buf += BOLD_ON + DOUBLE_HEIGHT + buf += _total_line('TOTAL:', quote_data.get('total', 0), chars).encode('cp437', errors='replace') + buf += NORMAL_SIZE + BOLD_OFF + + # Footer + buf += b'\n' + buf += ALIGN_CENTER + buf += 'Esta cotizacion no es comprobante fiscal\n'.encode('cp437', errors='replace') + buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace') + buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace') + buf += b'\n\n\n' + buf += PARTIAL_CUT + + return bytes(buf) + + def _format_line(left, right, width): """Pad a left-right line to fill the ticket width.""" space = width - len(left) - len(right) diff --git a/pos/services/vin_decoder.py b/pos/services/vin_decoder.py index 06c69c2..a6c404b 100644 --- a/pos/services/vin_decoder.py +++ b/pos/services/vin_decoder.py @@ -14,8 +14,20 @@ def decode_vin(vin): return {"error": "VIN debe tener exactamente 17 caracteres alfanumericos (sin I, O, Q)."} url = f"https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/{vin}?format=json" - resp = requests.get(url, timeout=10) - resp.raise_for_status() + # NHTSA's free API can be slow (5-30s). Retry once on timeout. + import time + for attempt in range(2): + try: + resp = requests.get(url, timeout=25) + resp.raise_for_status() + break + except requests.exceptions.Timeout: + if attempt == 0: + time.sleep(2) + continue + return {"error": "El servidor NHTSA no respondio. Intenta de nuevo en unos segundos."} + except requests.exceptions.RequestException as e: + return {"error": f"Error de conexion con NHTSA: {str(e)[:100]}"} data = resp.json()["Results"][0] error_text = data.get("ErrorText", "") or "" diff --git a/pos/services/wa_quotation.py b/pos/services/wa_quotation.py new file mode 100644 index 0000000..10dbc8c --- /dev/null +++ b/pos/services/wa_quotation.py @@ -0,0 +1,284 @@ +""" +WhatsApp Quotation Service — conversational quote builder. + +Tracks per-phone "open quotations" so a customer can ask about multiple +parts over several messages and receive a single formatted quotation at +the end. + +Flow: + 1. Customer asks about a part → bot shows local inventory match + 2. Customer says "cotizar" / "agregar" → last-shown part added to quote + 3. Repeat for more parts + 4. Customer says "enviar cotización" / "listo" → formatted quote sent + 5. Customer says "limpiar" / "nueva cotización" → quote cleared + +The quotation is stored in the tenant's existing `quotations` + +`quotation_items` tables so it also appears in the POS quotation list. +""" + +import re +from datetime import date, timedelta + + +# ─── Intent detection ──────────────────────────────────────────────── + +# Commands the customer can type (case-insensitive, accent-insensitive) +# NOTE: "si" is NOT here — it's handled as 'confirm' to avoid ambiguity +# with "si" after a quotation was sent (which means "confirm order"). +_ADD_PATTERNS = re.compile( + r'^(cotizar|agregar|agregalo|agrega|añadir|quiero ese|ese mero|' + r'dame ese|lo quiero|me lo apartas|si.?cotiza)$', + re.IGNORECASE +) + +_SEND_PATTERNS = re.compile( + r'^(enviar cotizaci[oó]n|listo|enviar|mandar cotizaci[oó]n|ya es todo|' + r'eso es todo|mandame la cotizaci[oó]n|terminé|termine|ver cotizaci[oó]n|' + r'mi cotizaci[oó]n|total|cuanto es)$', + re.IGNORECASE +) + +_CLEAR_PATTERNS = re.compile( + r'^(limpiar|nueva cotizaci[oó]n|borrar cotizaci[oó]n|empezar de nuevo|cancelar cotizaci[oó]n)$', + re.IGNORECASE +) + +# "si", "va", "confirmo" — confirm the quotation (close it as accepted) +_CONFIRM_PATTERNS = re.compile( + r'^(si|sí|va|confirmo|confirmar|acepto|de acuerdo|ok|okay|dale)$', + re.IGNORECASE +) + +_QTY_PATTERN = re.compile( + r'^(cotizar|agregar|dame|quiero)\s+(\d+)$', + re.IGNORECASE +) + + +def detect_quote_intent(text, has_open_quote=False): + """Detect if the message is a quotation command. + + Args: + text: the user's message + has_open_quote: True if this phone has an active quotation + + Returns: + ('add', quantity) — add last part to quote + ('send', None) — send the full quotation + ('clear', None) — clear the quotation + ('confirm', None) — confirm/accept the quotation + (None, None) — not a quote command, pass to AI + """ + if not text: + return None, None + + t = text.strip() + + # Check for quantity: "cotizar 3", "agregar 5" + qty_match = _QTY_PATTERN.match(t) + if qty_match: + return 'add', int(qty_match.group(2)) + + if _ADD_PATTERNS.match(t): + return 'add', 1 + + if _SEND_PATTERNS.match(t): + return 'send', None + + if _CLEAR_PATTERNS.match(t): + return 'clear', None + + # "si" / "va" / "confirmo" — only counts as 'confirm' when there's + # an open quote. Otherwise pass to the AI as normal conversation. + if has_open_quote and _CONFIRM_PATTERNS.match(t): + return 'confirm', None + + return None, None + + +def confirm_quotation(tenant_conn, phone): + """Mark the open quotation as confirmed/accepted.""" + qid = get_open_quotation(tenant_conn, phone) + if not qid: + return None + cur = tenant_conn.cursor() + cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,)) + tenant_conn.commit() + cur.close() + clear_last_shown(phone) + return qid + + +# ─── In-memory last-shown-part per phone ───────────────────────────── +# Tracks what part the bot last showed so "cotizar" knows what to add. +# Key: phone (clean, no @lid). Value: dict with inventory item info. + +_last_shown = {} + + +def set_last_shown_part(phone, part_info): + """Store the last part shown to this phone number. + + part_info: dict with keys inventory_id, part_number, name, brand, + price, stock, unit + """ + _last_shown[phone] = part_info + + +def get_last_shown_part(phone): + return _last_shown.get(phone) + + +def clear_last_shown(phone): + _last_shown.pop(phone, None) + + +# ─── Quotation CRUD ───────────────────────────────────────────────── + +def get_open_quotation(tenant_conn, phone): + """Find an active quotation for this phone, or None.""" + cur = tenant_conn.cursor() + cur.execute(""" + SELECT id FROM quotations + WHERE notes LIKE %s AND status = 'active' + ORDER BY created_at DESC LIMIT 1 + """, (f'%WA:{phone}%',)) + row = cur.fetchone() + cur.close() + return row[0] if row else None + + +def create_quotation(tenant_conn, phone): + """Create a new quotation tagged with this phone number.""" + cur = tenant_conn.cursor() + cur.execute(""" + INSERT INTO quotations (subtotal, tax_total, total, status, notes, valid_until) + VALUES (0, 0, 0, 'active', %s, %s) + RETURNING id + """, (f'WA:{phone}', date.today() + timedelta(days=7))) + qid = cur.fetchone()[0] + tenant_conn.commit() + cur.close() + return qid + + +def add_item_to_quotation(tenant_conn, quote_id, part_info, quantity=1): + """Add a part to an existing quotation and recalculate totals.""" + price = float(part_info.get('price') or 0) + tax_rate = float(part_info.get('tax_rate') or 0.16) + subtotal = round(price * quantity, 2) + + cur = tenant_conn.cursor() + cur.execute(""" + INSERT INTO quotation_items + (quotation_id, inventory_id, part_number, name, quantity, unit_price, tax_rate, subtotal) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, ( + quote_id, + part_info.get('inventory_id'), + part_info.get('part_number', ''), + part_info.get('name', ''), + quantity, + price, + tax_rate, + subtotal, + )) + + # Recalculate totals + cur.execute(""" + SELECT COALESCE(SUM(subtotal), 0), + COALESCE(SUM(subtotal * tax_rate), 0) + FROM quotation_items WHERE quotation_id = %s + """, (quote_id,)) + sub, tax = cur.fetchone() + cur.execute(""" + UPDATE quotations SET subtotal = %s, tax_total = %s, total = %s + WHERE id = %s + """, (sub, tax, round(sub + tax, 2), quote_id)) + + tenant_conn.commit() + cur.close() + return subtotal + + +def get_quotation_detail(tenant_conn, quote_id): + """Return full quotation with items.""" + cur = tenant_conn.cursor() + cur.execute(""" + SELECT id, subtotal, tax_total, total, status, valid_until, created_at + FROM quotations WHERE id = %s + """, (quote_id,)) + q = cur.fetchone() + if not q: + cur.close() + return None + + cur.execute(""" + SELECT part_number, name, quantity, unit_price, tax_rate, subtotal + FROM quotation_items WHERE quotation_id = %s ORDER BY id + """, (quote_id,)) + items = cur.fetchall() + cur.close() + + return { + 'id': q[0], + 'subtotal': float(q[1]), + 'tax_total': float(q[2]), + 'total': float(q[3]), + 'status': q[4], + 'valid_until': str(q[5]) if q[5] else None, + 'created_at': str(q[6]) if q[6] else None, + 'items': [{ + 'part_number': it[0], + 'name': it[1], + 'quantity': it[2], + 'unit_price': float(it[3]), + 'tax_rate': float(it[4]), + 'subtotal': float(it[5]), + } for it in items], + } + + +def clear_quotation(tenant_conn, phone): + """Cancel the open quotation for this phone.""" + qid = get_open_quotation(tenant_conn, phone) + if qid: + cur = tenant_conn.cursor() + cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,)) + tenant_conn.commit() + cur.close() + clear_last_shown(phone) + return qid + + +# ─── Format quotation for WhatsApp ────────────────────────────────── + +def format_quotation_wa(detail): + """Format a quotation as a WhatsApp-friendly text message.""" + if not detail or not detail.get('items'): + return None + + lines = [ + f'📄 *COTIZACIÓN #{detail["id"]}*', + f'Fecha: {detail["created_at"][:10] if detail.get("created_at") else "hoy"}', + f'Vigencia: {detail.get("valid_until") or "7 días"}', + '', + '─────────────────────', + ] + + for i, item in enumerate(detail['items'], 1): + qty = item['quantity'] + price = item['unit_price'] + sub = item['subtotal'] + lines.append(f'{i}. {item["name"]}') + lines.append(f' #{item["part_number"]} × {qty} = ${sub:,.2f}') + + lines.append('─────────────────────') + lines.append(f' Subtotal: ${detail["subtotal"]:,.2f}') + lines.append(f' IVA: ${detail["tax_total"]:,.2f}') + lines.append(f' *TOTAL: ${detail["total"]:,.2f}*') + lines.append('') + lines.append('_Responde "si" para confirmar el pedido._') + lines.append('_Responde "limpiar" para empezar de nuevo._') + + return '\n'.join(lines) diff --git a/pos/services/whatsapp_service.py b/pos/services/whatsapp_service.py index ad019a6..e2ab29c 100644 --- a/pos/services/whatsapp_service.py +++ b/pos/services/whatsapp_service.py @@ -55,12 +55,63 @@ def logout(): def process_incoming(webhook_data): + """Extract a normalized dict from a Baileys webhook payload. + + Supports text messages, image messages, audio (voice notes), and video. + Media content comes pre-downloaded as base64 from the bridge so Python + doesn't have to re-authenticate with WhatsApp servers. + + Returns: + dict with keys: + phone — numeric phone (no JID suffix) + jid — full remote JID (may be @s.whatsapp.net or @lid) + text — text content (plain text or media caption) + from_me — bool, True if we sent the message + message_id — WhatsApp message ID + media_kind — 'text' | 'image' | 'audio' | 'video' + media_base64 — base64 string if media, else None + media_mimetype — e.g. 'image/jpeg', 'audio/ogg' + is_voice_note — True for WhatsApp voice notes (audioMessage ptt) + """ data = webhook_data.get('data', {}) key = data.get('key', {}) message = data.get('message', {}) + + # remoteJid can be phone@s.whatsapp.net or LID@lid + remote_jid = key.get('remoteJid', '') + phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '') + + # The bridge now classifies and passes these extra fields. Fall back to + # the old parsing if they're missing (older bridge version). + media_kind = data.get('media_kind', 'text') + media_base64 = data.get('media_base64') + media_mimetype = data.get('media_mimetype') + media_caption = data.get('media_caption') or '' + is_voice_note = bool(data.get('media_ptt')) + push_name = data.get('push_name') or '' + + # Text content: + # - For 'text' messages → conversation or extendedTextMessage + # - For 'image'/'video' → the caption (may be empty) + # - For 'audio' → empty (filled in later by Whisper transcription) + if media_kind == 'text': + text = ( + message.get('conversation', '') + or message.get('extendedTextMessage', {}).get('text', '') + or '' + ) + else: + text = media_caption + return { - 'phone': key.get('remoteJid', '').replace('@s.whatsapp.net', ''), - 'text': message.get('conversation', '') or message.get('extendedTextMessage', {}).get('text', ''), + 'phone': phone, + 'jid': remote_jid, + 'text': text, 'from_me': key.get('fromMe', False), 'message_id': key.get('id', ''), + 'media_kind': media_kind, + 'media_base64': media_base64, + 'media_mimetype': media_mimetype, + 'is_voice_note': is_voice_note, + 'push_name': push_name, } diff --git a/pos/services/whisper_local.py b/pos/services/whisper_local.py new file mode 100644 index 0000000..a289772 --- /dev/null +++ b/pos/services/whisper_local.py @@ -0,0 +1,151 @@ +""" +Local Whisper transcription service. + +Uses faster-whisper (a CTranslate2-based port of OpenAI Whisper) for +transcribing short audio clips (WhatsApp voice notes) on the CPU. + +Runs fully offline after the first model download. No API keys, no +per-minute cost. Model is lazy-loaded on first call and cached in memory +for the lifetime of the process. + +Default model: 'tiny' — the smallest and fastest variant (~75 MB), good +enough for conversational Spanish. Change WHISPER_MODEL below to 'base' +(150 MB, slightly better accuracy) or 'small' (500 MB, noticeably better) +if you have the RAM and don't mind 2-3x slower inference. +""" + +import base64 as _b64 +import os +import subprocess +import tempfile +import threading + +# ─── Config ────────────────────────────────────────────────────────────── +# 'base' is the sweet spot for Mexican Spanish voice notes on CPU: +# tiny (75 MB) — too small, misses words in noisy/robot audio +# base (150 MB) — good accuracy, ~2s per 30s clip on a modern CPU ← default +# small (500 MB) — best accuracy, ~5s per 30s clip, worth it if RAM permits +WHISPER_MODEL = "base" +WHISPER_DEVICE = "cpu" +WHISPER_COMPUTE = "int8" # int8 quantization — CPU-friendly, minimal quality loss + +# ─── Lazy singleton model loader ───────────────────────────────────────── +_model = None +_model_lock = threading.Lock() + + +def _get_model(): + """Load the Whisper model on first use. Thread-safe.""" + global _model + if _model is not None: + return _model + with _model_lock: + if _model is not None: + return _model + from faster_whisper import WhisperModel + print(f"[whisper] Loading {WHISPER_MODEL} model ({WHISPER_DEVICE}, {WHISPER_COMPUTE})...") + _model = WhisperModel( + WHISPER_MODEL, + device=WHISPER_DEVICE, + compute_type=WHISPER_COMPUTE, + ) + print("[whisper] Model ready.") + return _model + + +# ─── Public API ────────────────────────────────────────────────────────── + +def transcribe_audio_base64(audio_base64: str, mimetype: str = "audio/ogg", + language: str = "es") -> str | None: + """Transcribe a base64-encoded audio blob to text. + + Args: + audio_base64: Raw base64 string (no data: prefix). + mimetype: MIME type from the sender (e.g. 'audio/ogg' for WA voice notes). + language: ISO 639-1 code to bias the model. 'es' for Spanish MX. + + Returns: + The transcribed text, or None if transcription fails or is empty. + """ + if not audio_base64: + return None + + # Decode base64 → write to a temp file with the right extension so + # ffmpeg (invoked by faster-whisper/CTranslate2) picks the decoder. + ext = _extension_for_mimetype(mimetype) + try: + audio_bytes = _b64.b64decode(audio_base64) + except Exception as e: + print(f"[whisper] base64 decode failed: {e}") + return None + + tmp_in = None + tmp_wav = None + try: + # Write the original audio to a temp file + tmp_in = tempfile.NamedTemporaryFile(suffix=ext, delete=False) + tmp_in.write(audio_bytes) + tmp_in.close() + + # WhatsApp voice notes are OGG/Opus — faster-whisper can handle it + # via its pyav decoder, but converting to 16kHz mono WAV first is + # more reliable across formats and ~2x faster. + tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) + tmp_wav.close() + rc = subprocess.run( + ["ffmpeg", "-y", "-i", tmp_in.name, + "-ar", "16000", "-ac", "1", + "-f", "wav", tmp_wav.name], + capture_output=True, + ) + if rc.returncode != 0: + print(f"[whisper] ffmpeg conversion failed: {rc.stderr.decode()[:200]}") + return None + + # Run Whisper + # - beam_size=5 for better accuracy on short/noisy clips + # - no VAD filter (was trimming real speech in some tests) + # - condition_on_previous_text=False for short independent clips + model = _get_model() + segments, info = model.transcribe( + tmp_wav.name, + language=language, + beam_size=5, + vad_filter=False, + condition_on_previous_text=False, + ) + text = " ".join(s.text.strip() for s in segments if s.text.strip()) + text = text.strip() + + if not text: + return None + + print(f"[whisper] ({info.language}, {info.duration:.1f}s) → {text[:100]}") + return text + + except Exception as e: + print(f"[whisper] transcription error: {e}") + return None + finally: + for f in (tmp_in, tmp_wav): + if f: + try: + os.unlink(f.name) + except Exception: + pass + + +def _extension_for_mimetype(mimetype: str) -> str: + """Map a MIME type to a file extension ffmpeg understands.""" + m = (mimetype or "").lower() + if "opus" in m or "ogg" in m: + return ".ogg" + if "mp3" in m or "mpeg" in m: + return ".mp3" + if "mp4" in m or "aac" in m: + return ".m4a" + if "wav" in m: + return ".wav" + if "webm" in m: + return ".webm" + return ".ogg" # WhatsApp voice notes are usually OGG/Opus diff --git a/pos/static/css/pos-glass.css b/pos/static/css/pos-glass.css new file mode 100644 index 0000000..fc7943f --- /dev/null +++ b/pos/static/css/pos-glass.css @@ -0,0 +1,683 @@ +/* ========================================================================== + POS-GLASS.CSS — Pixel-Perfect glassmorphism overlay for Nexus POS + Load AFTER tokens.css. Applies glass effects, glow, 3D buttons, + and animations to all POS pages without modifying inline styles. + ========================================================================== */ + +/* ── Hidden scrollbar (global) ── */ +html { scrollbar-width: none; } +html::-webkit-scrollbar { width: 0; } + +/* ── Smooth font rendering ── */ +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ========================================================================== + SIDEBAR — Glass treatment + ========================================================================== */ + +.sidebar, +.pos-sidebar { + background: var(--glass-bg-strong) !important; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-right: 1px solid var(--glass-border) !important; +} + +.sidebar__logo { + position: relative; +} + +.sidebar__logo-text { + position: relative; +} + +/* Glow under logo text */ +.sidebar__logo-text::after { + content: ''; + position: absolute; + bottom: -4px; + left: 0; + right: 0; + height: 2px; + background: var(--gradient-accent); + border-radius: 1px; + opacity: 0.4; + filter: blur(2px); +} + +/* Nav items — hover glow */ +.sidebar__nav a, +.sidebar__nav-item, +.sidebar .nav-item { + transition: all 0.25s var(--ease-out) !important; + border-radius: var(--radius-md); +} + +.sidebar__nav a:hover, +.sidebar__nav-item:hover, +.sidebar .nav-item:hover { + box-shadow: 0 0 12px var(--glow-color-soft); +} + +.sidebar__nav a.active, +.sidebar__nav-item.active, +.sidebar .nav-item.active { + box-shadow: 0 0 16px var(--glow-color-soft), inset 0 0 0 1px var(--glass-border); +} + +/* ========================================================================== + THEME BAR — Glass + ========================================================================== */ + +.theme-bar { + background: var(--glass-bg-strong) !important; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--glass-border) !important; +} + +/* ========================================================================== + CARDS — Glass with glow hover + ========================================================================== */ + +.kpi-card, +.table-card, +.card, +.stat-card, +.chart-card, +.alert-card, +.config-card, +.fleet-card, +.report-card, +.invoice-card, +.customer-card, +.panel { + background: var(--glass-bg) !important; + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border: 1px solid var(--glass-border) !important; + transition: all 0.3s var(--ease-out) !important; + position: relative; + overflow: hidden; +} + +/* Accent top-line on hover */ +.kpi-card::before, +.table-card::before, +.chart-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--gradient-accent); + transform: scaleX(0); + transform-origin: left; + transition: transform 0.4s var(--ease-out); + z-index: 1; +} + +.kpi-card:hover::before, +.table-card:hover::before, +.chart-card:hover::before { + transform: scaleX(1); +} + +.kpi-card:hover, +.table-card:hover, +.card:hover, +.stat-card:hover, +.chart-card:hover, +.config-card:hover, +.fleet-card:hover, +.report-card:hover { + border-color: var(--color-border-accent) !important; + box-shadow: 0 4px 20px var(--glow-color-soft); +} + +/* KPI card accent bar — add glow */ +.kpi-card__accent-bar { + box-shadow: 0 0 8px var(--glow-color-soft); +} + +/* ========================================================================== + BUTTONS — 3D depth effect + ========================================================================== */ + +/* Primary buttons */ +.btn--primary, +button.primary, +.btn-primary, +input[type="submit"], +button[type="submit"] { + background: var(--gradient-accent) !important; + border: none !important; + box-shadow: 0 3px 0 var(--color-primary-active), + 0 4px 10px var(--glow-color-soft) !important; + transition: all 0.25s var(--ease-out) !important; + position: relative; + overflow: hidden; +} + +.btn--primary:hover, +button.primary:hover, +.btn-primary:hover, +input[type="submit"]:hover, +button[type="submit"]:hover { + transform: translateY(-1px); + box-shadow: 0 4px 0 var(--color-primary-active), + 0 8px 20px var(--glow-color) !important; +} + +.btn--primary:active, +button.primary:active, +.btn-primary:active, +input[type="submit"]:active, +button[type="submit"]:active { + transform: translateY(1px); + box-shadow: 0 1px 0 var(--color-primary-active) !important; +} + +/* Ghost / secondary buttons — glass */ +.btn--ghost, +.btn--secondary, +.btn-secondary, +.btn-ghost, +button.secondary { + background: var(--glass-bg) !important; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--glass-border) !important; + transition: all 0.25s var(--ease-out) !important; +} + +.btn--ghost:hover, +.btn--secondary:hover, +.btn-secondary:hover, +.btn-ghost:hover, +button.secondary:hover { + border-color: var(--color-border-accent) !important; + box-shadow: 0 0 16px var(--glow-color-soft); +} + +/* ========================================================================== + INPUTS — Glass with focus glow + ========================================================================== */ + +input[type="text"], +input[type="number"], +input[type="email"], +input[type="password"], +input[type="search"], +input[type="tel"], +input[type="date"], +input[type="url"], +textarea, +select, +.search-input, +.filter-input { + background: var(--glass-bg) !important; + border: 1px solid var(--glass-border) !important; + transition: all 0.25s var(--ease-out) !important; +} + +input[type="text"]:focus, +input[type="number"]:focus, +input[type="email"]:focus, +input[type="password"]:focus, +input[type="search"]:focus, +input[type="tel"]:focus, +input[type="date"]:focus, +input[type="url"]:focus, +textarea:focus, +select:focus, +.search-input:focus, +.filter-input:focus { + border-color: var(--color-border-focus) !important; + box-shadow: 0 0 0 3px var(--glow-color-soft), 0 0 16px var(--glow-color-soft) !important; + outline: none; +} + +/* ========================================================================== + TABLES — Subtle glass rows + ========================================================================== */ + +table thead th { + background: var(--glass-bg) !important; + backdrop-filter: blur(8px); + font-family: var(--font-mono); + font-size: var(--text-caption); + text-transform: uppercase; + letter-spacing: var(--tracking-wider); +} + +table tbody tr { + transition: all 0.2s ease !important; +} + +table tbody tr:hover { + background: var(--glass-highlight) !important; + box-shadow: inset 0 0 0 1px var(--glass-border); +} + +/* ========================================================================== + MODALS — Glass overlay + glass content + ========================================================================== */ + +.modal-overlay, +.overlay, +.modal-backdrop { + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +.modal, +.modal-content, +.modal-dialog, +.dialog { + background: var(--glass-bg-strong) !important; + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--glass-border) !important; + box-shadow: 0 24px 48px rgba(0,0,0,0.3) !important; +} + +/* ========================================================================== + TABS — Glass active state + ========================================================================== */ + +.tab, +.tab-btn, +.tabs button { + transition: all 0.25s var(--ease-out) !important; + border-radius: var(--radius-md); +} + +.tab.active, +.tab-btn.active, +.tabs button.active { + background: var(--color-primary-muted) !important; + box-shadow: 0 0 12px var(--glow-color-soft); + border-color: var(--color-border-accent) !important; +} + +/* ========================================================================== + BADGES / TAGS — Subtle glow + ========================================================================== */ + +.badge, +.tag, +.status-badge, +.pill { + backdrop-filter: blur(4px); + transition: all 0.2s ease; +} + +/* ========================================================================== + SCROLL REVEAL — Available for any POS page that wants it + ========================================================================== */ + +.nx-reveal { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out); +} +.nx-reveal.is-visible { + opacity: 1; + transform: translateY(0); +} +.nx-stagger > .nx-reveal:nth-child(1) { transition-delay: 0ms; } +.nx-stagger > .nx-reveal:nth-child(2) { transition-delay: 80ms; } +.nx-stagger > .nx-reveal:nth-child(3) { transition-delay: 160ms; } +.nx-stagger > .nx-reveal:nth-child(4) { transition-delay: 240ms; } +.nx-stagger > .nx-reveal:nth-child(5) { transition-delay: 320ms; } +.nx-stagger > .nx-reveal:nth-child(6) { transition-delay: 400ms; } + +/* ========================================================================== + TOAST / NOTIFICATIONS — Glass + ========================================================================== */ + +.toast, +.notification, +.snackbar, +.alert { + background: var(--glass-bg-strong) !important; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--glass-border) !important; +} + +/* ========================================================================== + DROPDOWN / POPOVER — Glass + ========================================================================== */ + +.dropdown-menu, +.popover, +.autocomplete-list, +.suggestion-list { + background: var(--glass-bg-strong) !important; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--glass-border) !important; + box-shadow: 0 8px 32px rgba(0,0,0,0.2) !important; +} + +/* ========================================================================== + STATUS BAR (POS) — Glass + ========================================================================== */ + +.status-bar, +.pos-status-bar { + background: var(--glass-bg-strong) !important; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--glass-border) !important; +} + +/* ========================================================================== + LOADING SPINNER — Glow animation + ========================================================================== */ + +.spinner, +.loading-spinner { + animation: nx-glow-pulse 1.5s ease-in-out infinite; +} + +/* ========================================================================== + ANIMATIONS — Available keyframes + ========================================================================== */ + +@keyframes pos-fade-in { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Apply subtle entry animation to main content area */ +.content, +.main-content, +main { + animation: pos-fade-in 0.4s var(--ease-out) both; +} + +/* ========================================================================== + DASHED BORDER ACCENTS (Pixel-Perfect style) + ========================================================================== */ + +.section-divider, +hr { + border: none; + border-top: 1px dashed var(--glass-border); + margin: var(--space-4) 0; +} + +/* ========================================================================== + TABLET RESPONSIVE — Adaptive layout for 768px-1024px screens + Applied globally to all POS pages via pos-glass.css. + Targets iPad (768×1024), Android tablets (800×1280), and similar. + ========================================================================== */ + +/* ── Tablet portrait (768-1023px) — sidebar collapses, grids reflow ── */ +@media (max-width: 1023px) { + + /* Sidebar collapses to an overlay drawer */ + .sidebar, + .pos-sidebar { + position: fixed !important; + top: 0 !important; + left: 0 !important; + bottom: 0 !important; + z-index: var(--z-modal) !important; + transform: translateX(-100%) !important; + transition: transform 0.3s var(--ease-out) !important; + width: 260px !important; + } + + .sidebar.open, + .pos-sidebar.open { + transform: translateX(0) !important; + box-shadow: 0 0 40px rgba(0,0,0,0.3) !important; + } + + .sidebar-overlay { + display: none !important; + position: fixed !important; + inset: 0 !important; + z-index: calc(var(--z-modal) - 1) !important; + background: rgba(0,0,0,0.5) !important; + } + + .sidebar-overlay.open { + display: block !important; + } + + /* App shell: full width when sidebar is hidden */ + .app-shell { + flex-direction: column !important; + } + + .app-shell > main, + .app-shell > .main-content, + .app-shell > .content, + .main-content, + .content { + margin-left: 0 !important; + width: 100% !important; + } + + /* Show hamburger button */ + .hamburger-btn { + display: flex !important; + } + + /* Touch-friendly targets — minimum 44px tap area */ + button, + .btn, + .nav-card, + .tab-btn, + .tab, + .part-card, + .search-result-item, + table tbody tr, + .kpi-card { + min-height: 44px; + } + + /* Larger text for readability on tablets */ + .kpi-card__value { + font-size: 1.5rem !important; + } + + /* Grid reflow: 2 columns instead of 3-4 */ + .kpi-grid { + grid-template-columns: repeat(2, 1fr) !important; + } + + .nav-grid { + grid-template-columns: repeat(2, 1fr) !important; + } + + /* Tables: horizontal scroll wrapper on narrow screens */ + .table-wrap, + .table-card { + overflow-x: auto !important; + -webkit-overflow-scrolling: touch; + } + + /* POS-specific: if the POS has a side panel (cart), stack vertically */ + .pos-layout { + flex-direction: column !important; + } + + .pos-layout .pos-cart, + .pos-layout .cart-panel { + width: 100% !important; + max-width: 100% !important; + height: auto !important; + max-height: 40vh !important; + } + + /* Content headers: tighter padding */ + .content-header, + .header, + .page-header { + padding: var(--space-3) var(--space-4) !important; + } + + /* Search bar: full width */ + .search-bar, + .search-wrapper { + width: 100% !important; + max-width: 100% !important; + } + + /* Mode toggle: slightly larger buttons for touch */ + .mode-toggle button { + padding: 6px 14px !important; + font-size: 12px !important; + } + + /* Vehicle selector dropdowns: stack on smaller tablets */ + .vehicle-selector__inner, + .vehicle-selector .vs-group { + flex-wrap: wrap !important; + } + + .vehicle-selector .vs-arrow { + display: none !important; + } + + .vehicle-selector .vs-select { + min-width: 130px !important; + } +} + +/* ── Phone portrait (< 768px) — single column, max simplification ── */ +@media (max-width: 767px) { + + .sidebar { + width: 85vw !important; + max-width: 300px !important; + } + + .kpi-grid, + .nav-grid, + .results-grid { + grid-template-columns: 1fr !important; + } + + .kpi-card__value { + font-size: 1.3rem !important; + } + + /* Stack the mode toggle buttons vertically if tight */ + .mode-toggle { + flex-wrap: wrap !important; + } + + /* Hide non-essential UI to save space */ + .header__store-badge, + .vs-vin-divider { + display: none !important; + } + + /* Full-width modals */ + .modal-content { + max-width: 95vw !important; + margin: var(--space-3) !important; + padding: var(--space-4) !important; + } + + /* Tables: force readable font size */ + table { + font-size: 12px !important; + } + + table th, + table td { + padding: var(--space-2) var(--space-2) !important; + } +} + +/* ── Landscape tablet (height < 600px with wide screen) ── */ +@media (max-height: 600px) and (min-width: 768px) { + /* Reduce vertical padding for landscape tablet use */ + .kpi-grid { + gap: var(--space-2) !important; + } + + .dashboard, + .main-content, + .content { + padding: var(--space-3) !important; + } +} + +/* ── Touch device hints ── */ +@media (hover: none) and (pointer: coarse) { + /* Remove hover-only effects on touch devices — they cause sticky states */ + .kpi-card:hover, + .nav-card:hover, + .part-card:hover, + .table-card:hover, + .card:hover { + transform: none !important; + } + + /* Larger touch targets for interactive elements */ + .sidebar__nav a, + .sidebar__nav-item, + .sidebar .nav-item { + padding: 12px 16px !important; + min-height: 48px !important; + display: flex !important; + align-items: center !important; + } + + /* Scroll momentum on iOS */ + .table-wrap, + .main-content, + .content, + .parts-grid, + .nav-grid { + -webkit-overflow-scrolling: touch; + } + + /* Disable text selection on buttons (prevents accidental blue highlight on long tap) */ + button, + .btn, + .nav-card, + .tab-btn { + -webkit-user-select: none; + user-select: none; + } +} + + +/* ========================================================================== + PRINT — Disable glass effects for printing + ========================================================================== */ + +@media print { + .sidebar, + .theme-bar, + .kpi-card, + .table-card, + .card, + .modal, + .modal-content, + table thead th, + input, + select, + textarea { + background: #fff !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + box-shadow: none !important; + border-color: #ccc !important; + color: #000 !important; + } +} diff --git a/pos/static/css/tokens.css b/pos/static/css/tokens.css index cad8bfb..26a4041 100644 --- a/pos/static/css/tokens.css +++ b/pos/static/css/tokens.css @@ -558,6 +558,69 @@ } +/* ========================================================================== + GLASSMORPHISM TOKENS + ========================================================================== */ + +[data-theme="industrial"] { + --glass-bg: rgba(26, 26, 26, 0.70); + --glass-bg-strong: rgba(26, 26, 26, 0.85); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-blur: 16px; + --glass-highlight: rgba(245, 166, 35, 0.06); + + --glow-color: rgba(245, 166, 35, 0.40); + --glow-color-soft: rgba(245, 166, 35, 0.15); + --glow-color-strong: rgba(245, 166, 35, 0.60); + + --gradient-accent: linear-gradient(135deg, #F5A623 0%, #e8951a 50%, #d4850f 100%); + --gradient-text: linear-gradient(135deg, #F5A623 0%, #FFD080 50%, #F5A623 100%); + + --canvas-grid-color: rgba(255, 255, 255, 0.06); + --canvas-star-color: rgba(245, 166, 35, 0.30); + --canvas-glow-color: rgba(245, 166, 35, 0.08); +} + +[data-theme="modern"] { + --glass-bg: rgba(248, 249, 255, 0.70); + --glass-bg-strong: rgba(248, 249, 255, 0.85); + --glass-border: rgba(26, 26, 46, 0.08); + --glass-blur: 16px; + --glass-highlight: rgba(255, 107, 53, 0.04); + + --glow-color: rgba(255, 107, 53, 0.35); + --glow-color-soft: rgba(255, 107, 53, 0.12); + --glow-color-strong: rgba(255, 107, 53, 0.55); + + --gradient-accent: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FF6B35 100%); + --gradient-text: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #e85520 100%); + + --canvas-grid-color: rgba(26, 26, 46, 0.05); + --canvas-star-color: rgba(255, 107, 53, 0.20); + --canvas-glow-color: rgba(255, 107, 53, 0.06); +} + + +/* ========================================================================== + ANIMATION KEYFRAMES + ========================================================================== */ + +@keyframes nx-fade-up { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes nx-glow-pulse { + 0%, 100% { box-shadow: 0 0 20px var(--glow-color-soft); } + 50% { box-shadow: 0 0 40px var(--glow-color); } +} + +@keyframes nx-shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + + /* ========================================================================== END OF TOKENS FILE nexus-autoparts-design/tokens/tokens.css diff --git a/pos/static/js/accounting.js b/pos/static/js/accounting.js index 195abdf..2ff9c13 100644 --- a/pos/static/js/accounting.js +++ b/pos/static/js/accounting.js @@ -390,7 +390,37 @@ const Accounting = (() => { // ---- Exportar placeholder ---- function exportarContabilidad() { - alert('Exportar: proximamente'); + // Find the first visible table in the active accounting tab and export as CSV + var tables = document.querySelectorAll('table'); + var table = null; + for (var i = 0; i < tables.length; i++) { + if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) { + table = tables[i]; + break; + } + } + if (!table) { + alert('No hay datos para exportar en la vista actual.'); + return; + } + var rows = []; + var ths = table.querySelectorAll('thead th'); + if (ths.length) { + rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(',')); + } + table.querySelectorAll('tbody tr').forEach(function(tr) { + var cells = tr.querySelectorAll('td'); + rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(',')); + }); + if (rows.length <= 1) { alert('Sin datos para exportar.'); return; } + var csv = rows.join('\n'); + var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'contabilidad_nexus_' + new Date().toISOString().slice(0, 10) + '.csv'; + a.click(); + URL.revokeObjectURL(url); } // ---- Nueva Poliza modal ---- diff --git a/pos/static/js/catalog.js b/pos/static/js/catalog.js index ffa68ef..37fc833 100644 --- a/pos/static/js/catalog.js +++ b/pos/static/js/catalog.js @@ -49,15 +49,77 @@ // ─── Navigation State ─── var nav = { - level: 'brands', // brands|models|years|engines|categories|groups|parts + level: 'brands', // brands|models|years|engines|categories|groups|part_types|parts brand: null, // {id, name} model: null, // {id, name} year: null, // {id, year} engine: null, // {id_mye, name} + + // OEM mode (TecDoc) navigation state — integer IDs category: null, // {id, name} group: null, // {id, name} + partType: null, // {slug, name} ← 3rd-level subcategory (Nexpart-style) + + // Local mode (Nexpart) navigation state — string slugs. + // These live in parallel with category/group/partType so transitioning + // between modes doesn't trash the other branch's state. + nxGroup: null, // {slug, name} ← top-level Nexpart group (14 total) + nxSubgroup: null, // {slug, name} ← Nexpart subgroup + nxPartType: null, // {slug, name} ← Nexpart part type (3rd level) }; + // ─── Catalog mode (OEM / Local) ─── + var catalogMode = (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem'); + + function updateModeToggleUI() { + var btns = document.querySelectorAll('#modeToggle button'); + btns.forEach(function (b) { + if (b.getAttribute('data-mode') === catalogMode) { + b.classList.add('is-active'); + } else { + b.classList.remove('is-active'); + } + }); + } + + function setCatalogMode(mode) { + if (mode !== 'oem' && mode !== 'local' && mode !== 'supplies') return; + if (mode === catalogMode) return; + catalogMode = mode; + localStorage.setItem('catalog_mode', mode); + updateModeToggleUI(); + + // Clear category-and-below state regardless of mode + nav.category = nav.group = nav.partType = null; + nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null; + currentPage = 1; + + if (mode === 'supplies') { + // Supplies mode skips the vehicle chain entirely. + // Clear the vehicle state for visual clarity and go directly + // to the Shop Supplies top-level group list. + try { vsClearAll(); } catch (e) {} + nav.brand = nav.model = nav.year = nav.engine = null; + nav.level = 'categories'; + loadShopSuppliesGroups(); + return; + } + + // OEM/Local: smart reset — if the user already picked a vehicle, + // stay at the categories level. Otherwise reset to brand selection. + var hasVehicle = !!(nav.engine && nav.engine.id_mye); + if (hasVehicle) { + nav.level = 'categories'; + loadCategoriesForMode(); + return; + } + + try { vsClearAll(); } catch (e) {} + nav.level = 'brands'; + nav.brand = nav.model = nav.year = nav.engine = null; + loadBrands(); + } + var currentPage = 1; var currentDetailPart = null; var detailQty = 1; @@ -82,6 +144,10 @@ nav.engine = e.state.engine; nav.category = e.state.category; nav.group = e.state.group; + nav.partType = e.state.partType || null; + nav.nxGroup = e.state.nxGroup || null; + nav.nxSubgroup = e.state.nxSubgroup || null; + nav.nxPartType = e.state.nxPartType || null; currentPage = e.state.page || 1; // Reload the correct level @@ -89,8 +155,16 @@ else if (nav.level === 'models') loadModels(); else if (nav.level === 'years') loadYears(); else if (nav.level === 'engines') loadEngines(); - else if (nav.level === 'categories') loadCategories(); - else if (nav.level === 'groups') loadGroups(); + // When restoring from history, dispatch between OEM and Nexpart + // based on which branch of state is populated — this survives + // toggle changes made mid-session. + else if (nav.level === 'categories') loadCategoriesForMode(); + else if (nav.level === 'groups') { + if (nav.nxGroup) loadNexpartSubgroups(); else loadGroups(); + } + else if (nav.level === 'part_types') { + if (nav.nxSubgroup) loadNexpartPartTypes(); else loadPartTypes(); + } else if (nav.level === 'parts') loadParts(currentPage); else loadBrands(); @@ -151,8 +225,19 @@ if (nav.model) parts.push({ label: nav.model.name, action: 'loadYears' }); if (nav.year) parts.push({ label: String(nav.year.year), action: 'loadEngines' }); if (nav.engine) parts.push({ label: nav.engine.name, action: 'loadCategories' }); - if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' }); - if (nav.group) parts.push({ label: nav.group.name, action: null }); + + // The category/group/part_type trio is rendered from EITHER the Nexpart + // branch (nxGroup/nxSubgroup/nxPartType) or the OEM branch (category/ + // group/partType), depending on which is populated. Only one branch + // should be active at a time after a navigation reset. + if (nav.nxGroup) parts.push({ label: nav.nxGroup.name, action: 'loadNxSubgroups' }); + else if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' }); + + if (nav.nxSubgroup) parts.push({ label: nav.nxSubgroup.name, action: 'loadNxPartTypes' }); + else if (nav.group) parts.push({ label: nav.group.name, action: 'loadPartTypes' }); + + if (nav.nxPartType) parts.push({ label: nav.nxPartType.name, action: null }); + else if (nav.partType) parts.push({ label: nav.partType.name, action: null }); var html = ''; for (var i = 0; i < parts.length; i++) { @@ -173,8 +258,12 @@ else if (action === 'loadModels') { resetNavFrom('models'); loadModels(); } else if (action === 'loadYears') { resetNavFrom('years'); loadYears(); } else if (action === 'loadEngines') { resetNavFrom('engines'); loadEngines(); } - else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategories(); } + else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategoriesForMode(); } else if (action === 'loadGroups') { resetNavFrom('groups'); loadGroups(); } + else if (action === 'loadPartTypes') { resetNavFrom('part_types'); loadPartTypes(); } + // Nexpart-branch breadcrumb actions + else if (action === 'loadNxSubgroups') { resetNavFrom('groups'); loadNexpartSubgroups(); } + else if (action === 'loadNxPartTypes') { resetNavFrom('part_types'); loadNexpartPartTypes(); } }); }); } @@ -182,17 +271,33 @@ function resetNav() { nav.level = 'brands'; pushNavState(); - nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = null; + nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = nav.partType = null; + nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null; } function resetNavFrom(level) { - var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'parts']; + var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'part_types', 'parts']; var idx = levels.indexOf(level); if (idx <= 0) { resetNav(); return; } nav.level = level; - var keys = [null, 'model', 'year', 'engine', 'category', 'group', null]; + // For each level, the corresponding state key(s) to clear. + // In Local mode, 'categories' clears nxGroup, 'groups' clears nxSubgroup, etc. + // We clear BOTH mode-specific keys at each level so a mode switch mid-navigation + // is always clean. + var keys = [ + null, // brands (nothing to clear above) + ['model'], // models + ['year'], // years + ['engine'], // engines + ['category', 'nxGroup'], // categories ← both OEM + Nexpart + ['group', 'nxSubgroup'], // groups ← both OEM + Nexpart + ['partType', 'nxPartType'], // part_types ← both OEM + Nexpart + null, // parts + ]; for (var i = idx; i < keys.length; i++) { - if (keys[i]) nav[keys[i]] = null; + if (!keys[i]) continue; + var ks = keys[i]; + for (var j = 0; j < ks.length; j++) nav[ks[j]] = null; } } @@ -221,7 +326,7 @@ setupLevelFilter(true); showLoading(); - apiFetch(API + '/brands').then(function (data) { + apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { if (!data) { @@ -317,7 +422,7 @@ if (data.data.length === 1) { var e = data.data[0]; nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') }; - loadCategories(); + loadCategoriesForMode(); return; } @@ -333,7 +438,7 @@ navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.engine = { id_mye: parseInt(this.dataset.myeId), name: this.dataset.name }; - loadCategories(); + loadCategoriesForMode(); }); }); }); @@ -389,32 +494,345 @@ navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.group = { id: parseInt(this.dataset.groupId), name: this.dataset.name }; + nav.partType = null; // reset deeper levels + loadPartTypes(); + }); + }); + }); + } + + // ─── Part Types (3rd subcategory level — Nexpart-style) ─── + function loadPartTypes() { + nav.level = 'part_types'; + nav.partType = null; + pushNavState(); + updateBreadcrumb(); + levelTitle.textContent = nav.group.name; + setupLevelFilter(true); + showLoading(); + + apiFetch(API + '/part-types?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id).then(function (data) { + hideLoading(); + if (!data || !data.data || !data.data.length) { + // No part types? Skip directly to all parts in the group. + loadParts(1); + return; + } + // Single part type? Skip the picker — go straight to parts. + if (data.data.length === 1) { + var only = data.data[0]; + nav.partType = { slug: only.slug, name: only.name }; + loadParts(1); + return; + } + navGrid.className = 'nav-grid'; + navGrid.innerHTML = data.data.map(function (pt) { + var img = pt.sample_image + ? '' + : ''; + return ''; + }).join(''); + + navGrid.querySelectorAll('.nav-card').forEach(function (card) { + card.addEventListener('click', function () { + nav.partType = { slug: this.dataset.ptSlug, name: this.dataset.ptName }; loadParts(1); }); }); }); } - function loadParts(page) { + // ═══════════════════════════════════════════════════════════════════ + // NEXPART (Local mode) — parallel navigation functions + // ═══════════════════════════════════════════════════════════════════ + // These run in parallel to loadCategories / loadGroups / loadPartTypes + // and are only invoked when catalogMode === 'local'. They share the + // same DOM hooks (navGrid, breadcrumb, levelTitle) but fetch from the + // Nexpart-filtered endpoints and store state in nav.nxGroup / nxSubgroup + // / nxPartType instead of nav.category / group / partType. + + function loadCategoriesForMode() { + // Dispatcher — called by every place that used to call loadCategories() + if (catalogMode === 'local') { + loadNexpartCategories(); + } else { + loadCategories(); + } + } + + function loadNexpartCategories() { + nav.level = 'categories'; + pushNavState(); + updateBreadcrumb(); + levelTitle.textContent = 'Categorias (Local)'; + setupLevelFilter(true); + showLoading(); + + apiFetch(API + '/categories?mode=local&mye_id=' + nav.engine.id_mye).then(function (data) { + hideLoading(); + if (!data || !data.data || !data.data.length) { + showEmpty('Sin categorias Local', 'Ninguna parte de este vehiculo mapea al catalogo Local.'); + return; + } + navGrid.className = 'nav-grid'; + navGrid.innerHTML = data.data.map(function (c) { + return ''; + }).join(''); + + navGrid.querySelectorAll('.nav-card').forEach(function (card) { + card.addEventListener('click', function () { + nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name }; + // Reset deeper Nexpart state so a re-click always goes to + // a clean subgroup list. + nav.nxSubgroup = null; + nav.nxPartType = null; + loadNexpartSubgroups(); + }); + }); + }); + } + + function loadNexpartSubgroups() { + nav.level = 'groups'; + pushNavState(); + updateBreadcrumb(); + levelTitle.textContent = nav.nxGroup.name; + setupLevelFilter(true); + showLoading(); + + var url = API + '/groups?mode=local&mye_id=' + nav.engine.id_mye + + '&category_slug=' + encodeURIComponent(nav.nxGroup.slug); + + apiFetch(url).then(function (data) { + hideLoading(); + if (!data || !data.data || !data.data.length) { + showEmpty('Sin subcategorias', 'No hay subcategorias en ' + nav.nxGroup.name); + return; + } + navGrid.className = 'nav-grid'; + navGrid.innerHTML = data.data.map(function (s) { + return ''; + }).join(''); + + navGrid.querySelectorAll('.nav-card').forEach(function (card) { + card.addEventListener('click', function () { + nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name }; + nav.nxPartType = null; + loadNexpartPartTypes(); + }); + }); + }); + } + + function loadNexpartPartTypes() { + nav.level = 'part_types'; + nav.nxPartType = null; + pushNavState(); + updateBreadcrumb(); + levelTitle.textContent = nav.nxSubgroup.name; + setupLevelFilter(true); + showLoading(); + + var url = API + '/part-types?mode=local&mye_id=' + nav.engine.id_mye + + '&group_slug=' + encodeURIComponent(nav.nxGroup.slug) + + '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug); + + apiFetch(url).then(function (data) { + hideLoading(); + if (!data || !data.data || !data.data.length) { + showEmpty('Sin tipos de parte', 'No hay tipos de parte en ' + nav.nxSubgroup.name); + return; + } + // Single part type? Auto-drill-down to parts (UX shortcut). + if (data.data.length === 1) { + var only = data.data[0]; + nav.nxPartType = { slug: only.slug, name: only.name }; + loadParts(1); + return; + } + navGrid.className = 'nav-grid'; + navGrid.innerHTML = data.data.map(function (pt) { + var img = pt.sample_image + ? '' + : ''; + return ''; + }).join(''); + + navGrid.querySelectorAll('.nav-card').forEach(function (card) { + card.addEventListener('click', function () { + nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name }; + loadParts(1); + }); + }); + }); + } + + // ═══════════════════════════════════════════════════════════════════ + // SHOP SUPPLIES (Supplies mode) — vehicle-independent + // ═══════════════════════════════════════════════════════════════════ + // Uses nav.nxGroup / nav.nxSubgroup / nav.nxPartType for state (reuses + // the Nexpart slot because Supplies is a subset of the Nexpart taxonomy) + // but calls a different set of endpoints (/shop-supplies/*) that don't + // require an mye_id. + + function loadShopSuppliesGroups() { + nav.level = 'categories'; + pushNavState(); + updateBreadcrumb(); + levelTitle.textContent = 'Shop Supplies (sin vehiculo)'; + setupLevelFilter(true); + showLoading(); + + apiFetch(API + '/shop-supplies/groups').then(function (data) { + hideLoading(); + if (!data || !data.data || !data.data.length) { + showEmpty('Sin supplies', 'No hay grupos de Shop Supplies disponibles.'); + return; + } + navGrid.className = 'nav-grid'; + navGrid.innerHTML = data.data.map(function (g) { + return ''; + }).join(''); + + navGrid.querySelectorAll('.nav-card').forEach(function (card) { + card.addEventListener('click', function () { + nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name }; + nav.nxSubgroup = null; + nav.nxPartType = null; + loadShopSuppliesSubgroups(); + }); + }); + }); + } + + function loadShopSuppliesSubgroups() { + nav.level = 'groups'; + pushNavState(); + updateBreadcrumb(); + levelTitle.textContent = nav.nxGroup.name; + setupLevelFilter(true); + showLoading(); + + var url = API + '/shop-supplies/subgroups?group_slug=' + encodeURIComponent(nav.nxGroup.slug); + apiFetch(url).then(function (data) { + hideLoading(); + if (!data || !data.data || !data.data.length) { + showEmpty('Sin subgrupos', 'No hay subgrupos con partes disponibles.'); + return; + } + navGrid.className = 'nav-grid'; + navGrid.innerHTML = data.data.map(function (s) { + return ''; + }).join(''); + + navGrid.querySelectorAll('.nav-card').forEach(function (card) { + card.addEventListener('click', function () { + nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name }; + nav.nxPartType = null; + loadShopSuppliesPartTypes(); + }); + }); + }); + } + + function loadShopSuppliesPartTypes() { + nav.level = 'part_types'; + nav.nxPartType = null; + pushNavState(); + updateBreadcrumb(); + levelTitle.textContent = nav.nxSubgroup.name; + setupLevelFilter(true); + showLoading(); + + var url = API + '/shop-supplies/part-types' + + '?group_slug=' + encodeURIComponent(nav.nxGroup.slug) + + '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug); + + apiFetch(url).then(function (data) { + hideLoading(); + if (!data || !data.data || !data.data.length) { + showEmpty('Sin tipos', 'No hay tipos de parte en este subgrupo.'); + return; + } + // Single part type? Skip the picker. + if (data.data.length === 1) { + var only = data.data[0]; + nav.nxPartType = { slug: only.slug, name: only.name }; + loadShopSuppliesParts(1); + return; + } + navGrid.className = 'nav-grid'; + navGrid.innerHTML = data.data.map(function (pt) { + var img = pt.sample_image + ? '' + : ''; + return ''; + }).join(''); + + navGrid.querySelectorAll('.nav-card').forEach(function (card) { + card.addEventListener('click', function () { + nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name }; + loadShopSuppliesParts(1); + }); + }); + }); + } + + function loadShopSuppliesParts(page) { nav.level = 'parts'; pushNavState(); currentPage = page || 1; updateBreadcrumb(); - levelTitle.textContent = nav.group.name; + levelTitle.textContent = nav.nxPartType.name; setupLevelFilter(false); showLoading(); navGrid.innerHTML = ''; - apiFetch(API + '/parts?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id + '&page=' + currentPage + '&per_page=30').then(function (data) { - hideLoading(); - if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; } + var url = API + '/shop-supplies/parts' + + '?group_slug=' + encodeURIComponent(nav.nxGroup.slug) + + '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug) + + '&part_type_slug=' + encodeURIComponent(nav.nxPartType.slug) + + '&page=' + currentPage + '&per_page=30'; + apiFetch(url).then(function (data) { + hideLoading(); + if (!data || !data.data || !data.data.length) { + showEmpty('Sin partes', 'No hay partes en este tipo.'); + return; + } + + // Reuse the same aftermarket-styled rendering as Local mode. partsGrid.style.display = ''; partsGrid.innerHTML = data.data.map(function (p) { var stockBadge; - if (p.local_stock > 0) { - stockBadge = 'En stock: ' + p.local_stock + ''; - } else if (p.bodega_count > 0) { + if (p.in_stock_network || p.bodega_count > 0) { stockBadge = '' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + ''; } else { stockBadge = 'Sin stock'; @@ -424,10 +842,123 @@ ? '' + esc(p.name) + '' : ''; - return '
' + + var tierClass = p.priority_tier === 1 ? ' part-card--tier1' : (p.priority_tier === 2 ? ' part-card--tier2' : ''); + var manuBadge = ''; + if (p.manufacturer) { + var tierStar = p.priority_tier === 1 ? '' : ''; + manuBadge = '
' + esc(p.manufacturer) + '' + tierStar + '
'; + } + var skuLine = p.part_number + ? esc(p.part_number) + ' · OEM: ' + esc(p.oem_part_number || '') + '' + : esc(p.oem_part_number || ''); + + return '
' + '
' + imgHtml + '
' + '
' + - '
' + esc(p.oem_part_number) + '
' + + manuBadge + + '
' + skuLine + '
' + + '
' + esc(p.name) + '
' + + '
' + + '' + + '
'; + }).join(''); + + partsGrid.querySelectorAll('.part-card').forEach(function (card) { + card.addEventListener('click', function () { + openPartDetail(parseInt(this.dataset.partId)); + }); + }); + + if (data.pagination) renderPagination(data.pagination); + }); + } + + function loadParts(page) { + nav.level = 'parts'; + pushNavState(); + currentPage = page || 1; + updateBreadcrumb(); + + // Title: Nexpart part type > TecDoc part type > TecDoc group + if (nav.nxPartType) { + levelTitle.textContent = nav.nxPartType.name; + } else if (nav.partType) { + levelTitle.textContent = nav.partType.name; + } else if (nav.group) { + levelTitle.textContent = nav.group.name; + } else { + levelTitle.textContent = 'Partes'; + } + + setupLevelFilter(false); + showLoading(); + navGrid.innerHTML = ''; + + // Build the URL based on which navigation branch the user took. + // Nexpart branch uses slug-based params; OEM branch uses integer ids. + var url; + if (nav.nxGroup && nav.nxSubgroup && nav.nxPartType) { + url = API + '/parts?mode=local' + + '&mye_id=' + nav.engine.id_mye + + '&page=' + currentPage + '&per_page=30' + + '&nexpart_group=' + encodeURIComponent(nav.nxGroup.slug) + + '&nexpart_subgroup=' + encodeURIComponent(nav.nxSubgroup.slug) + + '&nexpart_part_type=' + encodeURIComponent(nav.nxPartType.slug); + } else { + var ptParam = nav.partType ? '&part_type=' + encodeURIComponent(nav.partType.slug) : ''; + url = API + '/parts?mye_id=' + nav.engine.id_mye + + '&group_id=' + nav.group.id + + '&page=' + currentPage + '&per_page=30' + + '&mode=' + catalogMode + + ptParam; + } + + apiFetch(url).then(function (data) { + hideLoading(); + if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; } + + var isLocal = (catalogMode === 'local'); + + partsGrid.style.display = ''; + partsGrid.innerHTML = data.data.map(function (p) { + // Stock badge — prefer tenant stock, then warehouse network, else fallback + var stockBadge; + if (p.local_stock > 0) { + stockBadge = 'En stock: ' + p.local_stock + ''; + } else if (p.in_stock_network || p.bodega_count > 0) { + stockBadge = '' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + ''; + } else { + stockBadge = 'Sin stock'; + } + + var imgHtml = p.image_url + ? '' + esc(p.name) + '' + : ''; + + // Local-mode extras: manufacturer badge + priority tier indicator + var manuBadge = ''; + var tierClass = ''; + if (isLocal && p.manufacturer) { + var tierLabel = ''; + if (p.priority_tier === 1) { tierClass = ' part-card--tier1'; tierLabel = '★'; } + else if (p.priority_tier === 2) { tierClass = ' part-card--tier2'; tierLabel = ''; } + manuBadge = '
' + esc(p.manufacturer) + '' + + (tierLabel ? '' + tierLabel + '' : '') + '
'; + } + + // SKU to show: aftermarket part_number in local mode, OEM number otherwise + var skuLine = isLocal && p.part_number + ? esc(p.part_number) + ' · OEM: ' + esc(p.oem_part_number) + '' + : esc(p.oem_part_number); + + return '
' + + '
' + imgHtml + '
' + + '
' + + manuBadge + + '
' + skuLine + '
' + '
' + esc(p.name) + '
' + '
' + '
-
-
-
- -
-
-
Epson TM-T88VI
-
- En línea - Tickets de venta -
-
USB · 192.168.10.50
-
Predeterminada para POS
-
- - -
-
-
- -
-
- -
-
-
Zebra GK420d
-
- En línea - Etiquetas de código de barras -
-
USB · 192.168.10.51
-
Predeterminada para inventario
-
- - -
-
-
- -
-
- -
-
-
HP LaserJet Pro M404
-
- Fuera de línea - Facturas y reportes -
-
Red · 192.168.10.52
-
- - -
+
+
+
🖨️
+
Sin impresoras configuradas
+
+ La configuracion de impresoras se hace desde el navegador.
+ Ve a chrome://devices o usa Ctrl+P para imprimir.
@@ -1595,41 +1551,41 @@
- +
- + Dejar en 0 si no aplica
- +
- +
- + +
- + + + + +
- +
@@ -1926,6 +1882,7 @@ + diff --git a/pos/templates/customers.html b/pos/templates/customers.html index fe71929..5bbd2ea 100644 --- a/pos/templates/customers.html +++ b/pos/templates/customers.html @@ -6,6 +6,7 @@ Nexus Autoparts — Clientes + @@ -1721,13 +1722,41 @@
Directorio, crédito y historial de compras
- -
- - +
- + - - Volver +
+ -
- - - -
+ + - -