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

@@ -62,6 +62,9 @@ def create_app():
from blueprints.marketplace_external_bp import marketplace_ext_bp
app.register_blueprint(marketplace_ext_bp)
from blueprints.dropshipping_bp import dropship_bp
app.register_blueprint(dropship_bp)
from blueprints.peer_bp import peer_bp
app.register_blueprint(peer_bp)
@@ -110,6 +113,9 @@ def create_app():
from blueprints.supplier_portal_bp import supplier_portal_bp
app.register_blueprint(supplier_portal_bp)
from blueprints.supplier_catalog_bp import supplier_catalog_bp
app.register_blueprint(supplier_catalog_bp)
from blueprints.internal_bp import internal_bp
app.register_blueprint(internal_bp)
@@ -131,6 +137,10 @@ def create_app():
tenant_name=getattr(g, 'tenant_name', None),
tenant_subdomain=getattr(g, 'tenant_subdomain', None))
@app.route('/pos/supplier-catalog')
def supplier_catalog_page():
return render_template('supplier_catalog.html')
@app.route('/pos/catalog')
def pos_catalog():
return render_template('catalog.html')

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()

View File

@@ -33,6 +33,7 @@ MIGRATIONS = {
'v3.0': 'v3.0_public_api.sql',
'v3.1': 'v3.1_inventory_vehicle_compat.sql',
'v3.2': 'v3.2_db_performance.sql',
'v3.8': 'v3.8_supplier_catalog.sql',
}

View File

