feat(catalog): supplier catalog cleanup, fuzzy matching, and navigation fixes

- Cleaned 137+ fake engine-displacement models from supplier imports
  (v3/v4 scripts: Chevrolet, Ford, Chrysler, Dodge, Jeep, Nissan, etc.)
- Removed 1,251+ corrupted models (INT. prefixes, year-suffix, torque specs,
  empty names, trailing-year variants)
- Migrated supplier tables to master DB (supplier_catalog,
  supplier_catalog_compat, supplier_catalog_interchange)
- Fixed _get_mye_ids_with_parts() to query supplier_catalog_compat from
  master DB so supplier-only vehicles appear for all tenants
- Added fuzzy model matcher with parenthesis stripping, noise suffix removal,
  compact matching, prefix/substring fallback, model aliases, and ±3 year
  proximity
- Matched compat rows: KEEP GREEN +14,152, KNADIAN +3,021, VAZLO +127,500,
  LUK +477, RAYBESTOS +1,743
- Added KNADIAN catalog importer with year-range expansion and future-year
  filtering
- Added VAZLO catalog importer with position parsing and SKU-in-model cleanup
- Added Keep Green, LUK, Yokomitsu, Raybestos catalog importers
- Cache clearing after cleanups (_classify_cache_*, nexus:mye_ids:*,
  nexus:brand_mye_counts:*)

Final match rates:
- KEEP GREEN: 90.3%
- VAZLO: 93.6%
- YOKOMITSU: 100.0%
- KNADIAN: 57.4%
- LUK: 51.0%
- RAYBESTOS: 55.9%
This commit is contained in:
2026-06-09 07:47:42 +00:00
parent 5ea667b80e
commit ea29cc31c0
53 changed files with 7727 additions and 548 deletions

View File

@@ -98,7 +98,9 @@ def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands):
part_ids = []
for p in parts_data:
pid = p.get('id_part') or p.get('id')
if pid is not None:
# Skip local inventory IDs (strings like 'inv:3') — aftermarket filter
# only applies to catalog parts with integer OEM part IDs.
if pid is not None and isinstance(pid, int):
part_ids.append(pid)
if not part_ids:
return parts_data
@@ -124,10 +126,11 @@ 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, mode=mode)
def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_brands(master, year_id=year_id, mode=mode, mye_ids=mye_ids)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/models', methods=['GET'])
@@ -137,10 +140,11 @@ def models():
year_id = request.args.get('year_id', type=int)
if not brand_id:
return jsonify({'error': 'brand_id required'}), 400
def _do(master):
data = catalog_service.get_models(master, brand_id, year_id=year_id)
def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_models(master, brand_id, year_id=year_id, mye_ids=mye_ids)
return jsonify({'data': data})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/years', methods=['GET'])
@@ -149,10 +153,11 @@ def years():
model_id = request.args.get('model_id', type=int)
if not model_id:
return jsonify({'error': 'model_id required'}), 400
def _do(master):
data = catalog_service.get_years(master, model_id)
def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_years(master, model_id, mye_ids=mye_ids)
return jsonify({'data': data})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/years-all', methods=['GET'])
@@ -175,10 +180,11 @@ def engines():
year_id = request.args.get('year_id', type=int)
if not model_id or not year_id:
return jsonify({'error': 'model_id and year_id required'}), 400
def _do(master):
data = catalog_service.get_engines(master, model_id, year_id)
def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_engines(master, model_id, year_id, mye_ids=mye_ids)
return jsonify({'data': data})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/categories', methods=['GET'])
@@ -198,7 +204,7 @@ def categories():
def _do(master, tenant, branch_id):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
if mode == 'local':
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id, tenant)
else:
data = catalog_service.get_categories(master, mye_id, allowed_brands)
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
@@ -220,17 +226,17 @@ def groups():
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
def _do(master, tenant, branch_id):
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)
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug, tenant)
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)
return _with_conns(_do)
# ─── Parts with stock enrichment (master + tenant) ───
@@ -251,19 +257,19 @@ def part_types():
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
def _do(master, tenant, branch_id):
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
master, mye_id, group_slug, subgroup_slug, tenant
)
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)
return _with_conns(_do)
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
@@ -307,8 +313,8 @@ 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)
page = max(1, request.args.get('page', 1, type=int) or 1)
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 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
def _do(master, tenant, branch_id):
@@ -344,8 +350,8 @@ def parts():
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)
page = max(1, request.args.get('page', 1, type=int) or 1)
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
@@ -364,14 +370,20 @@ def parts():
def _do(master, tenant, branch_id):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
# For local mode with allowed_brands, fetch everything first so filtering
# happens before pagination. OEM mode keeps post-filter for now.
fetch_all_for_filter = bool(allowed_brands) and (mode == 'local' or use_nexpart_nav)
_page = 1 if fetch_all_for_filter else page
_per_page = 9999 if fetch_all_for_filter else 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,
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,
master, mye_id, group_id, tenant, branch_id, _page, _per_page, part_type=part_type,
)
else:
result = catalog_service.get_parts(
@@ -379,6 +391,11 @@ def parts():
)
if allowed_brands:
result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands)
if fetch_all_for_filter:
total = len(result['data'])
offset = (page - 1) * per_page
result['data'] = result['data'][offset:offset + per_page]
result['pagination'] = catalog_service._pagination(page, per_page, total)
result['allowed_brands'] = allowed_brands or []
return jsonify(result)
return _with_conns(_do)
@@ -811,7 +828,7 @@ def brand_parts():
if tenant and oem_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, oem_ids)
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
except Exception:
pass
@@ -886,7 +903,7 @@ def brand_parts():
if tenant and part_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, part_ids)
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
except Exception:
pass
@@ -1007,7 +1024,7 @@ def mye_parts():
if tenant and oem_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, oem_ids)
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
except Exception:
pass
@@ -1082,7 +1099,7 @@ def mye_parts():
if tenant and part_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, part_ids)
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
except Exception:
pass

