# /home/Autopartes/pos/blueprints/catalog_bp.py """Catalog blueprint: browsable inventory with cart, external availability lookup, and cross-reference queries.""" from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn from services.inventory_engine import get_stock_bulk catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog') @catalog_bp.route('/search', methods=['GET']) @require_auth('catalog.view') def search_catalog(): """Search the tenant's inventory as a catalog. Returns items with stock and pricing. Query params: q (search), category, brand, vehicle_brand, page, per_page NOTE on filtering: - `brand` filters by part manufacturer (Bosch, NGK, etc.) — the `brand` column. - `vehicle_brand` filters by vehicle compatibility (Toyota, Nissan, etc.) — searches inside the `vehicle_compatibility` JSON field via ILIKE on the cast text. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() q = request.args.get('q', '') category = request.args.get('category', '') brand = request.args.get('brand', '') vehicle_brand = request.args.get('vehicle_brand', '') branch_id = request.args.get('branch_id', g.branch_id) in_stock_only = request.args.get('in_stock', '') == 'true' page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 30)), 100) where = ["i.is_active = true"] params = [] if branch_id: where.append("i.branch_id = %s") params.append(branch_id) if q: where.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode = %s)") params.extend([f'%{q}%', f'%{q}%', q]) if category: where.append("i.category_id = %s") params.append(int(category)) if brand: where.append("i.brand ILIKE %s") params.append(f'%{brand}%') if vehicle_brand: where.append("i.vehicle_compatibility::text ILIKE %s") params.append(f'%{vehicle_brand}%') where_sql = " AND ".join(where) cur.execute(f"SELECT count(*) FROM inventory i WHERE {where_sql}", params) total = cur.fetchone()[0] cur.execute(f""" SELECT i.id, i.part_number, i.barcode, i.name, i.brand, i.unit, i.price_1, i.price_2, i.price_3, i.tax_rate, i.image_url, i.category_id, i.location, i.min_stock FROM inventory i WHERE {where_sql} ORDER BY i.name LIMIT %s OFFSET %s """, params + [per_page, (page - 1) * per_page]) rows = cur.fetchall() inv_ids = [r[0] for r in rows] # Bulk stock lookup stock_map = {} if inv_ids: cur.execute(""" SELECT inventory_id, COALESCE(SUM(quantity), 0) FROM inventory_operations WHERE inventory_id = ANY(%s) GROUP BY inventory_id """, (inv_ids,)) stock_map = {r[0]: r[1] for r in cur.fetchall()} items = [] for r in rows: stock = stock_map.get(r[0], 0) if in_stock_only and stock <= 0: continue items.append({ 'id': r[0], 'part_number': r[1], 'barcode': r[2], 'name': r[3], 'brand': r[4], 'unit': r[5], 'price_1': float(r[6]) if r[6] else 0, 'price_2': float(r[7]) if r[7] else 0, 'price_3': float(r[8]) if r[8] else 0, 'tax_rate': float(r[9]) if r[9] else 0.16, 'image_url': r[10], 'category_id': r[11], 'location': r[12], 'stock': stock, 'low_stock': r[13] and stock < r[13] if r[13] else False }) cur.close(); conn.close() total_pages = (total + per_page - 1) // per_page return jsonify({ 'data': items, 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages} }) @catalog_bp.route('/categories', methods=['GET']) @require_auth('catalog.view') def catalog_categories(): """Get categories with item counts for catalog navigation.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_id = request.args.get('branch_id', g.branch_id) where = "i.is_active = true" params = [] if branch_id: where += " AND i.branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT i.category_id, COUNT(*) as item_count FROM inventory i WHERE {where} AND i.category_id IS NOT NULL GROUP BY i.category_id ORDER BY item_count DESC """, params) categories = [{'id': r[0], 'count': r[1]} for r in cur.fetchall()] cur.close(); conn.close() return jsonify({'data': categories}) @catalog_bp.route('/brands', methods=['GET']) @require_auth('catalog.view') def catalog_brands(): """Get part manufacturer brands with item counts for catalog navigation.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() branch_id = request.args.get('branch_id', g.branch_id) where = "i.is_active = true" params = [] if branch_id: where += " AND i.branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT i.brand, COUNT(*) as item_count FROM inventory i WHERE {where} AND i.brand IS NOT NULL AND i.brand != '' GROUP BY i.brand ORDER BY item_count DESC """, params) brands = [{'name': r[0], 'count': r[1]} for r in cur.fetchall()] cur.close(); conn.close() return jsonify({'data': brands}) @catalog_bp.route('/barcode/', methods=['GET']) @require_auth('catalog.view') def lookup_barcode(barcode): """Lookup a part by barcode (for scanner). Returns item with stock.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT i.id, i.part_number, i.barcode, i.name, i.brand, i.unit, i.price_1, i.price_2, i.price_3, i.tax_rate, i.cost, i.image_url, i.branch_id FROM inventory i WHERE (i.barcode = %s OR i.part_number = %s) AND i.is_active = true LIMIT 1 """, (barcode, barcode)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Part not found'}), 404 from services.inventory_engine import get_stock item = { 'id': row[0], 'part_number': row[1], 'barcode': row[2], 'name': row[3], 'brand': row[4], 'unit': row[5], 'price_1': float(row[6]) if row[6] else 0, 'price_2': float(row[7]) if row[7] else 0, 'price_3': float(row[8]) if row[8] else 0, 'tax_rate': float(row[9]) if row[9] else 0.16, 'cost': float(row[10]) if row[10] else 0, 'image_url': row[11], 'stock': get_stock(conn, row[0], row[12]) } cur.close(); conn.close() return jsonify(item) @catalog_bp.route('/external-availability/', methods=['GET']) @require_auth('catalog.view') def external_availability(part_number): """Check part availability in external bodegas (Nexus marketplace). Requires internet. Calls the main Nexus API. """ import requests try: # Query the Nexus master API for warehouse inventory # This calls the existing /api/search endpoint on the main Nexus server resp = requests.get( 'http://localhost:5000/api/search', params={'q': part_number}, timeout=5 ) if resp.status_code != 200: return jsonify({'data': [], 'source': 'nexus', 'error': 'Catalog unavailable'}), 200 results = resp.json() return jsonify({'data': results.get('results', []), 'source': 'nexus'}) except requests.RequestException: return jsonify({'data': [], 'source': 'nexus', 'error': 'No internet connection'}), 200 @catalog_bp.route('/cross-references/', methods=['GET']) @require_auth('catalog.view') def cross_references(part_number): """Get OEM <-> aftermarket cross-references for a part number. Calls the Nexus master API which has the full cross-reference database (part_cross_references table). Returns OEM equivalents and aftermarket alternatives. This follows the same pattern as external-availability: the tenant POS calls the central Nexus server which holds the master catalog data. """ import requests try: resp = requests.get( 'http://localhost:5000/api/cross-references', params={'part_number': part_number}, timeout=5 ) if resp.status_code != 200: return jsonify({'data': [], 'source': 'nexus', 'error': 'Cross-reference service unavailable'}), 200 results = resp.json() return jsonify({ 'part_number': part_number, 'cross_references': results.get('cross_references', []), 'source': 'nexus' }) except requests.RequestException: return jsonify({ 'part_number': part_number, 'cross_references': [], 'source': 'nexus', 'error': 'No internet connection' }), 200