@@ -386,3 +386,4 @@ CREATE TABLE IF NOT EXISTS tenant_config (
-- Barcode sequence
CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;

View File

@@ -0,0 +1,30 @@
-- ============================================================
-- v3.5 MercadoLibre Questions & Answers
-- ============================================================
-- Adds table for tracking buyer questions on ML listings.
-- All tables live in the tenant DB.
-- ============================================================
CREATE TABLE IF NOT EXISTS marketplace_questions (
id SERIAL PRIMARY KEY,
listing_id INTEGER REFERENCES marketplace_listings(id) ON DELETE SET NULL,
external_question_id VARCHAR(50) NOT NULL UNIQUE,
external_item_id VARCHAR(50) NOT NULL,
question_text TEXT NOT NULL,
answer_text TEXT,
status VARCHAR(20) DEFAULT 'unanswered', -- unanswered, answered, closed
buyer_id VARCHAR(50),
buyer_nickname VARCHAR(100),
question_date TIMESTAMPTZ,
answer_date TIMESTAMPTZ,
raw_json JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_status
ON marketplace_questions(status);
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_listing
ON marketplace_questions(listing_id);
CREATE INDEX IF NOT EXISTS idx_marketplace_questions_external
ON marketplace_questions(external_question_id);

View File

@@ -0,0 +1,18 @@
-- ============================================================
-- v3.6 Dropshipping API Integration
-- ============================================================
-- Adds config keys and webhook targets for external
-- dropshipping platforms.
-- ============================================================
-- Webhook targets for dropshipping notifications per tenant
CREATE TABLE IF NOT EXISTS dropshipping_webhooks (
id SERIAL PRIMARY KEY,
event_type VARCHAR(30) NOT NULL, -- stock_updated, price_updated, sale_made
target_url TEXT NOT NULL,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dropshipping_webhooks_event
ON dropshipping_webhooks(event_type) WHERE is_active = true;

View File

@@ -0,0 +1,22 @@
-- ============================================================
-- v3.7 SKU Aliases (multiple SKUs per inventory item)
-- ============================================================
-- Allows registering 2-3 alternative part numbers/SKUs for the
-- same product (e.g. different supplier SKUs).
-- ============================================================
CREATE TABLE IF NOT EXISTS inventory_sku_aliases (
id SERIAL PRIMARY KEY,
inventory_id INTEGER NOT NULL REFERENCES inventory(id) ON DELETE CASCADE,
sku VARCHAR(100) NOT NULL,
label VARCHAR(50), -- e.g. "Bodega A", "Proveedor X"
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT inventory_sku_aliases_unique_sku
UNIQUE (inventory_id, sku)
);
CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_inventory
ON inventory_sku_aliases(inventory_id) WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_inventory_sku_aliases_sku
ON inventory_sku_aliases(sku) WHERE is_active = true;

View File

@@ -0,0 +1,63 @@
-- v3.8 — Supplier Catalog tables
-- Adds supplier_catalog, supplier_catalog_compat, and supplier_catalog_interchange
-- to support multi-supplier parts injection into the vehicle catalog.
CREATE TABLE IF NOT EXISTS supplier_catalog (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
supplier_name VARCHAR(255) NOT NULL,
sku VARCHAR(255) NOT NULL,
name VARCHAR(500) NOT NULL,
category VARCHAR(255),
description TEXT,
image_url TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_tenant_id_supplier_name_sku_category_key
ON supplier_catalog (tenant_id, supplier_name, sku, category);
CREATE INDEX IF NOT EXISTS idx_sc_supplier
ON supplier_catalog (tenant_id, supplier_name, is_active);
CREATE INDEX IF NOT EXISTS idx_sc_sku
ON supplier_catalog (tenant_id, sku, category);
CREATE TABLE IF NOT EXISTS supplier_catalog_compat (
id SERIAL PRIMARY KEY,
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
make VARCHAR(255),
model VARCHAR(255),
year INTEGER,
engine VARCHAR(255),
engine_code VARCHAR(255),
model_year_engine_id INTEGER,
source VARCHAR(50) DEFAULT 'import',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS supplier_catalog_compat_catalog_id_make_model_year_engine_key
ON supplier_catalog_compat (catalog_id, make, model, year, engine);
CREATE INDEX IF NOT EXISTS idx_scc_catalog
ON supplier_catalog_compat (catalog_id);
CREATE INDEX IF NOT EXISTS idx_scc_vehicle
ON supplier_catalog_compat (make, model, year);
CREATE INDEX IF NOT EXISTS idx_scc_mye
ON supplier_catalog_compat (model_year_engine_id);
CREATE TABLE IF NOT EXISTS supplier_catalog_interchange (
id SERIAL PRIMARY KEY,
catalog_id INTEGER NOT NULL REFERENCES supplier_catalog(id) ON DELETE CASCADE,
brand VARCHAR(255),
part_number VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sci_catalog
ON supplier_catalog_interchange (catalog_id);

View File

@@ -0,0 +1,157 @@
"""Bulk catalog import service.
Imports products into inventory with optional vehicle compatibilities
and SKU aliases. Can auto-generate vehicle fitment via QWEN AI if
compatibilities are not provided.
"""
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def import_products(
tenant_conn,
products: list[dict],
branch_id: int,
auto_generate_compat: bool = False,
employee_id: Optional[int] = None,
):
"""Import a list of products into inventory.
Each product dict may contain:
- sku (str) *required
- name (str) *required
- brand (str)
- description (str)
- cost (float)
- price (float)
- stock (int)
- location (str)
- sku_aliases (list[dict]) [{"sku": str, "label": str}]
- vehicles (list[dict]) [{"make", "model", "year", "engine", "engine_code"}]
Returns {"imported": N, "failed": [{"sku": ..., "error": ...}], "compat_generated": M}
"""
cur = tenant_conn.cursor()
imported = 0
failed = []
compat_generated = 0
for idx, p in enumerate(products):
sku = (p.get("sku") or "").strip()
name = (p.get("name") or "").strip()
if not sku or not name:
failed.append({"index": idx, "sku": sku, "error": "sku and name are required"})
continue
brand = (p.get("brand") or "").strip() or None
description = (p.get("description") or "").strip() or None
cost = float(p.get("cost") or 0)
price = float(p.get("price") or 0)
stock = int(p.get("stock") or 0)
location = (p.get("location") or "").strip() or None
barcode = (p.get("barcode") or "").strip() or None
try:
# Check for duplicate SKU in same branch
cur.execute(
"SELECT id FROM inventory WHERE part_number = %s AND branch_id = %s AND is_active = true",
(sku, branch_id),
)
if cur.fetchone():
# Update existing item instead of creating new
cur.execute(
"""
UPDATE inventory
SET name = %s, brand = %s, description = %s, cost = %s, price_1 = %s,
location = %s, barcode = COALESCE(%s, barcode), updated_at = NOW()
WHERE part_number = %s AND branch_id = %s AND is_active = true
RETURNING id
""",
(name, brand, description, cost, price, location, barcode, sku, branch_id),
)
row = cur.fetchone()
item_id = row[0]
else:
# Insert new item
cur.execute(
"""
INSERT INTO inventory
(branch_id, part_number, barcode, name, description, brand,
unit, cost, price_1, price_2, price_3, tax_rate,
min_stock, max_stock, location, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true)
RETURNING id
""",
(
branch_id, sku, barcode, name, description, brand,
"PZA", cost, price, price, price, 0.16,
0, 0, location,
),
)
row = cur.fetchone()
item_id = row[0]
# Record initial stock if provided
if stock > 0:
from services.inventory_engine import record_initial
record_initial(tenant_conn, item_id, branch_id, stock, cost)
# Insert SKU aliases
aliases = p.get("sku_aliases") or []
for alias in aliases:
alias_sku = (alias.get("sku") or "").strip()
label = (alias.get("label") or "").strip() or None
if alias_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, alias_sku, label),
)
# Insert manual vehicle compatibilities
vehicles = p.get("vehicles") or []
for v in vehicles:
make = (v.get("make") or "").strip()
model = (v.get("model") or "").strip()
year = v.get("year")
engine = (v.get("engine") or "").strip() or None
engine_code = (v.get("engine_code") or "").strip() or None
if make and model and year:
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
""",
(item_id, make, model, year, engine, engine_code),
)
tenant_conn.commit()
imported += 1
# Auto-generate compat via QWEN if requested and no vehicles provided
if auto_generate_compat and not vehicles:
try:
from services.qwen_fitment import get_vehicle_fitment
from services.inventory_vehicle_compat import save_qwen_fitment
fitment = get_vehicle_fitment(sku, name, brand or "")
inserted = save_qwen_fitment(tenant_conn, item_id, fitment)
compat_generated += inserted
except Exception as qe:
logger.warning("QWEN auto-match failed for %s: %s", sku, qe)
except Exception as e:
tenant_conn.rollback()
logger.warning("Import failed for sku=%s: %s", sku, e)
failed.append({"index": idx, "sku": sku, "error": str(e)})
cur.close()
return {"imported": imported, "failed": failed, "compat_generated": compat_generated}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,168 @@
"""Dropshipping integration service.
Provides read-only inventory access for external dropshipping platforms
and webhook dispatching on stock/price/sale events.
"""
import logging
from services.inventory_engine import get_stock_bulk
logger = logging.getLogger(__name__)
def resolve_tenant_by_api_key(master_conn, api_key: str):
"""Find tenant_id and db_name for a given dropshipping API key.
Returns (tenant_id, db_name) or (None, None) if invalid.
"""
if not api_key:
return None, None
cur = master_conn.cursor()
# tenant_config lives in each tenant DB, so we need to scan tenants
cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true")
tenants = cur.fetchall()
for tid, db_name in tenants:
try:
tcur = master_conn.cursor()
# Use dblink or connect to tenant DB? Simpler: the blueprint
# will pass tenant_conn directly after resolution.
# Instead, we store a mapping in master DB for speed.
# For now, return all candidates and let caller validate.
pass
except Exception:
continue
cur.close()
return None, None
def _get_dropshipping_key(tenant_conn):
cur = tenant_conn.cursor()
cur.execute("SELECT value FROM tenant_config WHERE key = 'dropshipping_api_key'")
row = cur.fetchone()
cur.close()
return row[0] if row else None
def validate_api_key(tenant_conn, api_key: str) -> bool:
"""Check if the provided API key matches the tenant's configured key."""
if not api_key:
return False
expected = _get_dropshipping_key(tenant_conn)
return expected is not None and expected == api_key
def get_inventory_list(tenant_conn, search: str = None, page: int = 1, per_page: int = 50):
"""Return inventory items with stock and price for dropshipping."""
offset = (max(page, 1) - 1) * per_page
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
cur = tenant_conn.cursor()
params = []
where = "WHERE is_active = true"
if search:
where += " AND (name ILIKE %s OR part_number ILIKE %s)"
params.extend([f"%{search}%", f"%{search}%"])
cur.execute(
f"""
SELECT id, part_number, name, brand, price_1, price_2, price_3,
image_url, unit, description
FROM inventory
{where}
ORDER BY id DESC
LIMIT %s OFFSET %s
""",
params + [per_page, offset],
)
rows = cur.fetchall()
# Count total
cur.execute(f"SELECT COUNT(*) FROM inventory {where}", params)
total = cur.fetchone()[0]
cur.close()
items = []
for r in rows:
inv_id = r[0]
items.append({
"id": inv_id,
"sku": r[1],
"name": r[2],
"brand": r[3],
"price_1": float(r[4]) if r[4] else None,
"price_2": float(r[5]) if r[5] else None,
"price_3": float(r[6]) if r[6] else None,
"stock": stock_map.get(inv_id, 0),
"image_url": r[7],
"unit": r[8],
"description": r[9],
})
return {"items": items, "page": page, "per_page": per_page, "total": total}
def get_inventory_by_sku(tenant_conn, sku: str):
"""Return a single inventory item by SKU/part_number."""
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, part_number, name, brand, price_1, price_2, price_3,
image_url, unit, description
FROM inventory
WHERE part_number = %s AND is_active = true
LIMIT 1
""",
(sku,),
)
row = cur.fetchone()
cur.close()
if not row:
return None
inv_id = row[0]
return {
"id": inv_id,
"sku": row[1],
"name": row[2],
"brand": row[3],
"price_1": float(row[4]) if row[4] else None,
"price_2": float(row[5]) if row[5] else None,
"price_3": float(row[6]) if row[6] else None,
"stock": stock_map.get(inv_id, 0),
"image_url": row[7],
"unit": row[8],
"description": row[9],
}
def get_stock_by_skus(tenant_conn, skus: list[str]) -> dict:
"""Return stock levels for a list of SKUs."""
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, part_number FROM inventory
WHERE part_number = ANY(%s) AND is_active = true
""",
(skus,),
)
rows = cur.fetchall()
cur.close()
result = {}
for inv_id, sku in rows:
result[sku] = stock_map.get(inv_id, 0)
return result
def get_webhook_targets(tenant_conn, event_type: str) -> list[str]:
"""Return active webhook URLs for a given event type."""
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT target_url FROM dropshipping_webhooks
WHERE event_type = %s AND is_active = true
""",
(event_type,),
)
urls = [r[0] for r in cur.fetchall()]
cur.close()
return urls

View File

@@ -9,6 +9,7 @@ Depends on:
import json
import logging
import urllib.parse
from typing import Optional
from decimal import Decimal
@@ -19,6 +20,19 @@ from services.inventory_engine import get_stock, get_stock_bulk
logger = logging.getLogger(__name__)
def _resolve_image_urls(images: list[str], base_url: str | None = None) -> list[str]:
"""Convert relative image paths to absolute URLs."""
resolved = []
for url in images:
if not url:
continue
if base_url and url.startswith("/"):
resolved.append(urllib.parse.urljoin(base_url.rstrip("/") + "/", url.lstrip("/")))
else:
resolved.append(url)
return resolved
# ═══════════════════════════════════════════════════════════════════════════
# CONFIG HELPERS
# ═══════════════════════════════════════════════════════════════════════════
@@ -145,6 +159,7 @@ def build_item_payload(
custom_title: str = None,
extra_attributes: list = None,
shipping_cost: float = None,
base_url: str = None,
) -> dict:
"""Convert a Nexus inventory row into a MercadoLibre item payload."""
title = custom_title or f"{inventory_row['name']} {inventory_row['brand'] or ''} {inventory_row['part_number'] or ''}".strip()
@@ -167,17 +182,20 @@ def build_item_payload(
"buying_mode": "buy_it_now",
"listing_type_id": listing_type_id,
"condition": "new",
"pictures": [{"source": url} for url in images if url],
"pictures": [{"source": url} for url in _resolve_image_urls(images, base_url) if url],
"shipping": shipping_payload,
"attributes": [],
}
if inventory_row.get("brand"):
# Collect extra attribute IDs to avoid duplicates
extra_attr_ids = {a.get("id") for a in (extra_attributes or []) if a.get("id")}
if inventory_row.get("brand") and "BRAND" not in extra_attr_ids:
payload["attributes"].append(
{"id": "BRAND", "value_name": inventory_row["brand"]}
)
if inventory_row.get("part_number"):
if inventory_row.get("part_number") and "PART_NUMBER" not in extra_attr_ids:
payload["attributes"].append(
{"id": "PART_NUMBER", "value_name": inventory_row["part_number"]}
)
@@ -193,11 +211,11 @@ def build_item_payload(
if isinstance(vehicle_compat, list) and vehicle_compat:
first = vehicle_compat[0]
if isinstance(first, dict):
if first.get("brand"):
if first.get("brand") and "VEHICLE_MODEL" not in extra_attr_ids:
payload["attributes"].append(
{"id": "VEHICLE_MODEL", "value_name": first["brand"]}
)
if first.get("model"):
if first.get("model") and "VEHICLE_MODEL_NAME" not in extra_attr_ids:
payload["attributes"].append(
{"id": "VEHICLE_MODEL_NAME", "value_name": first["model"]}
)
@@ -243,7 +261,7 @@ def check_meli_shipping_config(svc: MeliService, cfg: dict) -> dict:
# LISTINGS CRUD
# ═══════════════════════════════════════════════════════════════════════════
def check_inventory_ml_status(tenant_conn, inventory_ids: list[int]) -> dict:
def check_inventory_ml_status(tenant_conn, inventory_ids: list[int], base_url: str = None) -> dict:
"""Check local pre-flight status for ML publishing.
Returns per-item dict with checks: has_image, has_stock, has_price,
@@ -298,7 +316,7 @@ def check_inventory_ml_status(tenant_conn, inventory_ids: list[int]) -> dict:
"has_price": price > 0,
"price": price,
"stock": stock,
"image_url": image_url,
"image_url": _resolve_image_urls([image_url], base_url)[0] if image_url else None,
"already_published": existing is not None,
"existing_listing": existing,
})
@@ -312,6 +330,7 @@ def validate_items(
listing_type_id: str = "gold_special",
shipping_mode: str = "me2",
custom_data: dict = None,
base_url: str = None,
) -> dict:
"""Validate items against ML /items/validate without creating them.
@@ -370,6 +389,7 @@ def validate_items(
images = []
if inv.get("image_url"):
images.append(inv["image_url"])
images = _resolve_image_urls(images, base_url)
if not images:
results["invalid"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen"})
continue
@@ -385,6 +405,7 @@ def validate_items(
shipping_mode=shipping_mode, listing_type_id=listing_type_id,
custom_title=title, extra_attributes=extra_attrs,
shipping_cost=shipping_cost,
base_url=base_url,
)
try:
@@ -412,6 +433,7 @@ def publish_items(
listing_type_id: str = "gold_special",
shipping_mode: str = "me2",
custom_data: dict = None,
base_url: str = None,
) -> dict:
"""Publish one or more inventory items to MercadoLibre.
@@ -476,6 +498,7 @@ def publish_items(
images = []
if inv.get("image_url"):
images.append(inv["image_url"])
images = _resolve_image_urls(images, base_url)
if not images:
results["failed"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen. ML requiere imagen para publicar."})
@@ -492,6 +515,7 @@ def publish_items(
shipping_mode=shipping_mode, listing_type_id=listing_type_id,
custom_title=title, extra_attributes=extra_attrs,
shipping_cost=shipping_cost,
base_url=base_url,
)
try:
@@ -740,6 +764,239 @@ def close_listing(tenant_conn, listing_id: int) -> dict:
return {"ok": True, "status": "closed"}
def delete_listing_permanently(tenant_conn, listing_id: int) -> dict:
"""Hard-delete a closed listing from the local DB.
Sets listing_id = NULL on marketplace_order_items to avoid FK errors,
then deletes the marketplace_listings row.
"""
cur = tenant_conn.cursor()
cur.execute(
"SELECT id, external_status FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
# Clear FK references so we can delete safely
cur.execute(
"UPDATE marketplace_order_items SET listing_id = NULL WHERE listing_id = %s",
(listing_id,),
)
cur.execute(
"DELETE FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "deleted": True}
# ═══════════════════════════════════════════════════════════════════════════
# QUESTIONS & ANSWERS
# ═══════════════════════════════════════════════════════════════════════════
def _upsert_question(cur, q_data: dict, listing_id_map: dict):
"""Upsert a single question from ML API into marketplace_questions."""
external_qid = str(q_data.get("id"))
external_item_id = str(q_data.get("item_id"))
text = q_data.get("text", "")
status = q_data.get("status", "unanswered")
answer = q_data.get("answer", {})
answer_text = answer.get("text") if answer else None
answer_date = None
if answer and answer.get("date_created"):
answer_date = answer["date_created"]
from_user = q_data.get("from", {})
buyer_id = str(from_user.get("id")) if from_user else None
buyer_nickname = from_user.get("nickname") if from_user else None
question_date = q_data.get("date_created")
listing_id = listing_id_map.get(external_item_id)
cur.execute(
"""
INSERT INTO marketplace_questions
(listing_id, external_question_id, external_item_id, question_text,
answer_text, status, buyer_id, buyer_nickname, question_date,
answer_date, raw_json, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (external_question_id)
DO UPDATE SET
question_text = EXCLUDED.question_text,
answer_text = EXCLUDED.answer_text,
status = EXCLUDED.status,
buyer_id = EXCLUDED.buyer_id,
buyer_nickname = EXCLUDED.buyer_nickname,
question_date = EXCLUDED.question_date,
answer_date = EXCLUDED.answer_date,
raw_json = EXCLUDED.raw_json,
updated_at = NOW()
""",
(
listing_id,
external_qid,
external_item_id,
text,
answer_text,
status,
buyer_id,
buyer_nickname,
question_date,
answer_date,
json.dumps(q_data),
),
)
def sync_questions(tenant_conn) -> dict:
"""Fetch questions from ML for all active listings and upsert locally.
Returns {"synced": N, "items": [...]}.
"""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, external_item_id
FROM marketplace_listings
WHERE channel = 'mercadolibre' AND is_active = true
"""
)
listings = {r[1]: r[0] for r in cur.fetchall()}
cur.close()
if not listings:
return {"synced": 0, "items": []}
total_synced = 0
for item_id in listings:
try:
resp = svc.get_questions(item_id, limit=50)
questions = resp.get("questions", [])
if not questions:
continue
cur = tenant_conn.cursor()
for q in questions:
_upsert_question(cur, q, listings)
tenant_conn.commit()
cur.close()
total_synced += len(questions)
except Exception as e:
logger.warning("Failed to sync questions for item %s: %s", item_id, e)
return {"synced": total_synced}
def fetch_question_from_ml(tenant_conn, external_question_id: str) -> dict:
"""Fetch a single question from ML API and upsert locally."""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg)
if not svc:
raise ValueError("MercadoLibre not configured")
q_data = svc.get_question(external_question_id)
cur = tenant_conn.cursor()
cur.execute(
"SELECT id, external_item_id FROM marketplace_listings WHERE external_item_id = %s",
(str(q_data.get("item_id")),),
)
row = cur.fetchone()
listing_id_map = {row[1]: row[0]} if row else {}
_upsert_question(cur, q_data, listing_id_map)
tenant_conn.commit()
cur.close()
return q_data
def answer_question(tenant_conn, local_question_id: int, text: str) -> dict:
"""Answer a question via ML API and update local status."""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
cur.execute(
"SELECT external_question_id FROM marketplace_questions WHERE id = %s",
(local_question_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Question not found")
external_qid = row[0]
cur.close()
resp = svc.answer_question(external_qid, text)
cur = tenant_conn.cursor()
cur.execute(
"""
UPDATE marketplace_questions
SET answer_text = %s, status = 'answered', answer_date = NOW(), updated_at = NOW()
WHERE id = %s
""",
(text, local_question_id),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "ml_response": resp}
def list_local_questions(tenant_conn, status: str = None) -> list:
"""Return questions from local DB, optionally filtered by status."""
cur = tenant_conn.cursor()
if status:
cur.execute(
"""
SELECT q.id, q.external_question_id, q.external_item_id, q.question_text,
q.answer_text, q.status, q.buyer_nickname, q.question_date,
q.answer_date, q.created_at, l.title, l.external_permalink
FROM marketplace_questions q
LEFT JOIN marketplace_listings l ON l.id = q.listing_id
WHERE q.status = %s
ORDER BY q.question_date DESC
""",
(status,),
)
else:
cur.execute(
"""
SELECT q.id, q.external_question_id, q.external_item_id, q.question_text,
q.answer_text, q.status, q.buyer_nickname, q.question_date,
q.answer_date, q.created_at, l.title, l.external_permalink
FROM marketplace_questions q
LEFT JOIN marketplace_listings l ON l.id = q.listing_id
ORDER BY q.question_date DESC
"""
)
rows = cur.fetchall()
cur.close()
results = []
for r in rows:
results.append({
"id": r[0],
"external_question_id": r[1],
"external_item_id": r[2],
"question_text": r[3],
"answer_text": r[4],
"status": r[5],
"buyer_nickname": r[6],
"question_date": r[7],
"answer_date": r[8],
"created_at": r[9],
"listing_title": r[10],
"listing_permalink": r[11],
})
return results
# ═══════════════════════════════════════════════════════════════════════════
# ORDERS
# ═══════════════════════════════════════════════════════════════════════════

View File

@@ -167,6 +167,23 @@ class MeliService:
def close_item(self, item_id: str) -> dict:
return self.update_item(item_id, {"status": "closed"})
# ─── Questions & Answers ─────────────────────────────────────────────
def get_questions(self, item_id: str, status: str = None, offset: int = 0, limit: int = 50) -> dict:
params = {"item_id": item_id, "offset": offset, "limit": limit}
if status:
params["status"] = status
return self._request("GET", "/questions/search", params=params)
def get_question(self, question_id: str) -> dict:
return self._request("GET", f"/questions/{question_id}")
def answer_question(self, question_id: str, text: str) -> dict:
return self._request("POST", "/answers", json_payload={"question_id": question_id, "text": text})
def delete_question(self, question_id: str) -> dict:
return self._request("DELETE", f"/questions/{question_id}")
# ─── Categories ──────────────────────────────────────────────────────
def get_category(self, category_id: str) -> dict:

View File

@@ -447,6 +447,35 @@ def process_sale(conn, sale_data):
except Exception:
pass # Learning errors never block sales
# Dropshipping webhook hook (non-blocking)
try:
from services import dropshipping_service as ds_svc
from services.webhook_service import dispatch_webhooks_bulk
webhook_urls = ds_svc.get_webhook_targets(conn, 'sale_made')
if webhook_urls:
payload_items = []
for item in enriched_items:
remaining = item['stock_before'] - item['quantity']
payload_items.append({
'sku': item['part_number'],
'name': item['name'],
'quantity_sold': item['quantity'],
'stock_remaining': remaining,
'unit_price': item['unit_price'],
})
threading.Thread(
target=dispatch_webhooks_bulk,
args=(webhook_urls, 'sale_made', {
'sale_id': sale_id,
'items': payload_items,
'total': totals['total'],
'created_at': str(created_at),
}),
daemon=True
).start()
except Exception:
pass # Webhook errors never block sales
return {
'id': sale_id,
'branch_id': branch_id,

View File

@@ -0,0 +1,65 @@
"""Webhook dispatch service for dropshipping and external integrations.
Sends POST requests to configured target URLs with retry logic.
Can be called synchronously or enqueued via Celery.
"""
import json
import logging
import requests
import threading
from typing import Optional
logger = logging.getLogger(__name__)
def _send_post(url: str, payload: dict, headers: Optional[dict] = None, timeout: int = 10):
"""Send a POST request and return (success, status_code, response_text)."""
default_headers = {"Content-Type": "application/json"}
if headers:
default_headers.update(headers)
try:
resp = requests.post(url, json=payload, headers=default_headers, timeout=timeout)
success = 200 <= resp.status_code < 300
if not success:
logger.warning("Webhook %s returned %s: %s", url, resp.status_code, resp.text[:200])
return success, resp.status_code, resp.text
except requests.exceptions.Timeout:
logger.warning("Webhook %s timed out after %ss", url, timeout)
return False, 0, "timeout"
except Exception as e:
logger.warning("Webhook %s failed: %s", url, e)
return False, 0, str(e)
def dispatch_webhook_sync(target_url: str, event_type: str, payload: dict, secret: Optional[str] = None):
"""Send webhook synchronously (use inside Celery tasks for async)."""
full_payload = {
"event": event_type,
"data": payload,
}
headers = {}
if secret:
headers["X-Webhook-Secret"] = secret
success, status, body = _send_post(target_url, full_payload, headers=headers)
return {"success": success, "status": status, "body": body[:500]}
def dispatch_webhooks_bulk(target_urls: list[str], event_type: str, payload: dict, secret: Optional[str] = None):
"""Dispatch to multiple URLs concurrently using threads."""
results = []
threads = []
def _send(url):
result = dispatch_webhook_sync(url, event_type, payload, secret=secret)
results.append({"url": url, **result})
for url in target_urls:
t = threading.Thread(target=_send, args=(url,))
t.start()
threads.append(t)
for t in threads:
t.join(timeout=15)
return results

View File

@@ -812,6 +812,18 @@
box-shadow: var(--shadow-md);
}
.chart-canvas-wrap {
position: relative;
height: 220px;
width: 100%;
}
.chart-canvas-wrap canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.rank-list {
display: flex;
flex-direction: column;

View File

@@ -197,4 +197,14 @@
}).catch(function() {});
} catch(e) {}
// ─── Service Worker update handler ───
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function (event) {
if (event.data && event.data.type === 'SW_UPDATED') {
console.log('[AppInit] SW updated to', event.data.cacheName, '— reloading...');
window.location.reload();
}
});
}
})();

View File

@@ -195,7 +195,19 @@
currentAbort = null;
}
var opts = { headers: headers };
if (url.indexOf('/pos/api/') === 0 && url.indexOf('mode=') !== -1 || url.indexOf('/years') !== -1 || url.indexOf('/brands') !== -1 || url.indexOf('/models') !== -1 || url.indexOf('/engines') !== -1 || url.indexOf('/categories') !== -1 || url.indexOf('/groups') !== -1 || url.indexOf('/part-types') !== -1 || url.indexOf('/parts') !== -1 || url.indexOf('/search') !== -1) {
var isCatalogNav = url.indexOf('/pos/api/') === 0 && (
url.indexOf('mode=') !== -1 ||
url.indexOf('/years') !== -1 ||
url.indexOf('/brands') !== -1 ||
url.indexOf('/models') !== -1 ||
url.indexOf('/engines') !== -1 ||
url.indexOf('/categories') !== -1 ||
url.indexOf('/groups') !== -1 ||
url.indexOf('/part-types') !== -1 ||
url.indexOf('/parts') !== -1 ||
url.indexOf('/search') !== -1
);
if (isCatalogNav) {
currentAbort = new AbortController();
opts.signal = currentAbort.signal;
}
@@ -233,7 +245,7 @@
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
return d.innerHTML.replace(/"/g, '&quot;');
}
// ─── Breadcrumb ───
@@ -290,9 +302,9 @@
function resetNav() {
nav.level = 'brands';
pushNavState();
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = nav.partType = null;
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
pushNavState();
}
function resetNavFrom(level) {
@@ -927,9 +939,14 @@
partsGrid.querySelectorAll('.part-card').forEach(function (card) {
card.addEventListener('click', function () {
var pid = this.dataset.partId;
var src = this.dataset.source || '';
if (typeof pid === 'string' && pid.indexOf('inv:') === 0) {
return;
}
if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) {
openSupplierDetail(pid.replace('sc:', ''));
return;
}
openPartDetail(parseInt(pid));
});
});
@@ -988,17 +1005,23 @@
partsGrid.innerHTML = data.data.map(function (p) {
// Stock badge — prefer tenant stock, then warehouse network, else fallback
var stockBadge;
if (p.local_stock > 0) {
var isSupplier = p.source === 'supplier_catalog' || (typeof p.id_part === 'string' && p.id_part.indexOf('sc:') === 0);
if (isSupplier) {
stockBadge = '<span class="stock-badge stock-badge--none" style="background:#f59e0b;color:#fff;">Cat. Proveedor</span>';
} else if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.in_stock_network || p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
}
// Local inventory native badge
var sourceBadge = p.source === 'local_inventory'
? '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#4f46e5;">Stock Local</span>'
: '';
// Source badge for local inventory or supplier catalog
var sourceBadge = '';
if (p.source === 'local_inventory') {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#4f46e5;">Stock Local</span>';
} else if (isSupplier) {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#f59e0b;color:#fff;">Cat. Proveedor</span>';
}
var imgHtml = p.image_url
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '" loading="lazy" decoding="async">'
@@ -1039,10 +1062,15 @@
partsGrid.querySelectorAll('.part-card').forEach(function (card) {
card.addEventListener('click', function () {
var pid = this.dataset.partId;
var src = this.dataset.source || '';
if (typeof pid === 'string' && pid.indexOf('inv:') === 0) {
// local-inventory item: info already visible on card
return;
}
if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) {
openSupplierDetail(pid.replace('sc:', ''));
return;
}
openPartDetail(parseInt(pid));
});
});
@@ -1185,6 +1213,73 @@
});
}
function openSupplierDetail(supplierId) {
detailBody.innerHTML = '<div class="loading is-visible"><div class="spinner"></div></div>';
detailFooter.style.display = 'none';
detailPanel.classList.add('is-open');
detailOverlay.classList.add('is-visible');
apiFetch('/pos/api/supplier-catalog/items/' + supplierId).then(function (data) {
if (!data || data.error) {
detailBody.innerHTML = '<p style="color:var(--color-error);padding:var(--space-4);">Error al cargar detalle.</p>';
return;
}
var p = data;
var html = '';
html += '<div class="detail-section">';
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-1);">' + esc(p.supplier_name) + ' &gt; ' + esc(p.category || '') + '</div>';
html += '<div class="detail-oem">' + esc(p.sku) + '</div>';
html += '<div class="detail-name">' + esc((p.name || '').replace(/\\n/g, ' ')) + '</div>';
if (p.description) html += '<div class="detail-desc">' + esc(p.description) + '</div>';
if (p.image_url) html += '<div style="margin-top:var(--space-3);text-align:center;"><img src="' + esc(p.image_url) + '" alt="" loading="lazy" decoding="async" style="max-width:100%;max-height:200px;object-fit:contain;border-radius:var(--radius-sm);"></div>';
html += '</div>';
// Interchanges
if (p.interchanges && p.interchanges.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Intercambios OEM</div>';
var seen = {};
p.interchanges.forEach(function(ix) {
var key = (ix.brand || '') + '|' + (ix.interchange_number || '');
if (seen[key]) return;
seen[key] = true;
html += '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--color-border);">' +
'<span style="font-weight:600;">' + esc(ix.brand || '') + '</span>' +
'<span style="color:var(--color-text-muted);font-family:monospace;">' + esc(ix.interchange_number || '') + '</span>' +
'</div>';
});
html += '</div>';
}
// Compatibilities — deduplicate by (make, model, year, engine)
if (p.compatibilities && p.compatibilities.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Vehiculos compatibles</div>';
var seenCompat = {};
var uniqCompat = [];
p.compatibilities.forEach(function(c) {
var key = (c.make || '') + '|' + (c.model || '') + '|' + (c.year || '') + '|' + (c.engine || '');
if (seenCompat[key]) return;
seenCompat[key] = true;
uniqCompat.push(c);
});
var currentMake = '';
uniqCompat.forEach(function(c) {
if (c.make !== currentMake) {
currentMake = c.make;
html += '<div style="font-weight:600;margin-top:8px;">' + esc(c.make) + '</div>';
}
html += '<div style="padding-left:12px;color:var(--color-text-muted);font-size:var(--text-body-sm);">' +
esc(c.model) + ' ' + c.year + ' ' + esc(c.engine || '') + '</div>';
});
html += '</div>';
}
detailBody.innerHTML = html;
});
}
function closeDetail() {
detailPanel.classList.remove('is-open');
detailOverlay.classList.remove('is-visible');
@@ -1398,17 +1493,22 @@
}
searchDropdown.innerHTML = data.data.map(function (r) {
var isLocal = r.source === 'local_inventory' || (typeof r.id_part === 'string' && r.id_part.indexOf('inv:') === 0);
var isSupplier = r.source === 'supplier_catalog' || (typeof r.id_part === 'string' && r.id_part.indexOf('sc:') === 0);
var stockLabel = r.local_stock > 0
? '<span class="stock-badge stock-badge--local" style="margin-left:auto;">Stock: ' + r.local_stock + '</span>'
: '';
var localBadge = isLocal
? '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#4f46e5;">Stock Local</span>'
: '';
var sourceBadge = '';
if (isLocal) {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#4f46e5;">Stock Local</span>';
} else if (isSupplier) {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#f59e0b;color:#fff;">Cat. Proveedor</span>';
}
var oemNum = isLocal ? (r.oem_part_number || r.part_number || '') : (r.oem_part_number || '');
return '<div class="search-result-item" data-part-id="' + r.id_part + '" data-name="' + esc(r.name) + '" data-pn="' + esc(oemNum) + '" data-price="' + (r.local_price || '') + '" data-stock="' + (r.local_stock || 0) + '">' +
var cleanName = (r.name || '').replace(/\\n/g, ' ');
return '<div class="search-result-item" data-part-id="' + r.id_part + '" data-name="' + esc(cleanName) + '" data-pn="' + esc(oemNum) + '" data-price="' + (r.local_price || '') + '" data-stock="' + (r.local_stock || 0) + '" data-source="' + (r.source || '') + '">' +
'<div style="flex:1;">' +
'<div class="search-result__oem">' + localBadge + esc(oemNum) + '</div>' +
'<div class="search-result__name">' + esc(r.name) + '</div>' +
'<div class="search-result__oem">' + sourceBadge + esc(oemNum) + '</div>' +
'<div class="search-result__name">' + esc(cleanName) + '</div>' +
(r.vehicle_info ? '<div class="search-result__vehicle">' + esc(r.vehicle_info) + '</div>' : '') +
'</div>' +
stockLabel +
@@ -1420,6 +1520,7 @@
el.addEventListener('click', function () {
searchDropdown.classList.remove('is-visible');
var pid = this.dataset.partId;
var src = this.dataset.source || '';
if (typeof pid === 'string' && pid.indexOf('inv:') === 0) {
var info = '💠 Stock Local\n\n' +
'Parte: ' + (this.dataset.pn || 'N/A') + '\n' +
@@ -1429,6 +1530,10 @@
alert(info);
return;
}
if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) {
openSupplierDetail(pid.replace('sc:', ''));
return;
}
openPartDetail(parseInt(pid));
});
});

View File

@@ -8,6 +8,9 @@
const token = localStorage.getItem('pos_token') || '';
if (!token) return;
let hourlyChart = null;
let topProductsChart = null;
function headers() {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
}
@@ -31,10 +34,11 @@
function renderHourlyChart(hourly) {
const ctx = document.getElementById('hourlySalesChart');
if (!ctx) return;
if (hourlyChart) { hourlyChart.destroy(); hourlyChart = null; }
const labels = hourly.map(function (h) { return h.hour + ':00'; });
const totals = hourly.map(function (h) { return h.total; });
new Chart(ctx, {
hourlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
@@ -62,10 +66,38 @@
function renderTopProductsChart(topProducts) {
const ctx = document.getElementById('topProductsChart');
if (!ctx) return;
if (topProductsChart) { topProductsChart.destroy(); topProductsChart = null; }
if (!topProducts || topProducts.length === 0) {
// No sales today — render a friendly empty-state mini chart so the canvas
// doesn't collapse or leave a blank hole.
topProductsChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Sin ventas hoy'],
datasets: [{
data: [1],
backgroundColor: ['rgba(136, 136, 136, 0.25)'],
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: { color: '#888', font: { size: 10 }, boxWidth: 10 }
}
}
}
});
return;
}
const labels = topProducts.map(function (p) { return p.name.substring(0, 20); });
const revenues = topProducts.map(function (p) { return p.revenue; });
new Chart(ctx, {
topProductsChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,

View File

@@ -287,8 +287,41 @@
// CREATE ITEM (createModal)
// =====================================================================
function loadCategories() {
var sel = document.getElementById('newCategory');
if (!sel) return;
apiFetch(API + '/categories').then(function(data) {
if (!data || !data.categories) return;
sel.innerHTML = '<option value="">Selecciona categoría</option>';
data.categories.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
});
}
window.loadCategories = loadCategories;
function onCategoryChange(categoryId) {
var subSel = document.getElementById('newSubcategory');
if (!subSel) return;
if (!categoryId) {
subSel.innerHTML = '<option value="">Selecciona categoría primero</option>';
subSel.disabled = true;
return;
}
apiFetch(API + '/categories/' + categoryId + '/subcategories').then(function(data) {
if (!data || !data.subcategories) return;
subSel.innerHTML = '<option value="">Selecciona subcategoría</option>';
data.subcategories.forEach(function(s) {
subSel.innerHTML += '<option value="' + s.id + '">' + esc(s.name) + '</option>';
});
subSel.disabled = false;
});
}
window.onCategoryChange = onCategoryChange;
function showCreateModal() {
document.getElementById('createModal').classList.add('is-open');
loadCategories();
// Attach AI classification on part number blur
var pnInput = document.getElementById('newPartNumber');
if (pnInput && !pnInput._classifyBound) {
@@ -334,6 +367,10 @@
function closeCreateModal() {
document.getElementById('createModal').classList.remove('is-open');
document.getElementById('createResult').innerHTML = '';
var catSel = document.getElementById('newCategory');
var subSel = document.getElementById('newSubcategory');
if (catSel) catSel.innerHTML = '<option value="">Selecciona categoría</option>';
if (subSel) { subSel.innerHTML = '<option value="">Selecciona categoría primero</option>'; subSel.disabled = true; }
}
function createItem() {
@@ -350,8 +387,20 @@
price_3: elPrice3 ? (parseFloat(elPrice3.value) || 0) : 0,
min_stock: parseInt(document.getElementById('newMinStock').value) || 0,
initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
location: document.getElementById('newLocation').value.trim()
location: document.getElementById('newLocation').value.trim(),
sku_aliases: []
};
var sku2 = document.getElementById('newSku2').value.trim();
var sku3 = document.getElementById('newSku3').value.trim();
var categoryId = document.getElementById('newCategory').value;
var subcategoryId = document.getElementById('newSubcategory').value;
if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'});
if (sku3) data.sku_aliases.push({sku: sku3, label: 'Alternativo 2'});
if (subcategoryId) {
data.category_id = parseInt(subcategoryId);
} else if (categoryId) {
data.category_id = parseInt(categoryId);
}
if (!data.part_number || !data.name) {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
return;
@@ -366,7 +415,7 @@
loadItems(currentPage);
// Close modal, clear form, refresh badges
closeCreateModal();
['newPartNumber','newName','newBrand','newBarcode','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
@@ -377,6 +426,54 @@
});
}
function submitBulkImport() {
var fileInput = document.getElementById('bulkImportFile');
var resultEl = document.getElementById('bulkImportResult');
var mode = document.getElementById('bulkImportMode').value;
var strategy = document.getElementById('bulkImportStrategy').value;
if (!fileInput.files || !fileInput.files[0]) {
resultEl.style.display = 'block';
resultEl.innerHTML = '<span style="color:var(--color-error);">Selecciona un archivo CSV o Excel.</span>';
return;
}
var file = fileInput.files[0];
var formData = new FormData();
formData.append('file', file);
resultEl.style.display = 'block';
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Importando...</span>';
fetch(API + '/items/bulk-import', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'X-Import-Mode': mode,
'X-Import-Strategy': strategy
},
body: formData
}).then(function(resp) { return resp.json(); }).then(function(data) {
if (data.error) {
resultEl.innerHTML = '<span style="color:var(--color-error);">' + esc(data.error) + '</span>';
return;
}
var html = '<div style="color:var(--color-success);">Importacion completada: <strong>' + data.created + '</strong> producto(s) creado(s)';
if (data.skipped > 0) html += ', <strong>' + data.skipped + '</strong> saltado(s)';
html += '</div>';
if (data.warnings && data.warnings.length) {
html += '<div style="margin-top:8px;max-height:160px;overflow:auto;background:var(--color-surface);padding:8px;border-radius:6px;font-size:var(--text-caption);">';
html += '<strong style="color:var(--color-warning);">Advertencias (' + data.warnings.length + '):</strong><ul style="margin:4px 0 0 16px;padding:0;">';
data.warnings.forEach(function(w) {
html += '<li>' + esc(w) + '</li>';
});
html += '</ul></div>';
}
resultEl.innerHTML = html;
loadItems(currentPage);
if (window.loadInventoryStats) window.loadInventoryStats();
}).catch(function(err) {
resultEl.innerHTML = '<span style="color:var(--color-error);">Error de red: ' + esc(err.message) + '</span>';
});
}
window.submitBulkImport = submitBulkImport;
// =====================================================================
// PURCHASE / ENTRADA (purchaseModal)
// =====================================================================
@@ -1006,17 +1103,36 @@
var attrName = esc(attr.name || attr.id);
var inputHtml = '<input type="text" class="meli-title-input" id="meliAttr-' + attrId + '" placeholder="' + attrName + '">';
if (attr.values && attr.values.length) {
inputHtml = '<select class="meli-title-input" id="meliAttr-' + attrId + '">' +
// Some ML attributes (like BRAND) have a closed list but the API still
// accepts free-text via value_name. Provide a select + "Other" fallback.
var selectId = 'meliAttrSel-' + attrId;
var otherId = 'meliAttrOther-' + attrId;
inputHtml = '<select class="meli-title-input" id="' + selectId + '" onchange="onMeliAttrSelectChange(\'' + attrId + '\')">' +
'<option value="">Selecciona ' + attrName + '</option>' +
attr.values.map(function(v) { return '<option value="' + esc(v.name) + '">' + esc(v.name) + '</option>'; }).join('') +
'</select>';
'<option value="__other__">Otra marca (escribir)...</option>' +
'</select>' +
'<input type="text" class="meli-title-input" id="' + otherId + '" placeholder="Escribe la ' + attrName + '" style="display:none;margin-top:6px;">';
}
html += '<div class="inv-field"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
html += '<div class="inv-field" id="meliAttrWrap-' + attrId + '"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
});
grid.innerHTML = html;
}).catch(function() { grid.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-caption);">Error cargando atributos</p>'; });
};
window.onMeliAttrSelectChange = function(attrId) {
var sel = document.getElementById('meliAttrSel-' + attrId);
var other = document.getElementById('meliAttrOther-' + attrId);
if (!sel || !other) return;
if (sel.value === '__other__') {
other.style.display = 'block';
other.focus();
} else {
other.style.display = 'none';
other.value = '';
}
};
window.handleMeliCatKeydown = function(e) {
if (!meliCatItems.length) return;
if (e.key === 'ArrowDown') {
@@ -1071,9 +1187,21 @@
if (stockEl) customData.stocks[id] = parseInt(stockEl.value);
var attrs = [];
meliCategoryAttrs.forEach(function(attr) {
var el = document.getElementById('meliAttr-' + attr.id);
if (el && el.value) {
attrs.push({ id: attr.id, value_name: el.value });
var val = '';
var sel = document.getElementById('meliAttrSel-' + attr.id);
if (sel) {
if (sel.value === '__other__') {
var otherEl = document.getElementById('meliAttrOther-' + attr.id);
val = otherEl ? otherEl.value : '';
} else {
val = sel.value;
}
} else {
var el = document.getElementById('meliAttr-' + attr.id);
if (el) val = el.value;
}
if (val) {
attrs.push({ id: attr.id, value_name: val });
}
});
if (attrs.length) customData.attributes[id] = attrs;
@@ -1298,6 +1426,26 @@
var history = data.history || [];
var html = '';
// Tab styles
html += '<style>';
html += '.compat-tabs{display:flex;gap:4px;border-bottom:1px solid var(--color-border);margin-bottom:16px;}';
html += '.compat-tab-btn{padding:8px 16px;font-size:var(--text-body-sm);font-weight:600;cursor:pointer;border:none;background:transparent;color:var(--color-text-muted);border-bottom:2px solid transparent;margin-bottom:-1px;}';
html += '.compat-tab-btn.is-active{color:var(--color-primary);border-bottom-color:var(--color-primary);background:var(--color-surface-0);border-radius:var(--radius-sm) var(--radius-sm) 0 0;}';
html += '.compat-tab-panel{display:none;}';
html += '.compat-tab-panel.is-active{display:block;}';
html += '.compat-form{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}';
html += '.compat-form label{font-size:var(--text-caption);color:var(--color-text-muted);display:block;margin-bottom:4px;}';
html += '</style>';
// Tabs
html += '<div class="compat-tabs">';
html += '<button class="compat-tab-btn is-active" onclick="switchCompatTab(\'detail\',this)">Detalle</button>';
html += '<button class="compat-tab-btn" onclick="switchCompatTab(\'compat\',this)">Compatibilidad</button>';
html += '</div>';
// Detail panel
html += '<div id="compatTab-detail" class="compat-tab-panel is-active">';
// Product image section
html += '<div style="text-align:center;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
if (data.image_url) {
@@ -1330,12 +1478,19 @@
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">ID Inventario</span><strong style="font-family:var(--font-mono);">' + data.id + '</strong></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">No. Parte</span><strong>' + esc(data.part_number) + '</strong></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Nombre</span><strong>' + esc(data.name) + '</strong></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Marca</span>' + esc(data.brand) + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Marca</span>' + esc(data.brand || '-') + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Categoría</span>' + esc(data.category_name || '-') + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Codigo de Barras</span><span style="font-family:var(--font-mono);">' + esc(data.barcode) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Ubicacion</span>' + esc(data.location || '-') + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Stock</span><strong style="font-size:1.2em;">' + (data.stock || 0) + '</strong></div>';
html += '</div>';
// SKU Aliases section
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">SKU Alternativos</div>';
html += '<div id="skuAliasContent" style="margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando SKU alternativos...</p>';
html += '</div>';
// Prices
html += '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Costo</span><span class="td--amount">$' + fmt(data.cost) + '</span></div>';
@@ -1415,14 +1570,67 @@
el.innerHTML = html2;
}
// Vehicle compatibility section
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">Vehiculos Compatibles</div>';
// Close detail panel
html += '</div>';
// Compatibility panel
html += '<div id="compatTab-compat" class="compat-tab-panel">';
// Existing compatibilities
html += '<div id="compatContent" style="margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando compatibilidades...</p>';
html += '</div>';
// Load vehicle compatibilities
(function loadCompat() {
// Manual add form
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">Agregar Manualmente</div>';
html += '<div class="compat-form">';
html += '<div><label>Marca</label><select class="select-filter" id="manualMake" onchange="onManualMakeChange(' + itemId + ')" style="width:100%;"><option value="">Cargando...</option></select></div>';
html += '<div><label>Modelo</label><select class="select-filter" id="manualModel" onchange="onManualModelChange(' + itemId + ')" style="width:100%;" disabled><option value="">Selecciona marca</option></select></div>';
html += '<div><label>Ano</label><select class="select-filter" id="manualYear" onchange="onManualYearChange(' + itemId + ')" style="width:100%;" disabled><option value="">Selecciona modelo</option></select></div>';
html += '<div><label>Motor</label><select class="select-filter" id="manualEngine" style="width:100%;" disabled><option value="">Selecciona ano</option></select></div>';
html += '</div>';
html += '<button class="btn btn--primary btn--sm" onclick="submitManualCompat(' + itemId + ')">Agregar compatibilidad</button>';
// Auto-match button
var btnLabel = compatSource === 'qwen' ? 'Auto-Match con IA (QWEN)' : (compatSource === 'both' ? 'Auto-Match (TecDoc + IA)' : 'Auto-Match por TecDoc');
html += '<div style="margin-top:16px;"><button class="btn btn--ghost btn--sm" onclick="autoMatchCompat(' + itemId + ')">' + btnLabel + '</button></div>';
html += '</div>';
// Load SKU aliases
(function loadSkuAliases() {
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var el = document.getElementById('skuAliasContent');
if (!el) return;
var list = d.aliases || [];
var html2 = '';
if (list.length > 0) {
html2 += '<table class="data-table"><thead><tr><th>SKU</th><th>Etiqueta</th><th></th></tr></thead><tbody>';
list.forEach(function(a) {
html2 += '<tr><td class="td--mono">' + esc(a.sku) + '</td><td>' + esc(a.label || '-') + '</td>';
html2 += '<td><button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="removeSkuAlias(' + itemId + ',' + a.id + ')">Quitar</button></td></tr>';
});
html2 += '</tbody></table>';
} else {
html2 += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin SKU alternativos.</p>';
}
html2 += '<div style="margin-top:8px;display:flex;gap:8px;">';
html2 += '<input type="text" class="meli-title-input" id="newAliasSku-' + itemId + '" placeholder="Nuevo SKU" style="flex:1;">';
html2 += '<input type="text" class="meli-title-input" id="newAliasLabel-' + itemId + '" placeholder="Etiqueta (opcional)" style="flex:1;">';
html2 += '<button class="btn btn--primary btn--sm" onclick="addSkuAlias(' + itemId + ')">Agregar</button>';
html2 += '</div>';
el.innerHTML = html2;
})
.catch(function() {
var el = document.getElementById('skuAliasContent');
if (el) el.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Error al cargar SKU alternativos.</p>';
});
})();
// Load vehicle compatibilities and makes
(function loadCompatPanel() {
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
@@ -1441,15 +1649,28 @@
} else {
html2 += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin vehiculos vinculados.</p>';
}
var btnLabel = compatSource === 'qwen' ? 'Auto-Match con IA (QWEN)' : (compatSource === 'both' ? 'Auto-Match (TecDoc + IA)' : 'Auto-Match por TecDoc');
var btnDesc = compatSource === 'qwen' ? 'Busca compatibilidad usando inteligencia artificial' : (compatSource === 'both' ? 'Busca en catalogo central y con IA' : 'Busca en catalogo central y vincula automaticamente');
html2 += '<div style="margin-top:8px;"><button class="btn btn--primary btn--sm" onclick="autoMatchCompat(' + itemId + ')">' + btnLabel + '</button> <span style="font-size:var(--text-caption);color:var(--color-text-muted);">' + btnDesc + '</span></div>';
el.innerHTML = html2;
})
.catch(function() {
var el = document.getElementById('compatContent');
if (el) el.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Error al cargar compatibilidades.</p>';
});
// Load makes
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var sel = document.getElementById('manualMake');
if (!sel) return;
var opts = '<option value="">Selecciona marca</option>';
(d.makes || []).forEach(function(m) { opts += '<option value="' + esc(m.name) + '" data-id="' + m.id + '">' + esc(m.name) + '</option>'; });
sel.innerHTML = opts;
sel.disabled = false;
})
.catch(function() {
var sel = document.getElementById('manualMake');
if (sel) { sel.innerHTML = '<option value="">Error al cargar</option>'; }
});
})();
// Movement history
@@ -1510,6 +1731,150 @@
}).catch(function() { alert('Error al quitar compatibilidad'); });
}
// Manual compatibility tab functions
window.switchCompatTab = function(tab, btn) {
document.querySelectorAll('.compat-tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
document.querySelectorAll('.compat-tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
btn.classList.add('is-active');
document.getElementById('compatTab-' + tab).classList.add('is-active');
};
window.onManualMakeChange = function(itemId) {
var sel = document.getElementById('manualMake');
var modelSel = document.getElementById('manualModel');
var yearSel = document.getElementById('manualYear');
var engineSel = document.getElementById('manualEngine');
if (!sel || !modelSel) return;
var opt = sel.options[sel.selectedIndex];
var brandId = opt ? opt.getAttribute('data-id') : null;
modelSel.innerHTML = '<option value="">Cargando...</option>';
modelSel.disabled = true;
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
yearSel.disabled = true;
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
engineSel.disabled = true;
if (!brandId) {
modelSel.innerHTML = '<option value="">Selecciona marca</option>';
return;
}
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona modelo</option>';
(d.models || []).forEach(function(m) { opts += '<option value="' + esc(m.name) + '" data-id="' + m.id + '">' + esc(m.name) + '</option>'; });
modelSel.innerHTML = opts;
modelSel.disabled = false;
})
.catch(function() { modelSel.innerHTML = '<option value="">Error</option>'; });
};
window.onManualModelChange = function(itemId) {
var modelSel = document.getElementById('manualModel');
var yearSel = document.getElementById('manualYear');
var engineSel = document.getElementById('manualEngine');
if (!modelSel || !yearSel) return;
var opt = modelSel.options[modelSel.selectedIndex];
var modelId = opt ? opt.getAttribute('data-id') : null;
yearSel.innerHTML = '<option value="">Cargando...</option>';
yearSel.disabled = true;
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
engineSel.disabled = true;
if (!modelId) {
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
return;
}
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona ano</option>';
(d.years || []).forEach(function(y) { opts += '<option value="' + y.year + '" data-id="' + y.id + '">' + y.year + '</option>'; });
yearSel.innerHTML = opts;
yearSel.disabled = false;
})
.catch(function() { yearSel.innerHTML = '<option value="">Error</option>'; });
};
window.onManualYearChange = function(itemId) {
var modelSel = document.getElementById('manualModel');
var yearSel = document.getElementById('manualYear');
var engineSel = document.getElementById('manualEngine');
if (!modelSel || !yearSel || !engineSel) return;
var mOpt = modelSel.options[modelSel.selectedIndex];
var yOpt = yearSel.options[yearSel.selectedIndex];
var modelId = mOpt ? mOpt.getAttribute('data-id') : null;
var yearId = yOpt ? yOpt.getAttribute('data-id') : null;
engineSel.innerHTML = '<option value="">Cargando...</option>';
engineSel.disabled = true;
if (!modelId || !yearId) {
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
return;
}
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona motor</option>';
(d.engines || []).forEach(function(e) { opts += '<option value="' + esc(e.name) + '" data-code="' + esc(e.code || '') + '">' + esc(e.name + (e.code ? ' (' + e.code + ')' : '')) + '</option>'; });
engineSel.innerHTML = opts;
engineSel.disabled = false;
})
.catch(function() { engineSel.innerHTML = '<option value="">Error</option>'; });
};
window.submitManualCompat = function(itemId) {
var makeSel = document.getElementById('manualMake');
var modelSel = document.getElementById('manualModel');
var yearSel = document.getElementById('manualYear');
var engineSel = document.getElementById('manualEngine');
if (!makeSel || !modelSel || !yearSel) return;
var make = makeSel.value;
var model = modelSel.value;
var year = yearSel.value;
var engine = engineSel ? engineSel.value : '';
var engineCode = engineSel && engineSel.selectedIndex > 0 ? (engineSel.options[engineSel.selectedIndex].getAttribute('data-code') || '') : '';
if (!make || !model || !year) {
alert('Selecciona al menos marca, modelo y ano');
return;
}
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ make: make, model: model, year: parseInt(year), engine: engine, engine_code: engineCode })
}).then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
viewProductDetail(itemId);
}).catch(function() { alert('Error al agregar compatibilidad'); });
};
// SKU alias actions
window.addSkuAlias = function(itemId) {
var skuEl = document.getElementById('newAliasSku-' + itemId);
var labelEl = document.getElementById('newAliasLabel-' + itemId);
var sku = skuEl ? skuEl.value.trim() : '';
var label = labelEl ? labelEl.value.trim() : '';
if (!sku) { alert('Ingresa un SKU'); return; }
fetch('/pos/api/inventory/items/' + itemId + '/skus', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: sku, label: label })
}).then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
viewProductDetail(itemId);
}).catch(function() { alert('Error al agregar SKU'); });
};
window.removeSkuAlias = function(itemId, aliasId) {
if (!confirm('Eliminar este SKU alternativo?')) return;
fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); })
.then(function() {
viewProductDetail(itemId);
}).catch(function() { alert('Error al eliminar SKU'); });
};
// =====================================================================
// EXPOSE GLOBALS (for onclick handlers in HTML)
// =====================================================================

View File

@@ -27,6 +27,7 @@
});
if (tab === 'listings') loadListings();
if (tab === 'orders') loadOrders();
if (tab === 'questions') loadQuestions();
};
function closeModal(id) {
@@ -81,7 +82,7 @@
localStorage.setItem('meli_shipping', shipping);
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri);
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=read+write+offline_access';
window.location.href = authUrl;
};
@@ -148,16 +149,18 @@
var statusClass = 'meli-status--' + (l.external_status || 'pending');
return '<div class="meli-card">'
+ '<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:var(--space-3);">'
+ '<div style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;">' + escapeHtml(l.title || l.inventory_name || 'Sin título') + '</div>'
+ '<a href="' + escapeHtml(l.external_permalink || '#') + '" target="_blank" rel="noopener" style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;color:var(--color-primary);text-decoration:none;">' + escapeHtml(l.title || l.inventory_name || 'Sin título') + ' ↗</a>'
+ '<span class="meli-status ' + statusClass + '">' + (l.external_status || '—') + '</span>'
+ '</div>'
+ '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-2);">'
+ 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: ' + escapeHtml(l.external_item_id || '—')
+ 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: <a href="' + escapeHtml(l.external_permalink || '#') + '" target="_blank" rel="noopener" style="color:var(--color-primary);">' + escapeHtml(l.external_item_id || '—') + '</a>'
+ '</div>'
+ '<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);">'
+ '<button class="btn btn--ghost btn--xs" onclick="syncListing(' + l.id + ')">Sync</button>'
+ (l.external_status === 'active' ? '<button class="btn btn--ghost btn--xs" onclick="pauseListing(' + l.id + ')">Pausar</button>' : '<button class="btn btn--ghost btn--xs" onclick="activateListing(' + l.id + ')">Activar</button>')
+ '<button class="btn btn--danger btn--xs" onclick="deleteListing(' + l.id + ')">Cerrar</button>'
+ (l.external_status === 'closed' || !l.is_active
? '<button class="btn btn--danger btn--xs" onclick="deleteListingPermanently(' + l.id + ')">Eliminar</button>'
: '<button class="btn btn--danger btn--xs" onclick="deleteListing(' + l.id + ')">Cerrar</button>')
+ '</div>'
+ '</div>';
}).join('');
@@ -202,6 +205,14 @@
} catch (e) { alert('Error: ' + e.message); }
};
window.deleteListingPermanently = async function(id) {
if (!confirm('¿Eliminar permanentemente esta publicación del listado local? Esta acción no se puede deshacer.')) return;
try {
var res = await fetch(API + '/listings/' + id + '/permanent', { method: 'DELETE', headers: headers() });
if (res.ok) { loadListings(); } else { alert('Error al eliminar'); }
} catch (e) { alert('Error: ' + e.message); }
};
// ─── Orders ────────────────────────────────────────────────────────────
var ordersData = [];
@@ -451,11 +462,144 @@
container.innerHTML = html;
}
// ─── Questions ─────────────────────────────────────────────────────────
var questionsData = [];
window.loadQuestions = async function() {
var container = document.getElementById('questionsContainer');
container.innerHTML = '<div class="skeleton-grid">' + Array(6).fill('<div class="skeleton skeleton--card"></div>').join('') + '</div>';
try {
var res = await fetch(API + '/questions', { headers: headers() });
if (!res.ok) throw new Error('Failed to load questions');
var data = await res.json();
questionsData = data.items || [];
renderQuestions();
} catch (e) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
title: 'Sin preguntas',
subtitle: 'No hay preguntas de compradores pendientes. Sincroniza con MercadoLibre para obtenerlas.',
action: '<button class="btn btn--meli btn--sm" onclick="syncQuestions()">Sincronizar con ML</button>'
});
}
};
function renderQuestions() {
var container = document.getElementById('questionsContainer');
var statusFilter = document.getElementById('questionStatusFilter').value;
var search = document.getElementById('questionSearch').value.toLowerCase();
var filtered = questionsData.filter(function(q) {
if (statusFilter && q.status !== statusFilter) return false;
if (search && !((q.question_text || '').toLowerCase().includes(search)) && !((q.listing_title || '').toLowerCase().includes(search))) return false;
return true;
});
// Stats bar
var unanswered = questionsData.filter(function(q) { return q.status === 'unanswered'; }).length;
var answered = questionsData.filter(function(q) { return q.status === 'answered'; }).length;
var total = questionsData.length;
var statsHtml = '<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4);">' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-primary);">' + total + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Total preguntas</div></div>' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-error);">' + unanswered + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Sin responder</div></div>' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-success);">' + answered + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Respondidas</div></div>' +
'</div>';
document.getElementById('questionsStatsBar').innerHTML = statsHtml;
if (!filtered.length) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
title: 'Sin preguntas',
subtitle: statusFilter ? 'No hay preguntas con el filtro seleccionado.' : 'No hay preguntas sincronizadas.',
action: ''
});
return;
}
container.innerHTML = filtered.map(function(q) {
var statusClass = 'meli-status--' + (q.status || 'pending');
var statusLabel = q.status === 'unanswered' ? 'Sin responder' : (q.status === 'answered' ? 'Respondida' : (q.status || '—'));
var answerHtml = '';
if (q.status === 'unanswered') {
answerHtml = '<div style="margin-top:var(--space-2);">' +
'<textarea class="meli-title-input" id="qAnswer-' + q.id + '" rows="2" placeholder="Escribe tu respuesta..."></textarea>' +
'<button class="btn btn--primary btn--xs" style="margin-top:6px;" onclick="submitAnswer(' + q.id + ')">Enviar respuesta</button>' +
'</div>';
} else if (q.answer_text) {
answerHtml = '<div style="margin-top:var(--space-2);padding:var(--space-2);background:var(--color-surface-0);border-radius:var(--radius-sm);font-size:var(--text-caption);color:var(--color-text-secondary);">' +
'<strong>Respuesta:</strong> ' + escapeHtml(q.answer_text) +
'</div>';
}
return '<div class="meli-card">'
+ '<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:var(--space-3);">'
+ '<div style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;">' + escapeHtml(q.listing_title || 'Artículo sin título') + '</div>'
+ '<span class="meli-status ' + statusClass + '">' + statusLabel + '</span>'
+ '</div>'
+ '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-2);">'
+ 'Comprador: ' + escapeHtml(q.buyer_nickname || '—') + ' · ' + (q.question_date ? new Date(q.question_date).toLocaleString('es-MX') : '—')
+ '</div>'
+ '<div style="font-size:var(--text-body-sm);color:var(--color-text-primary);margin-bottom:var(--space-2);">'
+ '<strong>Pregunta:</strong> ' + escapeHtml(q.question_text)
+ '</div>'
+ answerHtml
+ '</div>';
}).join('');
}
window.filterQuestions = renderQuestions;
window.syncQuestions = async function() {
var btn = document.querySelector('#panel-questions .btn--primary');
if (btn) { btn.disabled = true; btn.textContent = 'Sincronizando...'; }
try {
var res = await fetch(API + '/questions/sync', { method: 'POST', headers: headers() });
var data = await res.json();
if (res.ok) {
showToast('Sincronizadas ' + (data.synced || 0) + ' preguntas', 'ok', { title: 'Sincronización' });
loadQuestions();
} else {
showToast(data.error || 'Error al sincronizar', 'error', { title: 'Error' });
}
} catch (e) {
showToast(e.message, 'error', { title: 'Error de red' });
} finally {
if (btn) { btn.disabled = false; btn.textContent = '🔄 Actualizar'; }
}
};
window.submitAnswer = async function(questionId) {
var textarea = document.getElementById('qAnswer-' + questionId);
if (!textarea) return;
var text = textarea.value.trim();
if (!text) {
showToast('Escribe una respuesta antes de enviar', 'error', { title: 'Respuesta vacía' });
return;
}
try {
var res = await fetch(API + '/questions/' + questionId + '/answer', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ text: text })
});
var data = await res.json();
if (res.ok) {
showToast('Respuesta enviada correctamente', 'ok', { title: 'Pregunta respondida' });
loadQuestions();
} else {
showToast(data.error || 'Error al enviar respuesta', 'error', { title: 'Error' });
}
} catch (e) {
showToast(e.message, 'error', { title: 'Error de red' });
}
};
// Register Cmd+K items
if (typeof registerCmdKItem === 'function') {
registerCmdKItem({ group: 'MercadoLibre', label: 'Configuración ML', href: '/pos/marketplace-external', icon: '⚙️' });
registerCmdKItem({ group: 'MercadoLibre', label: 'Publicaciones ML', href: '/pos/marketplace-external#listings', icon: '📦' });
registerCmdKItem({ group: 'MercadoLibre', label: 'Órdenes ML', href: '/pos/marketplace-external#orders', icon: '🛒' });
registerCmdKItem({ group: 'MercadoLibre', label: 'Preguntas ML', href: '/pos/marketplace-external#questions', icon: '❓' });
}
document.addEventListener('DOMContentLoaded', function() {

View File

@@ -0,0 +1,299 @@
(function() {
'use strict';
const API = '/pos/api/supplier-catalog';
const VEHICLE_API = '/pos/api/inventory/vehicles';
const token = localStorage.getItem('pos_token') || '';
let state = {
q: '',
category: '',
make: '',
model: '',
year: '',
engine: '',
myeId: null,
page: 1,
perPage: 30,
totalPages: 1,
categories: [],
items: []
};
function headers() {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
}
let scAbort = null;
let scSeq = 0;
async function apiFetch(url) {
if (scAbort) {
scAbort.abort();
scAbort = null;
}
const ctrl = new AbortController();
scAbort = ctrl;
try {
const resp = await fetch(url, { headers: headers(), signal: ctrl.signal });
if (resp.status === 401) { window.location.href = '/pos/login'; return null; }
if (!resp.ok) { console.error('API error', url, resp.status); return null; }
return resp.json();
} catch (e) {
if (e.name === 'AbortError') return null;
console.error('API error', url, e);
return null;
}
}
async function apiFetchSeq(url) {
const mySeq = ++scSeq;
const data = await apiFetch(url);
if (!data || scSeq !== mySeq) return null;
return data;
}
// ─── Categories ─────────────────────────────────────────────
async function loadCategories() {
const data = await apiFetch(API + '/categories');
if (!data) return;
state.categories = data.categories || [];
renderCategories();
}
function renderCategories() {
const el = document.getElementById('categoriesGrid');
if (!el) return;
let html = '<div class="sc-cat-card' + (state.category === '' ? ' active' : '') + '" onclick="selectCategory(\'\')">' +
'<div>Todas</div><div class="count">' + state.categories.reduce((a,c)=>a+c.count,0) + ' items</div></div>';
state.categories.forEach(function(c) {
html += '<div class="sc-cat-card' + (state.category === c.name ? ' active' : '') + '" onclick="selectCategory(\'' + escapeHtml(c.name) + '\')">' +
'<div>' + escapeHtml(c.name) + '</div><div class="count">' + c.count + ' items</div></div>';
});
el.innerHTML = html;
}
window.selectCategory = function(name) {
state.category = name;
state.page = 1;
renderCategories();
doSearch();
};
// ─── Vehicle filters ────────────────────────────────────────
async function loadMakes() {
const data = await apiFetch(VEHICLE_API + '/makes');
if (!data) return;
const sel = document.getElementById('filterMake');
sel.innerHTML = '<option value="">Marca vehiculo</option>';
(data.data || []).forEach(function(m) {
sel.innerHTML += '<option value="' + escapeHtml(m.name_brand) + '">' + escapeHtml(m.name_brand) + '</option>';
});
}
window.onMakeChange = async function() {
const sel = document.getElementById('filterMake');
state.make = sel.value;
state.model = ''; state.year = ''; state.engine = ''; state.myeId = null;
document.getElementById('filterModel').disabled = true;
document.getElementById('filterYear').disabled = true;
document.getElementById('filterEngine').disabled = true;
if (!state.make) { doSearch(); return; }
const makes = await apiFetchSeq(VEHICLE_API + '/makes');
if (!makes) return;
const brand = (makes.data || []).find(function(m) { return m.name_brand === state.make; });
if (!brand) { doSearch(); return; }
const models = await apiFetchSeq(VEHICLE_API + '/models?brand_id=' + brand.id_brand);
if (!models) return;
const msel = document.getElementById('filterModel');
msel.innerHTML = '<option value="">Modelo</option>';
(models.data || []).forEach(function(m) {
msel.innerHTML += '<option value="' + m.id_model + '">' + escapeHtml(m.name_model) + '</option>';
});
msel.disabled = false;
doSearch();
};
window.onModelChange = async function() {
const sel = document.getElementById('filterModel');
const modelId = sel.value;
state.model = modelId ? sel.options[sel.selectedIndex].text : '';
state.year = ''; state.engine = ''; state.myeId = null;
document.getElementById('filterYear').disabled = true;
document.getElementById('filterEngine').disabled = true;
if (!modelId) { doSearch(); return; }
const years = await apiFetchSeq(VEHICLE_API + '/years?model_id=' + modelId);
if (!years) return;
const ysel = document.getElementById('filterYear');
ysel.innerHTML = '<option value="">Año</option>';
(years.data || []).forEach(function(y) {
ysel.innerHTML += '<option value="' + y.id_year + '">' + y.year_car + '</option>';
});
ysel.disabled = false;
doSearch();
};
window.onYearChange = async function() {
const sel = document.getElementById('filterYear');
const yearId = sel.value;
const modelId = document.getElementById('filterModel').value;
state.year = yearId ? sel.options[sel.selectedIndex].text : '';
state.engine = ''; state.myeId = null;
document.getElementById('filterEngine').disabled = true;
if (!yearId || !modelId) { doSearch(); return; }
const engines = await apiFetchSeq(VEHICLE_API + '/engines?model_id=' + modelId + '&year_id=' + yearId);
if (!engines) return;
const esel = document.getElementById('filterEngine');
esel.innerHTML = '<option value="">Motorizacion</option>';
(engines.data || []).forEach(function(e) {
const label = escapeHtml(e.name_engine) + (e.trim_level ? ' (' + escapeHtml(e.trim_level) + ')' : '');
esel.innerHTML += '<option value="' + e.id_mye + '">' + label + '</option>';
});
esel.disabled = false;
doSearch();
};
// ─── Search ─────────────────────────────────────────────────
window.doSearch = async function() {
state.q = document.getElementById('searchInput').value.trim();
const engineSel = document.getElementById('filterEngine');
state.myeId = engineSel.value || null;
let url = API + '/search?page=' + state.page + '&per_page=' + state.perPage;
if (state.q) url += '&q=' + encodeURIComponent(state.q);
if (state.category) url += '&category=' + encodeURIComponent(state.category);
if (state.myeId) {
url += '&mye_id=' + state.myeId;
} else {
if (state.make) url += '&make=' + encodeURIComponent(state.make);
if (state.model) url += '&model=' + encodeURIComponent(state.model);
if (state.year) url += '&year=' + encodeURIComponent(state.year);
}
const data = await apiFetch(url);
if (!data) return;
state.items = data.data || [];
state.totalPages = (data.pagination || {}).total_pages || 1;
renderItems();
renderPagination();
};
window.clearFilters = function() {
document.getElementById('searchInput').value = '';
document.getElementById('filterMake').value = '';
document.getElementById('filterModel').innerHTML = '<option value="">Modelo</option>'; document.getElementById('filterModel').disabled = true;
document.getElementById('filterYear').innerHTML = '<option value="">Año</option>'; document.getElementById('filterYear').disabled = true;
document.getElementById('filterEngine').innerHTML = '<option value="">Motorizacion</option>'; document.getElementById('filterEngine').disabled = true;
state.q = ''; state.category = ''; state.make = ''; state.model = ''; state.year = ''; state.engine = ''; state.myeId = null; state.page = 1;
renderCategories();
doSearch();
};
// ─── Render results ─────────────────────────────────────────
function renderItems() {
const el = document.getElementById('partsGrid');
if (!el) return;
if (!state.items.length) {
el.innerHTML = '<div class="sc-empty" style="grid-column:1/-1;"><div style="font-size:48px;margin-bottom:var(--space-4);">🔍</div><h3>Sin resultados</h3><p>Intenta con otros filtros o terminos de busqueda.</p></div>';
return;
}
el.innerHTML = state.items.map(function(it) {
return '<div class="sc-card" onclick="openDetail(' + it.id + ')">' +
'<div class="sc-card__sku">' + escapeHtml(it.sku) + '</div>' +
'<div class="sc-card__name">' + escapeHtml(it.name) + '</div>' +
'<div class="sc-card__meta">' +
'<span class="sc-card__badge">' + escapeHtml(it.category || 'SIN CATEGORIA') + '</span>' +
' <span>' + escapeHtml(it.supplier_name) + '</span>' +
'</div>' +
'</div>';
}).join('');
}
function renderPagination() {
const el = document.getElementById('pagination');
if (!el) return;
if (state.totalPages <= 1) { el.innerHTML = ''; return; }
let html = '<button ' + (state.page <= 1 ? 'disabled' : '') + ' onclick="goPage(' + (state.page - 1) + ')">Anterior</button>';
html += '<span>Pagina ' + state.page + ' de ' + state.totalPages + '</span>';
html += '<button ' + (state.page >= state.totalPages ? 'disabled' : '') + ' onclick="goPage(' + (state.page + 1) + ')">Siguiente</button>';
el.innerHTML = html;
}
window.goPage = function(p) {
state.page = p;
doSearch();
};
// ─── Detail modal ───────────────────────────────────────────
window.openDetail = async function(id) {
const data = await apiFetch(API + '/items/' + id);
if (!data) return;
document.getElementById('modalTitle').textContent = escapeHtml(data.sku);
let html = '';
html += '<div><strong style="font-size:var(--text-h6);">' + escapeHtml(data.name) + '</strong></div>';
html += '<div class="sc-modal__section"><h4>Informacion</h4>' +
'<p>Proveedor: ' + escapeHtml(data.supplier_name) + '<br>Categoria: ' + escapeHtml(data.category || 'N/A') + '</p></div>';
if (data.interchanges && data.interchanges.length) {
html += '<div class="sc-modal__section"><h4>Intercambios</h4><div class="sc-interchange-list">' +
data.interchanges.map(function(ix) {
return '<span class="sc-interchange-chip">' + escapeHtml(ix.brand) + ' — ' + escapeHtml(ix.part_number) + '</span>';
}).join('') + '</div></div>';
}
if (data.compatibilities && data.compatibilities.length) {
var seenCompat = {};
var uniqCompat = data.compatibilities.filter(function(c) {
var key = (c.make || '') + '|' + (c.model || '') + '|' + (c.year || '') + '|' + (c.engine || '');
if (seenCompat[key]) return false;
seenCompat[key] = true;
return true;
});
html += '<div class="sc-modal__section"><h4>Vehiculos compatibles (' + uniqCompat.length + ')</h4>' +
'<div class="sc-compat-grid">' +
uniqCompat.slice(0, 50).map(function(c) {
return '<div class="sc-compat-item">' +
'<strong>' + escapeHtml(c.make || '') + ' ' + escapeHtml(c.model || '') + '</strong><br>' +
(c.year || '') + ' ' + escapeHtml(c.engine || '') +
'</div>';
}).join('') +
(uniqCompat.length > 50 ? '<div style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">... y ' + (uniqCompat.length - 50) + ' mas</div>' : '') +
'</div></div>';
}
document.getElementById('modalBody').innerHTML = html;
document.getElementById('detailModal').classList.add('open');
};
window.closeModal = function() {
document.getElementById('detailModal').classList.remove('open');
};
// ─── Utils ──────────────────────────────────────────────────
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── Init ───────────────────────────────────────────────────
function init() {
if (!token) { window.location.href = '/pos/login'; return; }
loadCategories();
loadMakes();
doSearch().then(function() {
var params = new URLSearchParams(window.location.search);
var id = params.get('id');
if (id) { openDetail(parseInt(id)); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -1,27 +1,27 @@
// /home/Autopartes/pos/static/pwa/sw.js
// Nexus POS — Service Worker v9
// Nexus POS — Service Worker v17
// Self-contained vanilla JS. No external imports.
//
// Bump CACHE_NAME whenever static assets change significantly.
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely.
const CACHE_NAME = 'nexus-pos-v11';
const CACHE_NAME = 'nexus-pos-v17';
const APP_SHELL = [
'/pos/static/css/tokens.css',
'/pos/static/css/common.css',
'/pos/static/css/pos-ui.css',
'/pos/static/js/app-init.js',
'/pos/static/js/sidebar.js',
'/pos/static/js/login.js',
'/pos/static/js/pos.js',
'/pos/static/js/catalog.js',
'/pos/static/js/inventory.js',
'/pos/static/js/customers.js',
'/pos/static/js/invoicing.js',
'/pos/static/js/accounting.js',
'/pos/static/js/dashboard.js',
'/pos/static/js/config.js',
'/pos/static/js/reports.js',
'/pos/static/js/offline-banner.js',
'/pos/static/js/sync-engine.js',
'/pos/static/js/brand-catalog.js',
'/pos/static/js/i18n.js',
'/pos/static/js/kiosk.js',
'/pos/static/js/splash-loader.js',
'/pos/static/js/pos-utils.js',
'/pos/static/js/pwa-install.js',
'/pos/static/js/chat.js',
'/pos/static/pwa/manifest.json',
'/pos/static/pwa/icon-192.png',
'/pos/static/pwa/icon-512.png'
@@ -103,6 +103,12 @@ self.addEventListener('activate', function (event) {
);
}).then(function () {
return self.clients.claim();
}).then(function () {
return self.clients.matchAll({ type: 'window' }).then(function (clients) {
clients.forEach(function (client) {
client.postMessage({ type: 'SW_UPDATED', cacheName: CACHE_NAME });
});
});
})
);
});
@@ -117,6 +123,13 @@ self.addEventListener('fetch', function (event) {
return;
}
// Normalize cache key for static assets (strip query strings) so
// catalog.js?v=5 and catalog.js?v=6 share the same cache entry.
var cacheKey = req;
if (/\.(js|css|png|jpg|jpeg|webp|svg|gif|ico|woff|woff2|ttf|eot|json)$/.test(url.pathname)) {
cacheKey = new Request(url.pathname);
}
// Never cache auth endpoints
if (url.pathname.indexOf('/pos/api/auth/') !== -1) {
return;
@@ -172,16 +185,17 @@ self.addEventListener('fetch', function (event) {
}
// Everything else (JS, CSS, images) -> cache-first
event.respondWith(cacheFirst(req));
event.respondWith(cacheFirst(req, cacheKey));
});
function cacheFirst(request) {
return caches.match(request).then(function (cached) {
function cacheFirst(request, cacheKey) {
cacheKey = cacheKey || request;
return caches.match(cacheKey).then(function (cached) {
if (cached) {
fetch(request).then(function (response) {
if (response && response.status === 200 && request.method === 'GET') {
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, response);
cache.put(cacheKey, response);
});
}
}).catch(function () {});
@@ -191,7 +205,7 @@ function cacheFirst(request) {
if (response && response.status === 200 && request.method === 'GET') {
var clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, clone);
cache.put(cacheKey, clone);
});
}
return response;

View File

@@ -218,6 +218,17 @@ def process_meli_webhook_task(self, tenant_id: int, topic: str, resource: str):
# Re-use fetch_and_save_orders by passing the order directly
# For simplicity, trigger a full sync for recent orders
return meli_svc.fetch_and_save_orders(conn)
if topic.startswith("questions"):
# Fetch single question and upsert locally
if resource:
question_id = resource.split("/")[-1]
try:
meli_svc.fetch_question_from_ml(conn, question_id)
return {'ok': True, 'topic': topic, 'question_id': question_id}
except Exception as qe:
return {'ok': False, 'topic': topic, 'error': str(qe)}
return {'ok': True, 'topic': topic}
except Exception as e:
return {'error': str(e)}
@@ -270,7 +281,7 @@ def sync_vehicle_compatibility_task(self, tenant_id, item_id, part_number, name,
@celery.task(bind=True)
def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_id: str, listing_type: str = "gold_special", shipping_mode: str = "me2", custom_data: dict = None):
def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_id: str, listing_type: str = "gold_special", shipping_mode: str = "me2", custom_data: dict = None, base_url: str = None):
"""Publish inventory items to MercadoLibre asynchronously."""
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
@@ -284,6 +295,7 @@ def publish_meli_items_task(self, tenant_id: int, inventory_ids: list, category_
listing_type_id=listing_type,
shipping_mode=shipping_mode,
custom_data=custom_data or {},
base_url=base_url,
)
return result
except Exception as e:

View File

@@ -68,6 +68,10 @@
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>
Catalogo
</a>
<a class="nav-item" href="/pos/supplier-catalog" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M2 3h6l2 3h12v14H2V3z"/></svg>
Cat. Proveedores
</a>
<div class="nav-section-label" style="margin-top: var(--space-2);">Gestion</div>
<a class="nav-item" href="/pos/customers" role="menuitem">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
@@ -291,7 +295,7 @@
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/catalog.js?v=2" defer></script>
<script src="/pos/static/js/catalog.js?v=5" defer></script>
<script src="/pos/static/js/offline-banner.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>

View File

@@ -15,7 +15,7 @@
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<link rel="stylesheet" href="/pos/static/css/dashboard.css">
<link rel="stylesheet" href="/pos/static/css/dashboard.css?v=3">
</head>
<body>
@@ -368,7 +368,7 @@
Ventas por Hora
</div>
</div>
<canvas id="hourlySalesChart" height="180"></canvas>
<div class="chart-canvas-wrap"><canvas id="hourlySalesChart"></canvas></div>
</div>
<div class="rank-card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
@@ -376,7 +376,7 @@
Top Productos (Hoy)
</div>
</div>
<canvas id="topProductsChart" height="180"></canvas>
<div class="chart-canvas-wrap"><canvas id="topProductsChart"></canvas></div>
</div>
</div>
</section>
@@ -494,8 +494,8 @@
<script src="/pos/static/js/splash-loader.js?v=1" defer></script>
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/dashboard-stats.js" defer></script>
<script src="/pos/static/js/dashboard.js?v=2" defer></script>
<script src="/pos/static/js/dashboard-stats.js?v=3" defer></script>
<script src="/pos/static/js/dashboard.js?v=3" defer></script>
<script src="/pos/static/js/sync-engine.js" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
<script src="/pos/static/js/pwa-install.js" defer></script>

View File

@@ -183,6 +183,10 @@
<h1 class="page-header__title">Inventario</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Importar CSV
</button>
<button class="btn btn--ghost" onclick="exportVisibleTableCSV('inventario')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Exportar CSV
@@ -708,8 +712,20 @@
<div class="inv-form-grid">
<div class="inv-field"><label>No. Parte *</label><input type="text" id="newPartNumber" placeholder="Ej: GAT-50104" /></div>
<div class="inv-field"><label>Nombre *</label><input type="text" id="newName" placeholder="Nombre del producto" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca" /></div>
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca del fabricante" /></div>
<div class="inv-field"><label>Categoría</label>
<select class="select-filter" id="newCategory" onchange="onCategoryChange(this.value)" style="width:100%;">
<option value="">Selecciona categoría</option>
</select>
</div>
<div class="inv-field"><label>Subcategoría</label>
<select class="select-filter" id="newSubcategory" style="width:100%;" disabled>
<option value="">Selecciona categoría primero</option>
</select>
</div>
<div class="inv-field"><label>Barcode</label><input type="text" id="newBarcode" placeholder="Auto-generado si vacío" /></div>
<div class="inv-field"><label>SKU Alternativo 1</label><input type="text" id="newSku2" placeholder="Ej: SKU-Bodega-A" /></div>
<div class="inv-field"><label>SKU Alternativo 2</label><input type="text" id="newSku3" placeholder="Ej: SKU-Bodega-B" /></div>
<div class="inv-field"><label>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio Mostrador</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Stock Mínimo</label><input type="number" id="newMinStock" placeholder="0" /></div>
@@ -973,6 +989,47 @@
</div>
</div>
<!-- ══════════ Bulk Import Modal ══════════ -->
<div class="inv-modal-overlay" id="bulkImportModal">
<div class="inv-modal" style="max-width:520px;">
<div class="inv-modal__header">
<h3>Importar Productos Masivamente</h3>
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">&times;</button>
</div>
<div class="inv-modal__body">
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label>
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Modo de importación</label>
<select id="bulkImportMode" class="select-filter" style="width:100%;">
<option value="strict">Estricto — abortar al primer error</option>
<option value="lenient" selected>Permisivo — saltar filas con error</option>
</select>
</div>
<div style="margin-bottom:12px;">
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Compatibilidad de vehículo faltante</label>
<select id="bulkImportStrategy" class="select-filter" style="width:100%;">
<option value="qwen" selected>Auto-generar con IA (QWEN)</option>
<option value="skip">Omitir compatibilidad</option>
<option value="reject">Rechazar filas sin compatibilidad</option>
</select>
</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
<strong>Columnas esperadas:</strong>
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code</code>
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
</div>
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">Cancelar</button>
<button class="btn btn--primary" onclick="submitBulkImport()">Importar</button>
</div>
</div>
</div>
<!-- Offline Banner -->
<div id="offlineBanner" class="banner banner--warning" style="display:none;position:fixed;top:0;left:0;right:0;z-index:9999;border-radius:0;animation:none;">
<span class="banner__icon"></span>

View File

@@ -184,6 +184,9 @@
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-orders" onclick="switchTab('orders')">
Órdenes
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-questions" onclick="switchTab('questions')">
Preguntas
</button>
</div>
<!-- Tab Panels -->
@@ -262,6 +265,27 @@
<div id="listingsPagination" class="table-footer" style="margin-top:var(--space-4);"></div>
</div>
<!-- ══════════ TAB: Preguntas ══════════ -->
<div class="tab-panel" id="panel-questions" role="tabpanel">
<div class="toolbar">
<div class="search-box">
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="questionSearch" placeholder="Buscar pregunta..." oninput="filterQuestions()" />
</div>
<select class="select-filter" id="questionStatusFilter" onchange="filterQuestions()">
<option value="">Todas</option>
<option value="unanswered">Sin responder</option>
<option value="answered">Respondidas</option>
<option value="closed">Cerradas</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--ghost btn--sm" onclick="syncQuestions()">🔄 Sincronizar con ML</button>
<button class="btn btn--primary" onclick="loadQuestions()">🔄 Actualizar</button>
</div>
<div id="questionsStatsBar" style="margin-bottom:var(--space-4);"></div>
<div id="questionsContainer" class="meli-grid"></div>
</div>
<!-- ══════════ TAB: Órdenes ══════════ -->
<div class="tab-panel" id="panel-orders" role="tabpanel">
<div class="toolbar">

View File

@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html lang="es">
<head>
<script>(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catalogo de Proveedores — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/common.css" />
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=2" />
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
<style>
.supplier-catalog { padding: var(--space-5); max-width: 1400px; margin: 0 auto; }
.sc-header { display:flex; align-items:center; justify-content:space-between; gap:var(--space-4); margin-bottom:var(--space-5); flex-wrap:wrap; }
.sc-search { display:flex; gap:var(--space-3); flex:1; min-width:280px; }
.sc-search input { flex:1; padding:var(--space-3) var(--space-4); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-bg-elevated); color:var(--color-text-primary); font-size:var(--text-body); }
.sc-filters { display:flex; gap:var(--space-3); flex-wrap:wrap; margin-bottom:var(--space-5); }
.sc-filters select { padding:var(--space-2) var(--space-3); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-bg-elevated); color:var(--color-text-primary); min-width:140px; }
.sc-categories { display:grid; grid-template-columns:repeat(auto-fill, minmax(160px, 1fr)); gap:var(--space-3); margin-bottom:var(--space-5); }
.sc-cat-card { background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-lg); padding:var(--space-4); cursor:pointer; transition:all .15s; text-align:center; }
.sc-cat-card:hover, .sc-cat-card.active { border-color:var(--color-primary); box-shadow:var(--shadow-sm); }
.sc-cat-card .count { font-size:var(--text-caption); color:var(--color-text-muted); margin-top:2px; }
.sc-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:var(--space-4); }
.sc-card { background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-lg); padding:var(--space-4); cursor:pointer; transition:all .15s; display:flex; flex-direction:column; gap:var(--space-2); }
.sc-card:hover { border-color:var(--color-primary); transform:translateY(-2px); box-shadow:var(--shadow-sm); }
.sc-card__sku { font-family:var(--font-mono); font-size:var(--text-caption); color:var(--color-primary); font-weight:var(--font-weight-bold); }
.sc-card__name { font-weight:var(--font-weight-semibold); color:var(--color-text-primary); line-height:1.3; }
.sc-card__meta { font-size:var(--text-caption); color:var(--color-text-muted); margin-top:auto; }
.sc-card__badge { display:inline-block; padding:2px 8px; border-radius:var(--radius-full); background:var(--color-primary-muted); color:var(--color-primary); font-size:10px; font-weight:var(--font-weight-bold); text-transform:uppercase; }
.sc-empty { text-align:center; padding:var(--space-8); color:var(--color-text-muted); }
.sc-pagination { display:flex; justify-content:center; align-items:center; gap:var(--space-3); margin-top:var(--space-6); }
.sc-pagination button { padding:var(--space-2) var(--space-4); border:1px solid var(--color-border); border-radius:var(--radius-md); background:var(--color-bg-elevated); color:var(--color-text-primary); cursor:pointer; }
.sc-pagination button:disabled { opacity:.4; cursor:not-allowed; }
.sc-pagination span { font-size:var(--text-caption); color:var(--color-text-muted); }
/* Modal */
.sc-modal-overlay { position:fixed; inset:0; background:var(--overlay-backdrop); z-index:var(--z-modal); display:none; align-items:center; justify-content:center; padding:var(--space-4); }
.sc-modal-overlay.open { display:flex; }
.sc-modal { background:var(--color-bg-elevated); border:1px solid var(--color-border); border-radius:var(--radius-xl); width:100%; max-width:720px; max-height:90vh; overflow-y:auto; display:flex; flex-direction:column; }
.sc-modal__header { display:flex; align-items:center; justify-content:space-between; padding:var(--space-4) var(--space-5); border-bottom:1px solid var(--color-border); }
.sc-modal__body { padding:var(--space-5); display:flex; flex-direction:column; gap:var(--space-4); }
.sc-modal__section h4 { font-size:var(--text-body-sm); font-weight:var(--font-weight-bold); color:var(--color-text-secondary); margin-bottom:var(--space-2); text-transform:uppercase; letter-spacing:var(--tracking-wider); }
.sc-compat-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:var(--space-2); }
.sc-compat-item { background:var(--color-surface-1); border:1px solid var(--color-border); border-radius:var(--radius-md); padding:var(--space-2) var(--space-3); font-size:var(--text-caption); }
.sc-interchange-list { display:flex; flex-wrap:wrap; gap:var(--space-2); }
.sc-interchange-chip { background:var(--color-surface-2); border:1px solid var(--color-border); border-radius:var(--radius-full); padding:2px 10px; font-size:var(--text-caption); }
.sc-close { background:none; border:none; font-size:20px; color:var(--color-text-muted); cursor:pointer; }
</style>
</head>
<body>
<!-- Theme bar -->
<div class="theme-bar">
<span class="theme-bar__label">Tema:</span>
<button class="theme-btn" id="btn-industrial" onclick="setTheme('industrial')">Industrial</button>
<button class="theme-btn" id="btn-modern" onclick="setTheme('modern')">Moderno</button>
</div>
<div class="sidebar-overlay" id="sidebar-overlay" onclick="closeSidebar()"></div>
<div class="app-shell">
<nav class="sidebar themed-scrollbar" id="sidebar">
<div class="sidebar__logo">
<div class="sidebar__logo-text">Nexus</div>
<div class="sidebar__logo-sub">Autoparts POS</div>
</div>
<div class="sidebar__nav">
<div class="sidebar__section-label">Principal</div>
<a href="/pos/dashboard" class="nav-link"><span class="nav-link__icon">📊</span> Dashboard</a>
<a href="/pos/sale" class="nav-link"><span class="nav-link__icon">🛒</span> POS</a>
<a href="/pos/catalog" class="nav-link"><span class="nav-link__icon">📁</span> Catalogo</a>
<a href="/pos/supplier-catalog" class="nav-link active"><span class="nav-link__icon">🏭</span> Cat. Proveedores</a>
<a href="/pos/inventory" class="nav-link"><span class="nav-link__icon">📦</span> Inventario</a>
<a href="/pos/config" class="nav-link"><span class="nav-link__icon">⚙️</span> Configuracion</a>
</div>
</nav>
<div class="main">
<header class="header">
<div class="header__left">
<button class="hamburger-btn" onclick="toggleSidebar()"></button>
<div class="header__greeting">
<div class="header__title">Catalogo de Proveedores</div>
<div class="header__subtitle">Busca por vehiculo, SKU o nombre de parte</div>
</div>
</div>
</header>
<div class="supplier-catalog themed-scrollbar">
<div class="sc-header">
<div class="sc-search">
<input type="text" id="searchInput" placeholder="Buscar SKU, nombre o intercambio..." onkeydown="if(event.key==='Enter') doSearch()" />
<button class="btn btn--primary" onclick="doSearch()">Buscar</button>
<button class="btn btn--secondary" onclick="clearFilters()">Limpiar</button>
</div>
</div>
<div class="sc-filters">
<select id="filterMake" onchange="onMakeChange()"><option value="">Marca vehiculo</option></select>
<select id="filterModel" onchange="onModelChange()" disabled><option value="">Modelo</option></select>
<select id="filterYear" onchange="onYearChange()" disabled><option value="">Año</option></select>
<select id="filterEngine" onchange="doSearch()" disabled><option value="">Motorizacion</option></select>
</div>
<div class="sc-categories" id="categoriesGrid"></div>
<div id="resultsArea">
<div class="sc-grid" id="partsGrid"></div>
<div class="sc-pagination" id="pagination"></div>
</div>
</div>
</div>
</div>
<!-- Detail Modal -->
<div class="sc-modal-overlay" id="detailModal" onclick="closeModal(event)">
<div class="sc-modal" onclick="event.stopPropagation()">
<div class="sc-modal__header">
<h3 id="modalTitle">Detalle</h3>
<button class="sc-close" onclick="closeModal()">&times;</button>
</div>
<div class="sc-modal__body" id="modalBody"></div>
</div>
</div>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/supplier_catalog.js?v=2" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>
</html>

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Test bulk import endpoint — CSV parsing, column normalisation, upsert logic."""
import os
import sys
import io
import csv
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('MASTER_DB_URL', 'postgresql://postgres@/nexus_autopartes')
os.environ.setdefault('TENANT_DB_URL_TEMPLATE', 'postgresql://postgres@/{db_name}')
os.environ.setdefault('POS_JWT_SECRET', 'test-secret-12345678901234567890123456789012')
from blueprints.inventory_bp import _to_decimal, _to_int
RED = '\033[91m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RESET = '\033[0m'
def print_result(name, passed, detail=""):
status = f"{GREEN}PASS{RESET}" if passed else f"{RED}FAIL{RESET}"
print(f" [{status}] {name}" + (f"{detail}" if detail else ""))
def test_to_decimal():
assert _to_decimal("10.5") == 10.5
assert _to_decimal("1,000.50") == 1000.50
assert _to_decimal("") == 0
assert _to_decimal(None, 5) == 5
assert _to_decimal("abc", 99) == 99
print_result("_to_decimal parsing", True)
def test_to_int():
assert _to_int("42") == 42
assert _to_int("1,000") == 1000
assert _to_int("") == 0
assert _to_int(None, 7) == 7
assert _to_int("abc", 99) == 99
print_result("_to_int parsing", True)
def test_csv_column_normalisation():
"""Simulate the column normalisation done in bulk_import_items."""
raw_rows = [
{"SKU": "ABC123", "Nombre": "Filtro de aceite", "Marca": "Bosch", "Precio": "150", "Cantidad": "10"},
{"sku": "DEF456", "name": "Pastillas de freno", "brand": "TRW", "price": "450.50", "stock": "5"},
]
# Normalise keys
for r in raw_rows:
normalised = {}
for k, v in r.items():
nk = str(k).strip().lower().replace(' ', '_')
normalised[nk] = v
r.clear()
r.update(normalised)
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 raw_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)
assert raw_rows[0]['part_number'] == 'ABC123'
assert raw_rows[0]['name'] == 'Filtro de aceite'
assert raw_rows[0]['stock'] == '10'
assert raw_rows[1]['part_number'] == 'DEF456'
assert raw_rows[1]['price'] == '450.50'
print_result("CSV column normalisation", True)
def test_csv_dict_reader():
"""Verify csv.DictReader produces the expected structure."""
csv_text = "sku,name,brand,price,stock\nABC123,Filtro,Bosch,150,10\nDEF456,Pastillas,TRW,450,5"
f = io.StringIO(csv_text)
reader = csv.DictReader(f)
rows = list(reader)
assert len(rows) == 2
assert rows[0]['sku'] == 'ABC123'
assert rows[1]['price'] == '450'
print_result("csv.DictReader parsing", True)
def run_all():
print("\nBulk Import Tests")
print("=" * 40)
test_to_decimal()
test_to_int()
test_csv_column_normalisation()
test_csv_dict_reader()
print("=" * 40)
print("Done.\n")
if __name__ == '__main__':
run_all()