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:
@@ -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
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
128
pos/blueprints/dropshipping_bp.py
Normal file
128
pos/blueprints/dropshipping_bp.py
Normal 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()
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
278
pos/blueprints/supplier_catalog_bp.py
Normal file
278
pos/blueprints/supplier_catalog_bp.py
Normal 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})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user