View File

@@ -469,21 +469,12 @@ _ALLOWED_PART_BRANDS = [
@config_bp.route('/available-brands', methods=['GET'])
@require_auth()
def get_available_brands():
"""Return whitelisted aftermarket manufacturer names from master DB."""
from tenant_db import get_master_conn
conn = get_master_conn()
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT m.name_manufacture
FROM manufacturers m
JOIN aftermarket_parts ap ON ap.manufacturer_id = m.id_manufacture
WHERE m.name_manufacture IS NOT NULL AND m.name_manufacture != ''
AND LOWER(m.name_manufacture) = ANY(%s)
ORDER BY m.name_manufacture ASC
""", ([b.lower() for b in _ALLOWED_PART_BRANDS],))
brands = [r[0] for r in cur.fetchall()]
cur.close()
conn.close()
"""Return the whitelisted part manufacturer names.
The master DB manufacturers/aftermarket_parts tables were removed with
TecDoc, so we return the curated whitelist directly.
"""
brands = sorted({b.strip() for b in _ALLOWED_PART_BRANDS if b and b.strip()})
return jsonify({'brands': brands})

View File

@@ -12,6 +12,7 @@ dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api
from middleware import require_auth
from tenant_db import get_tenant_conn
class DecimalEncoder(json.JSONEncoder):
@@ -25,83 +26,95 @@ class DecimalEncoder(json.JSONEncoder):
@require_auth()
def get_stats():
"""Summary stats for today and this month."""
from tenant_db import get_tenant_db
db = get_tenant_db()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
today = datetime.utcnow().date()
month_start = today.replace(day=1)
# Sales today
today_sales = db.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s""", (today,)
).fetchone()
try:
# Sales today
cur.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s""", (today,)
)
today_sales = cur.fetchone()
# Sales this month
month_sales = db.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
).fetchone()
# Sales this month
cur.execute(
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
)
month_sales = cur.fetchone()
# Top 5 products today
top_products = db.execute(
"""SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id_sale
JOIN parts p ON si.part_id = p.id_part
WHERE DATE(s.created_at) = %s
GROUP BY p.name
ORDER BY revenue DESC
LIMIT 5""", (today,)
).fetchall()
# Top 5 products today
cur.execute(
"""SELECT si.name, SUM(si.quantity) as qty, SUM(si.subtotal) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id
WHERE DATE(s.created_at) = %s
GROUP BY si.name
ORDER BY revenue DESC
LIMIT 5""", (today,)
)
top_products = cur.fetchall()
# Hourly sales today (0-23)
hourly = db.execute(
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s
GROUP BY hour ORDER BY hour""", (today,)
).fetchall()
hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly}
# Hourly sales today (0-23)
cur.execute(
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
COUNT(*) as count, COALESCE(SUM(total), 0) as total
FROM sales WHERE DATE(created_at) = %s
GROUP BY hour ORDER BY hour""", (today,)
)
hourly = cur.fetchall()
hourly_map = {row[0]: {'count': row[1], 'total': row[2]} for row in hourly}
return jsonify({
'today': {
'sales_count': today_sales['count'],
'sales_total': today_sales['total'],
},
'month': {
'sales_count': month_sales['count'],
'sales_total': month_sales['total'],
},
'top_products': [
{'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']}
for row in top_products
],
'hourly_sales': [
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
'total': hourly_map.get(h, {}).get('total', 0)}
for h in range(24)
],
}, cls=DecimalEncoder)
return jsonify({
'today': {
'sales_count': today_sales[0],
'sales_total': float(today_sales[1]) if today_sales[1] is not None else 0,
},
'month': {
'sales_count': month_sales[0],
'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0,
},
'top_products': [
{'name': row[0], 'quantity': row[1], 'revenue': float(row[2]) if row[2] is not None else 0}
for row in top_products
],
'hourly_sales': [
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
'total': float(hourly_map.get(h, {}).get('total', 0))}
for h in range(24)
],
})
finally:
cur.close()
conn.close()
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
@require_auth()
def get_employee_stats():
"""Sales per employee today."""
from tenant_db import get_tenant_db
db = get_tenant_db()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
today = datetime.utcnow().date()
rows = db.execute(
"""SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total
FROM sales s
JOIN employees e ON s.employee_id = e.id_employee
WHERE DATE(s.created_at) = %s
GROUP BY e.name
ORDER BY total DESC""", (today,)
).fetchall()
return jsonify({
'employees': [
{'name': row['name'], 'sales': row['sales'], 'total': row['total']}
for row in rows
]
}, cls=DecimalEncoder)
try:
cur.execute(
"""SELECT e.name, COUNT(s.id) as sales, COALESCE(SUM(s.total), 0) as total
FROM sales s
JOIN employees e ON s.employee_id = e.id
WHERE DATE(s.created_at) = %s
GROUP BY e.name
ORDER BY total DESC""", (today,)
)
rows = cur.fetchall()
return jsonify({
'employees': [
{'name': row[0], 'sales': row[1], 'total': float(row[2]) if row[2] is not None else 0}
for row in rows
]
})
finally:
cur.close()
conn.close()

View File

@@ -0,0 +1,128 @@
"""Dropshipping API — public read-only inventory endpoints.
Authentication: X-Dropshipping-Key header (per-tenant).
Optional: X-Tenant-Subdomain for faster resolution.
"""
from flask import Blueprint, request, jsonify, g
from tenant_db import get_tenant_conn, get_master_conn
from services import dropshipping_service as ds_svc
from services.webhook_service import dispatch_webhooks_bulk
dropship_bp = Blueprint("dropship", __name__, url_prefix="/pos/api/dropship")
def _resolve_tenant_by_key(api_key: str, subdomain_hint: str = None):
"""Return (tenant_conn, tenant_id) for a valid dropshipping API key.
If subdomain_hint is provided, validate only that tenant.
Otherwise scan active tenants (acceptable for small tenant count).
"""
master = get_master_conn()
try:
cur = master.cursor()
if subdomain_hint:
cur.execute(
"SELECT id, db_name FROM tenants WHERE subdomain = %s AND is_active = true",
(subdomain_hint,),
)
rows = cur.fetchall()
else:
cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true")
rows = cur.fetchall()
cur.close()
for tid, db_name in rows:
try:
tconn = get_tenant_conn(tid)
if ds_svc.validate_api_key(tconn, api_key):
return tconn, tid
tconn.close()
except Exception:
continue
return None, None
finally:
master.close()
def _require_dropship_auth():
key = request.headers.get("X-Dropshipping-Key")
subdomain = request.headers.get("X-Tenant-Subdomain")
if not key:
return jsonify({"error": "Missing X-Dropshipping-Key header"}), 401
tconn, tid = _resolve_tenant_by_key(key, subdomain_hint=subdomain)
if not tconn:
return jsonify({"error": "Invalid API key or tenant inactive"}), 401
g.tenant_id = tid
g.tenant_conn = tconn
return None
def _release_tenant():
if hasattr(g, "tenant_conn") and g.tenant_conn:
g.tenant_conn.close()
@dropship_bp.route("/inventory", methods=["GET"])
def list_inventory():
err = _require_dropship_auth()
if err:
return err
try:
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 200)
search = request.args.get("q")
result = ds_svc.get_inventory_list(g.tenant_conn, search=search, page=page, per_page=per_page)
return jsonify(result)
finally:
_release_tenant()
@dropship_bp.route("/inventory/<sku>", methods=["GET"])
def get_inventory_item(sku):
err = _require_dropship_auth()
if err:
return err
try:
item = ds_svc.get_inventory_by_sku(g.tenant_conn, sku)
if not item:
return jsonify({"error": "SKU not found"}), 404
return jsonify(item)
finally:
_release_tenant()
@dropship_bp.route("/stock", methods=["GET"])
def get_stock():
err = _require_dropship_auth()
if err:
return err
try:
skus = request.args.get("skus", "")
sku_list = [s.strip() for s in skus.split(",") if s.strip()]
if not sku_list:
return jsonify({"error": "Provide ?skus=SKU1,SKU2,SKU3"}), 400
result = ds_svc.get_stock_by_skus(g.tenant_conn, sku_list)
return jsonify({"stock": result})
finally:
_release_tenant()
@dropship_bp.route("/webhooks/test", methods=["POST"])
def test_webhook():
"""Test endpoint to trigger a sample webhook to all configured targets."""
err = _require_dropship_auth()
if err:
return err
try:
urls = ds_svc.get_webhook_targets(g.tenant_conn, "stock_updated")
if not urls:
return jsonify({"error": "No webhook targets configured"}), 400
results = dispatch_webhooks_bulk(
urls,
"test",
{"message": "Webhook test from Nexus POS", "tenant_id": g.tenant_id},
)
return jsonify({"dispatched": len(results), "results": results})
finally:
_release_tenant()

View File

@@ -4,6 +4,7 @@
import io
import json
import os
import csv
from datetime import datetime, timedelta
from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
@@ -46,6 +47,24 @@ def _apply_tier_discounts(price_1, discounts):
return p2, p3
def _to_decimal(val, default=0):
if val is None or val == '':
return default
try:
return float(str(val).replace(',', ''))
except (ValueError, TypeError):
return default
def _to_int(val, default=0):
if val is None or val == '':
return default
try:
return int(float(str(val).replace(',', '')))
except (ValueError, TypeError):
return default
# ─── AI Classification ───────────────────────────
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
@@ -203,9 +222,10 @@ def get_item(item_id):
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT i.*, b.name as branch_name
SELECT i.*, b.name as branch_name, c.name as category_name
FROM inventory i
LEFT JOIN branches b ON i.branch_id = b.id
LEFT JOIN categories c ON i.category_id = c.id
WHERE i.id = %s
""", (item_id,))
row = cur.fetchone()
@@ -309,6 +329,23 @@ def create_item():
if initial_stock > 0:
record_initial(conn, item_id, branch_id, initial_stock, data.get('cost'))
# Insert SKU aliases if provided
sku_aliases = data.get('sku_aliases', [])
if sku_aliases:
for alias in sku_aliases:
sku = (alias.get('sku') or '').strip()
label = (alias.get('label') or '').strip()
if sku:
cur.execute(
"""
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
VALUES (%s, %s, %s)
ON CONFLICT (inventory_id, sku) DO UPDATE SET
is_active = true, label = EXCLUDED.label
""",
(item_id, sku, label or None),
)
log_action(conn, 'INVENTORY_CREATE', 'inventory', item_id,
new_value={'part_number': data['part_number'], 'name': data['name'], 'initial_stock': initial_stock})
@@ -333,13 +370,19 @@ def create_item():
if not compat_background:
# Fallback: synchronous processing
if compat_source in ('tecdoc', 'both'):
master = None
try:
master = get_master_conn()
auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'],
brand=data.get('brand'), name=data.get('name'))
master.close()
except Exception as am_err:
print(f"[auto_match] Error for item {item_id}: {am_err}")
finally:
if master:
try:
master.close()
except Exception:
pass
if compat_source in ('qwen', 'both'):
try:
@@ -371,6 +414,291 @@ def create_item():
return jsonify({'error': str(e)}), 500
@inventory_bp.route('/items/bulk-import', methods=['POST'])
@require_auth('inventory.edit')
def bulk_import_items():
"""
Bulk import inventory items with optional vehicle compatibility.
Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body.
Headers:
X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows.
X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN;
'skip' ignores missing compat; 'reject' requires all compat.
Expected CSV columns (case-insensitive):
sku/part_number, name, brand, price, stock, cost,
location, description, category, make, model, year, engine, engine_code
Optional compat columns: make, model, year, engine, engine_code
"""
from services.qwen_fitment import get_vehicle_fitment
from services.inventory_vehicle_compat import save_qwen_fitment
import services.inventory_vehicle_compat as ivc_service
mode = request.headers.get('X-Import-Mode', 'strict').lower()
strategy = request.headers.get('X-Import-Strategy', 'qwen').lower()
errors = []
warnings = []
created_ids = []
skipped = 0
created = 0
# ---------- 1. Parse input ----------
rows = []
if request.content_type and 'multipart/form-data' in request.content_type:
file = request.files.get('file')
if not file:
return jsonify({'error': 'No file uploaded'}), 400
try:
ext = os.path.splitext(file.filename)[1].lower()
if ext == '.csv':
stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
reader = csv.DictReader(stream)
rows = list(reader)
elif ext in ('.xls', '.xlsx', '.xlsm'):
try:
import openpyxl
except ImportError:
return jsonify({'error': 'Excel support requires openpyxl. Please convert to CSV or install openpyxl.'}), 400
wb = openpyxl.load_workbook(file.stream, data_only=True)
ws = wb.active
headers = [str(c).strip().lower().replace(' ', '_') if c else '' for c in next(ws.iter_rows(values_only=True))]
for raw in ws.iter_rows(min_row=2, values_only=True):
rows.append({headers[i]: (str(v) if v is not None else '') for i, v in enumerate(raw) if i < len(headers)})
else:
return jsonify({'error': 'Unsupported file type. Use CSV or Excel.'}), 400
except Exception as e:
return jsonify({'error': f'Failed to parse file: {e}'}), 400
else:
body = request.get_json() or {}
rows = body.get('items')
if not rows or not isinstance(rows, list):
return jsonify({'error': 'Expected JSON body with an "items" array'}), 400
if not rows:
return jsonify({'error': 'No data rows found'}), 400
# Normalise column names on first row
if rows:
first = rows[0]
normalised_keys = {}
for k in list(first.keys()):
nk = str(k).strip().lower().replace(' ', '_')
normalised_keys[k] = nk
for r in rows:
for old_k, new_k in normalised_keys.items():
if old_k in r:
r[new_k] = r.pop(old_k)
# Map common synonyms
col_map = {
'sku': 'part_number', 'numero_de_parte': 'part_number', 'parte': 'part_number',
'nombre': 'name', 'producto': 'name', 'descripcion': 'name',
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
'ubicacion': 'location', 'categoria': 'category',
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
'modelo': 'model', 'anio': 'year', 'ano': 'year',
'motor': 'engine', 'codigo_motor': 'engine_code',
}
for r in rows:
for old_k, new_k in col_map.items():
if old_k in r and new_k not in r:
r[new_k] = r.pop(old_k)
required = ['part_number', 'name']
first_keys = set(rows[0].keys()) if rows else set()
missing_required = [c for c in required if c not in first_keys]
if missing_required:
return jsonify({'error': f'Missing required columns: {missing_required}'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
from services.barcode_generator import generate_barcode
from tenant_db import get_master_conn
from services.inventory_engine import record_initial
# Pre-fetch tenant db_name for barcode generation
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (g.tenant_id,))
db_name_row = mcur.fetchone()
db_name = db_name_row[0] if db_name_row else None
mcur.close(); mconn.close()
for row_num, row in enumerate(rows, start=1):
part_number = str(row.get('part_number', '')).strip()
name = str(row.get('name', '')).strip()
if not part_number or not name:
msg = f'Row {row_num}: part_number and name are required'
if mode == 'strict':
conn.rollback(); cur.close(); conn.close()
return jsonify({'error': msg}), 400
warnings.append(msg)
skipped += 1
continue
branch_id = _to_int(row.get('branch_id'), g.branch_id)
if not branch_id:
msg = f'Row {row_num}: branch_id required (not set in row or session)'
if mode == 'strict':
conn.rollback(); cur.close(); conn.close()
return jsonify({'error': msg}), 400
warnings.append(msg)
skipped += 1
continue
brand = str(row.get('brand', '')).strip()
price_1 = _to_decimal(row.get('price'), 0)
stock = _to_int(row.get('stock'), 0)
cost = _to_decimal(row.get('cost'), 0)
location = str(row.get('location', '')).strip()
description = str(row.get('description', '')).strip()
category = str(row.get('category', '')).strip()
# Check if item already exists for this branch
cur.execute("SELECT id FROM inventory WHERE branch_id = %s AND part_number = %s", (branch_id, part_number))
existing = cur.fetchone()
if existing:
item_id = existing[0]
# Update existing item — add stock if provided
cur.execute(
"""
UPDATE inventory SET
name = %s,
brand = COALESCE(NULLIF(%s,''), brand),
cost = CASE WHEN %s > 0 THEN %s ELSE cost END,
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
stock = stock + %s,
location = COALESCE(NULLIF(%s,''), location),
description = COALESCE(NULLIF(%s,''), description),
category = COALESCE(NULLIF(%s,''), category)
WHERE id = %s
""",
(name, brand, cost, cost, price_1, price_1, stock, location, description, category, item_id)
)
was_inserted = False
# Record stock adjustment for existing item if stock > 0
if stock > 0:
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
else:
# Generate barcode for new item
barcode = generate_barcode(conn, db_name)
cur.execute(
"""
INSERT INTO inventory
(branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
)
item_id = cur.fetchone()[0]
was_inserted = True
# Record initial stock if provided and new item
if was_inserted and stock > 0:
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
conn.commit()
created_ids.append(item_id)
created += 1
# ---------- 2. Vehicle compatibility ----------
make = str(row.get('make', '')).strip()
model = str(row.get('model', '')).strip()
year_str = str(row.get('year', '')).strip()
engine = str(row.get('engine', '')).strip()
engine_code = str(row.get('engine_code', '')).strip()
has_compat = any([make, model, year_str, engine, engine_code])
if has_compat:
# Validate / resolve against vehicle tables
year = _to_int(year_str, None)
mye_id = None
if make and model and year:
# Try exact match against model_year_engine
cur.execute(
"""
SELECT mye.id FROM model_year_engine mye
JOIN models m ON m.id = mye.model_id
JOIN brands b ON b.id = m.brand_id
JOIN years y ON y.id = mye.year_id
WHERE LOWER(b.name) = LOWER(%s)
AND LOWER(m.name) = LOWER(%s)
AND y.year = %s
LIMIT 1
""",
(make, model, year)
)
r = cur.fetchone()
if r:
mye_id = r[0]
else:
warnings.append(
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
'saving as text-only compatibility.'
)
if mye_id:
cur.execute(
"""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, make, model, year, engine, engine_code)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT DO NOTHING
""",
(item_id, mye_id, make, model, year_str, engine, engine_code)
)
else:
cur.execute(
"""
INSERT INTO inventory_vehicle_compat
(inventory_id, make, model, year, engine, engine_code)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT DO NOTHING
""",
(item_id, make, model, year_str, engine, engine_code)
)
conn.commit()
else:
# No compatibility provided
if strategy == 'reject':
msg = f'Row {row_num}: missing vehicle compatibility (strategy=reject)'
if mode == 'strict':
conn.rollback(); cur.close(); conn.close()
return jsonify({'error': msg}), 400
warnings.append(msg)
elif strategy == 'qwen':
try:
fitment = get_vehicle_fitment(part_number, name, brand)
save_qwen_fitment(conn, item_id, fitment)
conn.commit()
except Exception as qe:
warnings.append(f'Row {row_num}: QWEN fitment failed: {qe}')
# strategy == 'skip' → do nothing
cur.close()
conn.close()
return jsonify({
'created': created,
'skipped': skipped,
'item_ids': created_ids,
'warnings': warnings,
'errors': errors,
}), 200
except Exception as e:
conn.rollback()
try: cur.close()
except Exception: pass
conn.close()
return jsonify({'error': str(e)}), 500
@inventory_bp.route('/items/<int:item_id>', methods=['PUT'])
@require_auth('inventory.edit')
def update_item(item_id):
@@ -1371,22 +1699,6 @@ def report_branch_comparison():
# ─── Categories and Brands ─────────────────────
@inventory_bp.route('/categories', methods=['GET'])
@require_auth('inventory.view')
def list_categories():
"""Get distinct categories from inventory."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT DISTINCT category_id FROM inventory
WHERE is_active = true AND category_id IS NOT NULL
ORDER BY category_id
""")
categories = [r[0] for r in cur.fetchall()]
cur.close(); conn.close()
return jsonify({'data': categories})
@inventory_bp.route('/brands', methods=['GET'])
@require_auth('inventory.view')
def list_brands():
@@ -1718,6 +2030,89 @@ def auto_match_item_vehicles(item_id):
return jsonify({'error': 'No compatibility source configured'}), 400
# ─── SKU Aliases (multiple part numbers per item) ───────────────────────
@inventory_bp.route('/items/<int:item_id>/skus', methods=['GET'])
@require_auth('inventory.view')
def get_item_sku_aliases(item_id):
"""Return active SKU aliases for an inventory item."""
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"""
SELECT id, sku, label, created_at
FROM inventory_sku_aliases
WHERE inventory_id = %s AND is_active = true
ORDER BY created_at
""",
(item_id,),
)
rows = cur.fetchall()
cur.close()
return jsonify({
'aliases': [
{'id': r[0], 'sku': r[1], 'label': r[2], 'created_at': r[3]}
for r in rows
]
})
finally:
conn.close()
@inventory_bp.route('/items/<int:item_id>/skus', methods=['POST'])
@require_auth('inventory.edit')
def add_item_sku_alias(item_id):
"""Add an SKU alias to an inventory item."""
data = request.get_json() or {}
sku = (data.get('sku') or '').strip()
label = (data.get('label') or '').strip()
if not sku:
return jsonify({'error': 'sku is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
VALUES (%s, %s, %s)
ON CONFLICT (inventory_id, sku) DO UPDATE SET
is_active = true, label = EXCLUDED.label
RETURNING id
""",
(item_id, sku, label or None),
)
row = cur.fetchone()
conn.commit()
cur.close()
return jsonify({'id': row[0], 'message': 'SKU alias added'}), 201
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
conn.close()
@inventory_bp.route('/items/<int:item_id>/skus/<int:alias_id>', methods=['DELETE'])
@require_auth('inventory.edit')
def delete_item_sku_alias(item_id, alias_id):
"""Soft-delete an SKU alias."""
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"""
UPDATE inventory_sku_aliases SET is_active = false WHERE id = %s AND inventory_id = %s
""",
(alias_id, item_id),
)
conn.commit()
cur.close()
return jsonify({'message': 'SKU alias removed'})
finally:
conn.close()
@inventory_bp.route('/mye/search', methods=['GET'])
@require_auth()
def search_mye_endpoint():
@@ -1735,6 +2130,178 @@ def search_mye_endpoint():
master.close()
# ─── Manual Vehicle Compatibility (text-based) ────────────────────────────
@inventory_bp.route('/items/<int:item_id>/vehicles/manual', methods=['POST'])
@require_auth('inventory.edit')
def add_manual_vehicle_compat(item_id):
"""Add a manual vehicle compatibility using free-text fields."""
data = request.get_json() or {}
make = (data.get('make') or '').strip()
model = (data.get('model') or '').strip()
year = data.get('year')
engine = (data.get('engine') or '').strip()
engine_code = (data.get('engine_code') or '').strip()
if not make or not model or not year:
return jsonify({'error': 'make, model and year are required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO inventory_vehicle_compat
(inventory_id, make, model, year, engine, engine_code, source, model_year_engine_id)
VALUES (%s, %s, %s, %s, %s, %s, 'manual', NULL)
ON CONFLICT DO NOTHING
RETURNING id
""",
(item_id, make, model, year, engine or None, engine_code or None),
)
row = cur.fetchone()
conn.commit()
cur.close()
if not row:
return jsonify({'error': 'Compatibility already exists or item not found'}), 409
return jsonify({'id': row[0], 'message': 'Compatibility added'}), 201
finally:
conn.close()
@inventory_bp.route('/vehicles/makes', methods=['GET'])
@require_auth()
def get_vehicle_makes():
"""Return distinct vehicle makes from master DB."""
master = get_master_conn()
try:
cur = master.cursor()
cur.execute("SELECT id_brand, name_brand FROM brands ORDER BY name_brand")
rows = cur.fetchall()
cur.close()
return jsonify({'makes': [{'id': r[0], 'name': r[1]} for r in rows]})
finally:
master.close()
@inventory_bp.route('/vehicles/models', methods=['GET'])
@require_auth()
def get_vehicle_models():
"""Return models for a given brand."""
brand_id = request.args.get('brand_id', type=int)
if not brand_id:
return jsonify({'error': 'brand_id required'}), 400
master = get_master_conn()
try:
cur = master.cursor()
cur.execute(
"SELECT id_model, name_model FROM models WHERE brand_id = %s ORDER BY name_model",
(brand_id,),
)
rows = cur.fetchall()
cur.close()
return jsonify({'models': [{'id': r[0], 'name': r[1]} for r in rows]})
finally:
master.close()
@inventory_bp.route('/vehicles/years', methods=['GET'])
@require_auth()
def get_vehicle_years():
"""Return distinct years available for a model."""
model_id = request.args.get('model_id', type=int)
if not model_id:
return jsonify({'error': 'model_id required'}), 400
master = get_master_conn()
try:
cur = master.cursor()
cur.execute(
"""
SELECT DISTINCT y.id_year, y.year_car
FROM model_year_engine mye
JOIN years y ON y.id_year = mye.year_id
WHERE mye.model_id = %s
ORDER BY y.year_car DESC
""",
(model_id,),
)
rows = cur.fetchall()
cur.close()
return jsonify({'years': [{'id': r[0], 'year': r[1]} for r in rows]})
finally:
master.close()
@inventory_bp.route('/vehicles/engines', methods=['GET'])
@require_auth()
def get_vehicle_engines():
"""Return engines available for a model+year."""
model_id = request.args.get('model_id', type=int)
year_id = request.args.get('year_id', type=int)
if not model_id or not year_id:
return jsonify({'error': 'model_id and year_id required'}), 400
master = get_master_conn()
try:
cur = master.cursor()
cur.execute(
"""
SELECT DISTINCT e.id_engine, e.name_engine, e.engine_code
FROM model_year_engine mye
JOIN engines e ON e.id_engine = mye.engine_id
WHERE mye.model_id = %s AND mye.year_id = %s
ORDER BY e.name_engine
""",
(model_id, year_id),
)
rows = cur.fetchall()
cur.close()
return jsonify({'engines': [{'id': r[0], 'name': r[1], 'code': r[2]} for r in rows]})
finally:
master.close()
# ─── Categories ──────────────────────────────────
@inventory_bp.route('/categories', methods=['GET'])
@require_auth()
def list_inventory_categories():
"""Return active categories (root only). Optional ?parent_id= for subcategories."""
parent_id = request.args.get('parent_id')
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
if parent_id:
cur.execute(
"SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name",
(parent_id,)
)
else:
cur.execute(
"SELECT id, name FROM categories WHERE parent_id IS NULL AND is_active = true ORDER BY name"
)
rows = cur.fetchall()
return jsonify({'categories': [{'id': r[0], 'name': r[1]} for r in rows]})
finally:
conn.close()
@inventory_bp.route('/categories/<int:category_id>/subcategories', methods=['GET'])
@require_auth()
def list_inventory_subcategories(category_id):
"""Return subcategories for a given category."""
conn = get_tenant_conn(g.tenant_id)
try:
cur = conn.cursor()
cur.execute(
"SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name",
(category_id,)
)
rows = cur.fetchall()
return jsonify({'subcategories': [{'id': r[0], 'name': r[1]} for r in rows]})
finally:
conn.close()
# ─── Global Tier Discounts ───────────────────────
@inventory_bp.route('/tier-discounts', methods=['GET'])

View File

@@ -28,6 +28,23 @@ from flask import Blueprint, request, jsonify, g
from middleware import require_auth, has_permission
from tenant_db import get_tenant_conn, get_master_conn
from services import marketplace_external_service as meli_svc
def _get_public_base_url() -> str:
"""Build the tenant's public base URL from request headers (handles reverse proxy)."""
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
host = request.headers.get("X-Forwarded-Host", request.host)
# Cloudflare specific header
cf_visitor = request.headers.get("CF-Visitor")
if cf_visitor and '"scheme":"https"' in cf_visitor:
proto = "https"
# Force https for production domain if we detect http behind a TLS terminator
if proto == "http" and ("nexusautoparts.com.mx" in host or request.headers.get("X-Forwarded-Ssl") == "on"):
proto = "https"
return f"{proto}://{host}/"
from services.meli_service import MeliService, MeliAuthError
marketplace_ext_bp = Blueprint(
@@ -148,6 +165,10 @@ def search_categories():
return jsonify({"error": "MercadoLibre not connected"}), 400
result = svc.search_categories(site_id, q)
return jsonify({"categories": result})
except MeliAuthError:
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@@ -167,6 +188,10 @@ def list_listings():
try:
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
return jsonify(result)
except MeliAuthError:
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@@ -199,6 +224,7 @@ def create_listings():
listing_type_id=listing_type,
shipping_mode=shipping_mode,
custom_data=custom_data,
base_url=_get_public_base_url(),
)
return jsonify(result), 201
except ValueError as e:
@@ -220,7 +246,7 @@ def inventory_check():
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.check_inventory_ml_status(conn, inventory_ids)
result = meli_svc.check_inventory_ml_status(conn, inventory_ids, base_url=_get_public_base_url())
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
@@ -242,6 +268,8 @@ def category_attributes(category_id):
# Filter to required attributes only for the UI
required = [a for a in attrs if a.get("tags", {}).get("required")]
return jsonify({"attributes": required, "all": attrs})
except MeliAuthError:
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
@@ -277,6 +305,7 @@ def validate_listings():
listing_type_id=listing_type,
shipping_mode=shipping_mode,
custom_data=custom_data,
base_url=_get_public_base_url(),
)
return jsonify(result)
except ValueError as e:
@@ -316,6 +345,7 @@ def create_listings_async():
listing_type=listing_type,
shipping_mode=shipping_mode,
custom_data=custom_data,
base_url=_get_public_base_url(),
)
return jsonify({"task_id": task.id, "status": "queued"}), 202
except Exception as e:
@@ -413,6 +443,85 @@ def delete_listing(listing_id):
conn.close()
@marketplace_ext_bp.route("/listings/<int:listing_id>/permanent", methods=["DELETE"])
@require_auth()
def delete_listing_permanent(listing_id):
"""Hard-delete a closed listing from the local DB."""
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.delete_listing_permanently(conn, listing_id)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# QUESTIONS & ANSWERS
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/questions", methods=["GET"])
@require_auth()
def list_questions():
"""List questions from local DB. Query param: ?status=unanswered"""
status = request.args.get("status")
conn = get_tenant_conn(g.tenant_id)
try:
items = meli_svc.list_local_questions(conn, status=status)
return jsonify({"items": items})
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/questions/sync", methods=["POST"])
@require_auth()
def sync_questions():
"""Force sync questions from ML for all active listings."""
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.sync_questions(conn)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/questions/<int:question_id>/answer", methods=["POST"])
@require_auth()
def answer_question(question_id):
"""Answer a buyer question via ML API."""
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
text = data.get("text", "").strip()
if not text:
return jsonify({"error": "Answer text is required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.answer_question(conn, question_id, text)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# ORDERS
# ═══════════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,278 @@
"""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
"""
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/<int:item_id>', 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/<int:item_id>', 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})

