diff --git a/pos/blueprints/catalog_bp.py b/pos/blueprints/catalog_bp.py new file mode 100644 index 0000000..53bc103 --- /dev/null +++ b/pos/blueprints/catalog_bp.py @@ -0,0 +1,265 @@ +# /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