"""Supplier Catalog Blueprint — parts from suppliers with vehicle compatibility. Independent from inventory. Supports: - Browse by supplier/category - Search by text or vehicle (MYE or make/model/year) - Part detail with compatibilities and interchanges - Bulk import via Excel """ import csv import io from datetime import date from flask import Blueprint, request, jsonify, g, render_template from psycopg2.extras import RealDictCursor from tenant_db import get_master_conn from middleware import require_auth supplier_catalog_bp = Blueprint('supplier_catalog', __name__, url_prefix='/pos/api/supplier-catalog') # ─── Helpers ─────────────────────────────────────────────────────────────── def _get_master_conn(): return get_master_conn() def _json_response(data, status=200): return jsonify(data), status # ─── Brands ──────────────────────────────────────────────────────────────── @supplier_catalog_bp.route('/brands', methods=['GET']) @require_auth('catalog.view') def list_brands(): """Return distinct makes (vehicle brands) present in the supplier catalog.""" conn = _get_master_conn() cur = conn.cursor() cur.execute(""" SELECT DISTINCT make, COUNT(*) as cnt FROM supplier_catalog_compat WHERE make IS NOT NULL AND make != '' GROUP BY make ORDER BY make ASC """) rows = cur.fetchall() cur.close(); conn.close() return jsonify({'brands': [{'name': r[0], 'count': r[1]} for r in rows]}) # ─── Search ──────────────────────────────────────────────────────────────── @supplier_catalog_bp.route('/search', methods=['GET']) @require_auth('catalog.view') def search_items(): """Search supplier catalog by text and/or vehicle.""" q = (request.args.get('q') or '').strip() mye_id = request.args.get('mye_id', type=int) make = (request.args.get('make') or '').strip() model = (request.args.get('model') or '').strip() year = request.args.get('year', type=int) supplier = (request.args.get('supplier') or '').strip() category = (request.args.get('category') or '').strip() page = max(1, request.args.get('page', 1, type=int)) per_page = min(100, request.args.get('per_page', 30, type=int)) offset = (page - 1) * per_page conn = _get_master_conn() cur = conn.cursor() # Build query dynamically where_parts = ["sc.is_active = true"] params = [] if supplier: where_parts.append("sc.supplier_name = %s") params.append(supplier) if category: where_parts.append("sc.category = %s") params.append(category) # Text search on SKU, name, or interchange part_number if q: where_parts.append(""" (sc.sku ILIKE %s OR sc.name ILIKE %s OR EXISTS ( SELECT 1 FROM supplier_catalog_interchange sci2 WHERE sci2.catalog_id = sc.id AND sci2.part_number ILIKE %s )) """) like_q = f'%{q}%' params.extend([like_q, like_q, like_q]) # Vehicle filter vehicle_join = "" if mye_id: vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id" where_parts.append("scc.model_year_engine_id = %s") params.append(mye_id) elif make or model or year: vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id" if make: where_parts.append("scc.make ILIKE %s") params.append(f'%{make}%') if model: where_parts.append("scc.model ILIKE %s") params.append(f'%{model}%') if year: where_parts.append("scc.year = %s") params.append(year) where_sql = " AND ".join(where_parts) # Count total count_sql = f""" SELECT COUNT(DISTINCT sc.id) FROM supplier_catalog sc {vehicle_join} WHERE {where_sql} """ cur.execute(count_sql, params) total = cur.fetchone()[0] # Fetch page fetch_sql = f""" SELECT DISTINCT sc.id, sc.supplier_name, sc.sku, sc.name, sc.category, sc.description, sc.image_url FROM supplier_catalog sc {vehicle_join} WHERE {where_sql} ORDER BY sc.name ASC LIMIT %s OFFSET %s """ cur.execute(fetch_sql, params + [per_page, offset]) rows = cur.fetchall() items = [] for r in rows: items.append({ 'id': r[0], 'supplier_name': r[1], 'sku': r[2], 'name': r[3], 'category': r[4], 'description': r[5], 'image_url': r[6], }) cur.close(); conn.close() return jsonify({ 'data': items, 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page, } }) # ─── Item Detail ─────────────────────────────────────────────────────────── @supplier_catalog_bp.route('/items/', methods=['GET']) @require_auth('catalog.view') def get_item_detail(item_id): """Return full detail for a supplier catalog item including compat + interchanges.""" conn = _get_master_conn() cur = conn.cursor() cur.execute(""" SELECT id, supplier_name, sku, name, category, description, image_url, created_at FROM supplier_catalog WHERE id = %s AND is_active = true """, (item_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Item not found'}), 404 item = { 'id': row[0], 'supplier_name': row[1], 'sku': row[2], 'name': row[3], 'category': row[4], 'description': row[5], 'image_url': row[6], 'created_at': str(row[7]) if row[7] else None, } # Compatibilities — deduplicate by (make, model, year, engine) because # the same vehicle may map to multiple MYE ids (especially when engine # text is empty from the supplier catalog). cur.execute(""" SELECT make, model, year, engine, model_year_engine_id, source FROM supplier_catalog_compat WHERE catalog_id = %s ORDER BY make, model, year, engine """, (item_id,)) seen_compat = set() compatibilities = [] for r in cur.fetchall(): key = (r[0], r[1], r[2], r[3]) if key in seen_compat: continue seen_compat.add(key) compatibilities.append({ 'make': r[0], 'model': r[1], 'year': r[2], 'engine': r[3], 'model_year_engine_id': r[4], 'source': r[5] }) item['compatibilities'] = compatibilities # Interchanges cur.execute(""" SELECT brand, part_number FROM supplier_catalog_interchange WHERE catalog_id = %s ORDER BY brand, part_number """, (item_id,)) item['interchanges'] = [ {'brand': r[0], 'part_number': r[1]} for r in cur.fetchall() ] cur.close(); conn.close() return jsonify(item) # ─── Categories ──────────────────────────────────────────────────────────── @supplier_catalog_bp.route('/categories', methods=['GET']) @require_auth('catalog.view') def list_categories(): """Return distinct categories with counts.""" conn = _get_master_conn() cur = conn.cursor() cur.execute(""" SELECT category, COUNT(*) as cnt FROM supplier_catalog WHERE is_active = true GROUP BY category ORDER BY cnt DESC """) rows = cur.fetchall() cur.close(); conn.close() return jsonify({'categories': [{'name': r[0], 'count': r[1]} for r in rows]}) # ─── Suppliers ───────────────────────────────────────────────────────────── @supplier_catalog_bp.route('/suppliers', methods=['GET']) @require_auth('catalog.view') def list_suppliers(): """Return distinct suppliers with counts.""" conn = _get_master_conn() cur = conn.cursor() cur.execute(""" SELECT supplier_name, COUNT(*) as cnt FROM supplier_catalog WHERE is_active = true GROUP BY supplier_name ORDER BY supplier_name ASC """) rows = cur.fetchall() cur.close(); conn.close() return jsonify({'suppliers': [{'name': r[0], 'count': r[1]} for r in rows]}) # ─── Delete ──────────────────────────────────────────────────────────────── @supplier_catalog_bp.route('/items/', methods=['DELETE']) @require_auth('inventory.edit') def delete_item(item_id): """Soft-delete a supplier catalog item.""" conn = _get_master_conn() cur = conn.cursor() cur.execute("UPDATE supplier_catalog SET is_active = false WHERE id = %s", (item_id,)) conn.commit() cur.close(); conn.close() return jsonify({'success': True}) # ─── Prices ──────────────────────────────────────────────────────────────── def _get_latest_prices(master_conn, tenant_id, catalog_ids): """Return a dict catalog_id -> price row for the latest active price per item.""" if not catalog_ids: return {} cur = master_conn.cursor() cur.execute(""" SELECT DISTINCT ON (catalog_id) catalog_id, price, currency, effective_from, effective_to FROM supplier_catalog_prices WHERE tenant_id = %s AND catalog_id = ANY(%s) AND is_active = true AND (effective_to IS NULL OR effective_to >= CURRENT_DATE) ORDER BY catalog_id, effective_from DESC """, (tenant_id, list(catalog_ids))) prices = {} for r in cur.fetchall(): prices[r[0]] = { 'price': float(r[1]) if r[1] is not None else None, 'currency': r[2] or 'MXN', 'effective_from': str(r[3]) if r[3] else None, 'effective_to': str(r[4]) if r[4] else None, } cur.close() return prices @supplier_catalog_bp.route('/prices', methods=['GET']) @require_auth('catalog.view') def list_prices(): """List active supplier prices for the current tenant.""" supplier = (request.args.get('supplier') or '').strip() q = (request.args.get('q') or '').strip() page = max(1, request.args.get('page', 1, type=int)) per_page = min(200, request.args.get('per_page', 50, type=int)) offset = (page - 1) * per_page conn = _get_master_conn() cur = conn.cursor() where_parts = ["sc.is_active = true", "scp.tenant_id = %s"] params = [g.tenant_id] if supplier: where_parts.append("sc.supplier_name = %s") params.append(supplier) if q: where_parts.append("(sc.sku ILIKE %s OR sc.name ILIKE %s)") like_q = f'%{q}%' params.extend([like_q, like_q]) where_sql = " AND ".join(where_parts) cur.execute(f""" SELECT COUNT(DISTINCT sc.id) FROM supplier_catalog sc JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id WHERE {where_sql} AND scp.is_active = true AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE) """, params) total = cur.fetchone()[0] cur.execute(f""" SELECT DISTINCT ON (sc.id) sc.id, sc.supplier_name, sc.sku, sc.name, sc.category, scp.price, scp.currency, scp.effective_from, scp.effective_to FROM supplier_catalog sc JOIN supplier_catalog_prices scp ON scp.catalog_id = sc.id WHERE {where_sql} AND scp.is_active = true AND (scp.effective_to IS NULL OR scp.effective_to >= CURRENT_DATE) ORDER BY sc.id, scp.effective_from DESC LIMIT %s OFFSET %s """, params + [per_page, offset]) items = [] for r in cur.fetchall(): items.append({ 'catalog_id': r[0], 'supplier_name': r[1], 'sku': r[2], 'name': r[3], 'category': r[4], 'price': float(r[5]) if r[5] is not None else None, 'currency': r[6] or 'MXN', 'effective_from': str(r[7]) if r[7] else None, 'effective_to': str(r[8]) if r[8] else None, }) cur.close(); conn.close() return jsonify({ 'data': items, 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page} }) @supplier_catalog_bp.route('/prices/template', methods=['GET']) @require_auth('catalog.view') def download_price_template(): """Return a CSV template for uploading supplier prices.""" output = io.StringIO() writer = csv.writer(output) writer.writerow(['supplier_name', 'sku', 'price', 'currency', 'effective_from']) writer.writerow(['YOKOMITSU', 'DENK070A', '1250.00', 'MXN', '2026-01-01']) output.seek(0) return (output.getvalue(), 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': 'attachment; filename="supplier_prices_template.csv"' }) def _read_upload_file(file_storage): """Read CSV or Excel upload and return list of dict rows.""" filename = (file_storage.filename or '').lower() content = file_storage.read() if filename.endswith('.csv'): text = content.decode('utf-8-sig') reader = csv.DictReader(io.StringIO(text)) return [row for row in reader] if filename.endswith(('.xlsx', '.xls')): try: import openpyxl except ImportError as e: raise RuntimeError('openpyxl no instalado; sube CSV o instala openpyxl') from e wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True) ws = wb.active rows = list(ws.iter_rows(values_only=True)) if not rows: return [] headers = [str(c).strip().lower() if c else '' for c in rows[0]] return [ dict(zip(headers, row)) for row in rows[1:] if any(cell is not None and str(cell).strip() for cell in row) ] raise ValueError('Formato no soportado. Usa CSV o Excel (.xlsx)') @supplier_catalog_bp.route('/prices/upload', methods=['POST']) @require_auth('inventory.edit') def upload_prices(): """Bulk upload/upsert supplier prices for the current tenant. Expected columns: supplier_name, sku, price, [currency], [effective_from] """ if 'file' not in request.files: return jsonify({'error': 'Archivo requerido'}), 400 file_storage = request.files['file'] if not file_storage or not file_storage.filename: return jsonify({'error': 'Archivo requerido'}), 400 try: rows = _read_upload_file(file_storage) except Exception as e: return jsonify({'error': str(e)}), 400 if not rows: return jsonify({'error': 'El archivo esta vacio o no tiene filas validas'}), 400 conn = _get_master_conn() cur = conn.cursor() # Build a lookup of supplier+sku -> catalog_id # We expect all rows to refer to existing catalog items. normalized_rows = [] errors = [] for idx, row in enumerate(rows, start=2): supplier = str(row.get('supplier_name') or '').strip() sku = str(row.get('sku') or '').strip() price_raw = row.get('price') currency = str(row.get('currency') or 'MXN').strip().upper() or 'MXN' eff_from_raw = row.get('effective_from') if not supplier or not sku: errors.append(f'Fila {idx}: supplier_name y sku son requeridos') continue try: price = float(str(price_raw).replace(',', '').strip()) except Exception: errors.append(f'Fila {idx}: precio invalido para {supplier}/{sku}') continue eff_from = date.today() if eff_from_raw: try: eff_from = date.fromisoformat(str(eff_from_raw).strip()) except Exception: errors.append(f'Fila {idx}: effective_from invalido (use YYYY-MM-DD)') continue normalized_rows.append((supplier, sku, price, currency, eff_from)) if errors: cur.close(); conn.close() return jsonify({'error': 'Errores de validacion', 'details': errors}), 400 # Bulk lookup catalog IDs catalog_lookup = {} for supplier, sku, *_ in normalized_rows: catalog_lookup[(supplier, sku)] = None if catalog_lookup: keys = list(catalog_lookup.keys()) # Batch query using unnest cur.execute(""" SELECT supplier_name, sku, id FROM supplier_catalog WHERE is_active = true AND (supplier_name, sku) = ANY(%s) """, (keys,)) for r in cur.fetchall(): catalog_lookup[(r[0], r[1])] = r[2] upserts = [] for idx, (supplier, sku, price, currency, eff_from) in enumerate(normalized_rows, start=2): catalog_id = catalog_lookup.get((supplier, sku)) if not catalog_id: errors.append(f'Fila {idx}: SKU {supplier}/{sku} no existe en el catalogo') continue upserts.append((g.tenant_id, catalog_id, price, currency, eff_from)) if errors: cur.close(); conn.close() return jsonify({'error': 'Errores de validacion', 'details': errors}), 400 inserted = 0 updated = 0 for tenant_id, catalog_id, price, currency, eff_from in upserts: # Try update existing row with same (tenant_id, catalog_id, effective_from) cur.execute(""" UPDATE supplier_catalog_prices SET price = %s, currency = %s, is_active = true, updated_at = NOW() WHERE tenant_id = %s AND catalog_id = %s AND effective_from = %s RETURNING id """, (price, currency, tenant_id, catalog_id, eff_from)) if cur.fetchone(): updated += 1 else: cur.execute(""" INSERT INTO supplier_catalog_prices (tenant_id, catalog_id, price, currency, effective_from, is_active) VALUES (%s, %s, %s, %s, %s, true) """, (tenant_id, catalog_id, price, currency, eff_from)) inserted += 1 conn.commit() cur.close(); conn.close() return jsonify({ 'success': True, 'processed': len(upserts), 'inserted': inserted, 'updated': updated, })