266 lines
8.9 KiB
Python
266 lines
8.9 KiB
Python
# /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/<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/<part_number>', 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/<part_number>', 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
|