View File

@@ -12,6 +12,7 @@ supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api
from middleware import require_auth
from tenant_db import get_tenant_conn
class DecimalEncoder(json.JSONEncoder):
@@ -26,48 +27,47 @@ class DecimalEncoder(json.JSONEncoder):
def get_demand():
"""Aggregated demand by zone, part group, and time range."""
days = request.args.get('days', 30, type=int)
group_id = request.args.get('group_id', type=int)
branch_id = request.args.get('branch_id', type=int)
from tenant_db import get_tenant_db
db = get_tenant_db()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
since = datetime.utcnow() - timedelta(days=days)
params = [since]
filters = "s.created_at >= %s"
if group_id:
filters += " AND p.group_id = %s"
params.append(group_id)
if branch_id:
filters += " AND s.branch_id = %s"
params.append(branch_id)
try:
params = [since]
filters = "s.created_at >= %s"
if branch_id:
filters += " AND s.branch_id = %s"
params.append(branch_id)
rows = db.execute(
f"""SELECT g.name as group_name, b.name as branch_name,
COUNT(DISTINCT s.id_sale) as orders,
SUM(si.quantity) as qty_requested,
COALESCE(SUM(si.total), 0) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id_sale
JOIN parts p ON si.part_id = p.id_part
JOIN part_groups g ON p.group_id = g.id_group
LEFT JOIN branches b ON s.branch_id = b.id_branch
WHERE {filters}
GROUP BY g.name, b.name
ORDER BY revenue DESC
LIMIT 100""", tuple(params)
).fetchall()
cur.execute(
f"""SELECT b.name as branch_name,
COUNT(DISTINCT s.id) as orders,
SUM(si.quantity) as qty_requested,
COALESCE(SUM(si.subtotal), 0) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id
LEFT JOIN branches b ON s.branch_id = b.id
WHERE {filters}
GROUP BY b.name
ORDER BY revenue DESC
LIMIT 100""", tuple(params)
)
rows = cur.fetchall()
return jsonify({
'since': since.isoformat(),
'days': days,
'demand': [
{'group': row['group_name'], 'branch': row['branch_name'],
'orders': row['orders'], 'quantity': row['qty_requested'],
'revenue': row['revenue']}
for row in rows
]
}, cls=DecimalEncoder)
return jsonify({
'since': since.isoformat(),
'days': days,
'demand': [
{'branch': row[0] or 'Sin sucursal',
'orders': row[1], 'quantity': row[2],
'revenue': float(row[3]) if row[3] is not None else 0}
for row in rows
]
})
finally:
cur.close()
conn.close()
@supplier_portal_bp.route('/top-parts', methods=['GET'])
@@ -75,31 +75,31 @@ def get_demand():
def get_top_parts():
"""Top moving parts for suppliers to restock."""
days = request.args.get('days', 30, type=int)
from tenant_db import get_tenant_db
db = get_tenant_db()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
since = datetime.utcnow() - timedelta(days=days)
rows = db.execute(
"""SELECT p.oem_part_number, p.name, g.name as group_name,
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue,
COALESCE(SUM(wi.stock_quantity), 0) as current_stock
FROM sale_items si
JOIN sales s ON si.sale_id = s.id_sale
JOIN parts p ON si.part_id = p.id_part
JOIN part_groups g ON p.group_id = g.id_group
LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id
WHERE s.created_at >= %s
GROUP BY p.oem_part_number, p.name, g.name
ORDER BY sold DESC
LIMIT 50""", (since,)
).fetchall()
try:
cur.execute(
"""SELECT si.part_number, si.name,
SUM(si.quantity) as sold, COALESCE(SUM(si.subtotal), 0) as revenue
FROM sale_items si
JOIN sales s ON si.sale_id = s.id
WHERE s.created_at >= %s
GROUP BY si.part_number, si.name
ORDER BY sold DESC
LIMIT 50""", (since,)
)
rows = cur.fetchall()
return jsonify({
'since': since.isoformat(),
'parts': [
{'oem': row['oem_part_number'], 'name': row['name'],
'group': row['group_name'], 'sold': row['sold'],
'revenue': row['revenue'], 'stock': row['current_stock']}
for row in rows
]
}, cls=DecimalEncoder)
return jsonify({
'since': since.isoformat(),
'parts': [
{'part_number': row[0], 'name': row[1],
'sold': row[2], 'revenue': float(row[3]) if row[3] is not None else 0}
for row in rows
]
})
finally:
cur.close()
conn.close()