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:
10
pos/app.py
10
pos/app.py
@@ -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')
|
||||
|
||||
@@ -98,7 +98,9 @@ def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands):
|
||||
part_ids = []
|
||||
for p in parts_data:
|
||||
pid = p.get('id_part') or p.get('id')
|
||||
if pid is not None:
|
||||
# Skip local inventory IDs (strings like 'inv:3') — aftermarket filter
|
||||
# only applies to catalog parts with integer OEM part IDs.
|
||||
if pid is not None and isinstance(pid, int):
|
||||
part_ids.append(pid)
|
||||
if not part_ids:
|
||||
return parts_data
|
||||
@@ -124,10 +126,11 @@ def brands():
|
||||
from services.catalog_modes import normalize_mode
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
def _do(master):
|
||||
data = catalog_service.get_brands(master, year_id=year_id, mode=mode)
|
||||
def _do(master, tenant, branch_id):
|
||||
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||
data = catalog_service.get_brands(master, year_id=year_id, mode=mode, mye_ids=mye_ids)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/models', methods=['GET'])
|
||||
@@ -137,10 +140,11 @@ def models():
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
if not brand_id:
|
||||
return jsonify({'error': 'brand_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_models(master, brand_id, year_id=year_id)
|
||||
def _do(master, tenant, branch_id):
|
||||
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||
data = catalog_service.get_models(master, brand_id, year_id=year_id, mye_ids=mye_ids)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/years', methods=['GET'])
|
||||
@@ -149,10 +153,11 @@ def years():
|
||||
model_id = request.args.get('model_id', type=int)
|
||||
if not model_id:
|
||||
return jsonify({'error': 'model_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_years(master, model_id)
|
||||
def _do(master, tenant, branch_id):
|
||||
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||
data = catalog_service.get_years(master, model_id, mye_ids=mye_ids)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/years-all', methods=['GET'])
|
||||
@@ -175,10 +180,11 @@ def engines():
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
if not model_id or not year_id:
|
||||
return jsonify({'error': 'model_id and year_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_engines(master, model_id, year_id)
|
||||
def _do(master, tenant, branch_id):
|
||||
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
|
||||
data = catalog_service.get_engines(master, model_id, year_id, mye_ids=mye_ids)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/categories', methods=['GET'])
|
||||
@@ -198,7 +204,7 @@ def categories():
|
||||
def _do(master, tenant, branch_id):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
if mode == 'local':
|
||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
|
||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id, tenant)
|
||||
else:
|
||||
data = catalog_service.get_categories(master, mye_id, allowed_brands)
|
||||
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
|
||||
@@ -220,17 +226,17 @@ def groups():
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
def _do(master, tenant, branch_id):
|
||||
if mode == 'local':
|
||||
if not category_slug:
|
||||
return jsonify({'error': 'category_slug required for local mode'}), 400
|
||||
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug)
|
||||
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug, tenant)
|
||||
else:
|
||||
if not category_id:
|
||||
return jsonify({'error': 'category_id required for oem mode'}), 400
|
||||
data = catalog_service.get_groups(master, mye_id, category_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
# ─── Parts with stock enrichment (master + tenant) ───
|
||||
@@ -251,19 +257,19 @@ def part_types():
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
def _do(master, tenant, branch_id):
|
||||
if mode == 'local':
|
||||
if not group_slug or not subgroup_slug:
|
||||
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
|
||||
data = catalog_service.get_nexpart_part_types_for_vehicle(
|
||||
master, mye_id, group_slug, subgroup_slug
|
||||
master, mye_id, group_slug, subgroup_slug, tenant
|
||||
)
|
||||
else:
|
||||
if not group_id:
|
||||
return jsonify({'error': 'group_id required for oem mode'}), 400
|
||||
data = catalog_service.get_part_types(master, mye_id, group_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
|
||||
@@ -307,8 +313,8 @@ def shop_supplies_parts():
|
||||
group_slug = request.args.get('group_slug')
|
||||
subgroup_slug = request.args.get('subgroup_slug')
|
||||
part_type_slug = request.args.get('part_type_slug')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 30, type=int)
|
||||
page = max(1, request.args.get('page', 1, type=int) or 1)
|
||||
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
|
||||
if not group_slug or not subgroup_slug or not part_type_slug:
|
||||
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
|
||||
def _do(master, tenant, branch_id):
|
||||
@@ -344,8 +350,8 @@ def parts():
|
||||
nexpart_subgroup = request.args.get('nexpart_subgroup')
|
||||
nexpart_part_type = request.args.get('nexpart_part_type')
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 30, type=int)
|
||||
page = max(1, request.args.get('page', 1, type=int) or 1)
|
||||
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
|
||||
if not mye_id:
|
||||
@@ -364,14 +370,20 @@ def parts():
|
||||
|
||||
def _do(master, tenant, branch_id):
|
||||
allowed_brands = _get_allowed_brands(tenant) if tenant else None
|
||||
# For local mode with allowed_brands, fetch everything first so filtering
|
||||
# happens before pagination. OEM mode keeps post-filter for now.
|
||||
fetch_all_for_filter = bool(allowed_brands) and (mode == 'local' or use_nexpart_nav)
|
||||
_page = 1 if fetch_all_for_filter else page
|
||||
_per_page = 9999 if fetch_all_for_filter else per_page
|
||||
|
||||
if use_nexpart_nav:
|
||||
result = catalog_service.get_parts_for_nexpart_triple(
|
||||
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
|
||||
tenant, branch_id, page, per_page,
|
||||
tenant, branch_id, _page, _per_page,
|
||||
)
|
||||
elif mode == 'local':
|
||||
result = catalog_service.get_parts_local(
|
||||
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||
master, mye_id, group_id, tenant, branch_id, _page, _per_page, part_type=part_type,
|
||||
)
|
||||
else:
|
||||
result = catalog_service.get_parts(
|
||||
@@ -379,6 +391,11 @@ def parts():
|
||||
)
|
||||
if allowed_brands:
|
||||
result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands)
|
||||
if fetch_all_for_filter:
|
||||
total = len(result['data'])
|
||||
offset = (page - 1) * per_page
|
||||
result['data'] = result['data'][offset:offset + per_page]
|
||||
result['pagination'] = catalog_service._pagination(page, per_page, total)
|
||||
result['allowed_brands'] = allowed_brands or []
|
||||
return jsonify(result)
|
||||
return _with_conns(_do)
|
||||
@@ -811,7 +828,7 @@ def brand_parts():
|
||||
if tenant and oem_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, oem_ids)
|
||||
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -886,7 +903,7 @@ def brand_parts():
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, part_ids)
|
||||
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1007,7 +1024,7 @@ def mye_parts():
|
||||
if tenant and oem_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, oem_ids)
|
||||
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1082,7 +1099,7 @@ def mye_parts():
|
||||
if tenant and part_ids:
|
||||
try:
|
||||
from services.catalog_service import _get_local_stock_bulk
|
||||
local_stock = _get_local_stock_bulk(tenant, part_ids)
|
||||
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -469,21 +469,12 @@ _ALLOWED_PART_BRANDS = [
|
||||
@config_bp.route('/available-brands', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_available_brands():
|
||||
"""Return whitelisted aftermarket manufacturer names from master DB."""
|
||||
from tenant_db import get_master_conn
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT m.name_manufacture
|
||||
FROM manufacturers m
|
||||
JOIN aftermarket_parts ap ON ap.manufacturer_id = m.id_manufacture
|
||||
WHERE m.name_manufacture IS NOT NULL AND m.name_manufacture != ''
|
||||
AND LOWER(m.name_manufacture) = ANY(%s)
|
||||
ORDER BY m.name_manufacture ASC
|
||||
""", ([b.lower() for b in _ALLOWED_PART_BRANDS],))
|
||||
brands = [r[0] for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
"""Return the whitelisted part manufacturer names.
|
||||
|
||||
The master DB manufacturers/aftermarket_parts tables were removed with
|
||||
TecDoc, so we return the curated whitelist directly.
|
||||
"""
|
||||
brands = sorted({b.strip() for b in _ALLOWED_PART_BRANDS if b and b.strip()})
|
||||
return jsonify({'brands': brands})
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
@@ -25,83 +26,95 @@ class DecimalEncoder(json.JSONEncoder):
|
||||
@require_auth()
|
||||
def get_stats():
|
||||
"""Summary stats for today and this month."""
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
today = datetime.utcnow().date()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# Sales today
|
||||
today_sales = db.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s""", (today,)
|
||||
).fetchone()
|
||||
try:
|
||||
# Sales today
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s""", (today,)
|
||||
)
|
||||
today_sales = cur.fetchone()
|
||||
|
||||
# Sales this month
|
||||
month_sales = db.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
|
||||
).fetchone()
|
||||
# Sales this month
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
|
||||
)
|
||||
month_sales = cur.fetchone()
|
||||
|
||||
# Top 5 products today
|
||||
top_products = db.execute(
|
||||
"""SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY p.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5""", (today,)
|
||||
).fetchall()
|
||||
# Top 5 products today
|
||||
cur.execute(
|
||||
"""SELECT si.name, SUM(si.quantity) as qty, SUM(si.subtotal) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY si.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5""", (today,)
|
||||
)
|
||||
top_products = cur.fetchall()
|
||||
|
||||
# Hourly sales today (0-23)
|
||||
hourly = db.execute(
|
||||
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
|
||||
COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s
|
||||
GROUP BY hour ORDER BY hour""", (today,)
|
||||
).fetchall()
|
||||
hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly}
|
||||
# Hourly sales today (0-23)
|
||||
cur.execute(
|
||||
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
|
||||
COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s
|
||||
GROUP BY hour ORDER BY hour""", (today,)
|
||||
)
|
||||
hourly = cur.fetchall()
|
||||
hourly_map = {row[0]: {'count': row[1], 'total': row[2]} for row in hourly}
|
||||
|
||||
return jsonify({
|
||||
'today': {
|
||||
'sales_count': today_sales['count'],
|
||||
'sales_total': today_sales['total'],
|
||||
},
|
||||
'month': {
|
||||
'sales_count': month_sales['count'],
|
||||
'sales_total': month_sales['total'],
|
||||
},
|
||||
'top_products': [
|
||||
{'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']}
|
||||
for row in top_products
|
||||
],
|
||||
'hourly_sales': [
|
||||
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
|
||||
'total': hourly_map.get(h, {}).get('total', 0)}
|
||||
for h in range(24)
|
||||
],
|
||||
}, cls=DecimalEncoder)
|
||||
return jsonify({
|
||||
'today': {
|
||||
'sales_count': today_sales[0],
|
||||
'sales_total': float(today_sales[1]) if today_sales[1] is not None else 0,
|
||||
},
|
||||
'month': {
|
||||
'sales_count': month_sales[0],
|
||||
'sales_total': float(month_sales[1]) if month_sales[1] is not None else 0,
|
||||
},
|
||||
'top_products': [
|
||||
{'name': row[0], 'quantity': row[1], 'revenue': float(row[2]) if row[2] is not None else 0}
|
||||
for row in top_products
|
||||
],
|
||||
'hourly_sales': [
|
||||
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
|
||||
'total': float(hourly_map.get(h, {}).get('total', 0))}
|
||||
for h in range(24)
|
||||
],
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_employee_stats():
|
||||
"""Sales per employee today."""
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
today = datetime.utcnow().date()
|
||||
rows = db.execute(
|
||||
"""SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total
|
||||
FROM sales s
|
||||
JOIN employees e ON s.employee_id = e.id_employee
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY e.name
|
||||
ORDER BY total DESC""", (today,)
|
||||
).fetchall()
|
||||
return jsonify({
|
||||
'employees': [
|
||||
{'name': row['name'], 'sales': row['sales'], 'total': row['total']}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT e.name, COUNT(s.id) as sales, COALESCE(SUM(s.total), 0) as total
|
||||
FROM sales s
|
||||
JOIN employees e ON s.employee_id = e.id
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY e.name
|
||||
ORDER BY total DESC""", (today,)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({
|
||||
'employees': [
|
||||
{'name': row[0], 'sales': row[1], 'total': float(row[2]) if row[2] is not None else 0}
|
||||
for row in rows
|
||||
]
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
128
pos/blueprints/dropshipping_bp.py
Normal file
128
pos/blueprints/dropshipping_bp.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Dropshipping API — public read-only inventory endpoints.
|
||||
|
||||
Authentication: X-Dropshipping-Key header (per-tenant).
|
||||
Optional: X-Tenant-Subdomain for faster resolution.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services import dropshipping_service as ds_svc
|
||||
from services.webhook_service import dispatch_webhooks_bulk
|
||||
|
||||
dropship_bp = Blueprint("dropship", __name__, url_prefix="/pos/api/dropship")
|
||||
|
||||
|
||||
def _resolve_tenant_by_key(api_key: str, subdomain_hint: str = None):
|
||||
"""Return (tenant_conn, tenant_id) for a valid dropshipping API key.
|
||||
|
||||
If subdomain_hint is provided, validate only that tenant.
|
||||
Otherwise scan active tenants (acceptable for small tenant count).
|
||||
"""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
cur = master.cursor()
|
||||
if subdomain_hint:
|
||||
cur.execute(
|
||||
"SELECT id, db_name FROM tenants WHERE subdomain = %s AND is_active = true",
|
||||
(subdomain_hint,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
else:
|
||||
cur.execute("SELECT id, db_name FROM tenants WHERE is_active = true")
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
for tid, db_name in rows:
|
||||
try:
|
||||
tconn = get_tenant_conn(tid)
|
||||
if ds_svc.validate_api_key(tconn, api_key):
|
||||
return tconn, tid
|
||||
tconn.close()
|
||||
except Exception:
|
||||
continue
|
||||
return None, None
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
def _require_dropship_auth():
|
||||
key = request.headers.get("X-Dropshipping-Key")
|
||||
subdomain = request.headers.get("X-Tenant-Subdomain")
|
||||
if not key:
|
||||
return jsonify({"error": "Missing X-Dropshipping-Key header"}), 401
|
||||
tconn, tid = _resolve_tenant_by_key(key, subdomain_hint=subdomain)
|
||||
if not tconn:
|
||||
return jsonify({"error": "Invalid API key or tenant inactive"}), 401
|
||||
g.tenant_id = tid
|
||||
g.tenant_conn = tconn
|
||||
return None
|
||||
|
||||
|
||||
def _release_tenant():
|
||||
if hasattr(g, "tenant_conn") and g.tenant_conn:
|
||||
g.tenant_conn.close()
|
||||
|
||||
|
||||
@dropship_bp.route("/inventory", methods=["GET"])
|
||||
def list_inventory():
|
||||
err = _require_dropship_auth()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 200)
|
||||
search = request.args.get("q")
|
||||
result = ds_svc.get_inventory_list(g.tenant_conn, search=search, page=page, per_page=per_page)
|
||||
return jsonify(result)
|
||||
finally:
|
||||
_release_tenant()
|
||||
|
||||
|
||||
@dropship_bp.route("/inventory/<sku>", methods=["GET"])
|
||||
def get_inventory_item(sku):
|
||||
err = _require_dropship_auth()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
item = ds_svc.get_inventory_by_sku(g.tenant_conn, sku)
|
||||
if not item:
|
||||
return jsonify({"error": "SKU not found"}), 404
|
||||
return jsonify(item)
|
||||
finally:
|
||||
_release_tenant()
|
||||
|
||||
|
||||
@dropship_bp.route("/stock", methods=["GET"])
|
||||
def get_stock():
|
||||
err = _require_dropship_auth()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
skus = request.args.get("skus", "")
|
||||
sku_list = [s.strip() for s in skus.split(",") if s.strip()]
|
||||
if not sku_list:
|
||||
return jsonify({"error": "Provide ?skus=SKU1,SKU2,SKU3"}), 400
|
||||
result = ds_svc.get_stock_by_skus(g.tenant_conn, sku_list)
|
||||
return jsonify({"stock": result})
|
||||
finally:
|
||||
_release_tenant()
|
||||
|
||||
|
||||
@dropship_bp.route("/webhooks/test", methods=["POST"])
|
||||
def test_webhook():
|
||||
"""Test endpoint to trigger a sample webhook to all configured targets."""
|
||||
err = _require_dropship_auth()
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
urls = ds_svc.get_webhook_targets(g.tenant_conn, "stock_updated")
|
||||
if not urls:
|
||||
return jsonify({"error": "No webhook targets configured"}), 400
|
||||
results = dispatch_webhooks_bulk(
|
||||
urls,
|
||||
"test",
|
||||
{"message": "Webhook test from Nexus POS", "tenant_id": g.tenant_id},
|
||||
)
|
||||
return jsonify({"dispatched": len(results), "results": results})
|
||||
finally:
|
||||
_release_tenant()
|
||||
@@ -4,6 +4,7 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import csv
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
@@ -46,6 +47,24 @@ def _apply_tier_discounts(price_1, discounts):
|
||||
return p2, p3
|
||||
|
||||
|
||||
def _to_decimal(val, default=0):
|
||||
if val is None or val == '':
|
||||
return default
|
||||
try:
|
||||
return float(str(val).replace(',', ''))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _to_int(val, default=0):
|
||||
if val is None or val == '':
|
||||
return default
|
||||
try:
|
||||
return int(float(str(val).replace(',', '')))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
# ─── AI Classification ───────────────────────────
|
||||
|
||||
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
|
||||
@@ -203,9 +222,10 @@ def get_item(item_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT i.*, b.name as branch_name
|
||||
SELECT i.*, b.name as branch_name, c.name as category_name
|
||||
FROM inventory i
|
||||
LEFT JOIN branches b ON i.branch_id = b.id
|
||||
LEFT JOIN categories c ON i.category_id = c.id
|
||||
WHERE i.id = %s
|
||||
""", (item_id,))
|
||||
row = cur.fetchone()
|
||||
@@ -309,6 +329,23 @@ def create_item():
|
||||
if initial_stock > 0:
|
||||
record_initial(conn, item_id, branch_id, initial_stock, data.get('cost'))
|
||||
|
||||
# Insert SKU aliases if provided
|
||||
sku_aliases = data.get('sku_aliases', [])
|
||||
if sku_aliases:
|
||||
for alias in sku_aliases:
|
||||
sku = (alias.get('sku') or '').strip()
|
||||
label = (alias.get('label') or '').strip()
|
||||
if sku:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (inventory_id, sku) DO UPDATE SET
|
||||
is_active = true, label = EXCLUDED.label
|
||||
""",
|
||||
(item_id, sku, label or None),
|
||||
)
|
||||
|
||||
log_action(conn, 'INVENTORY_CREATE', 'inventory', item_id,
|
||||
new_value={'part_number': data['part_number'], 'name': data['name'], 'initial_stock': initial_stock})
|
||||
|
||||
@@ -333,13 +370,19 @@ def create_item():
|
||||
if not compat_background:
|
||||
# Fallback: synchronous processing
|
||||
if compat_source in ('tecdoc', 'both'):
|
||||
master = None
|
||||
try:
|
||||
master = get_master_conn()
|
||||
auto_match_vehicle_compatibility(master, conn, item_id, data['part_number'],
|
||||
brand=data.get('brand'), name=data.get('name'))
|
||||
master.close()
|
||||
except Exception as am_err:
|
||||
print(f"[auto_match] Error for item {item_id}: {am_err}")
|
||||
finally:
|
||||
if master:
|
||||
try:
|
||||
master.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if compat_source in ('qwen', 'both'):
|
||||
try:
|
||||
@@ -371,6 +414,291 @@ def create_item():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@inventory_bp.route('/items/bulk-import', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def bulk_import_items():
|
||||
"""
|
||||
Bulk import inventory items with optional vehicle compatibility.
|
||||
Expects multipart/form-data with a 'file' (CSV/Excel) or JSON body.
|
||||
Headers:
|
||||
X-Import-Mode: 'strict' (default) aborts on first error; 'lenient' skips bad rows.
|
||||
X-Import-Strategy: 'qwen' (default) auto-generates missing compat via QWEN;
|
||||
'skip' ignores missing compat; 'reject' requires all compat.
|
||||
Expected CSV columns (case-insensitive):
|
||||
sku/part_number, name, brand, price, stock, cost,
|
||||
location, description, category, make, model, year, engine, engine_code
|
||||
Optional compat columns: make, model, year, engine, engine_code
|
||||
"""
|
||||
from services.qwen_fitment import get_vehicle_fitment
|
||||
from services.inventory_vehicle_compat import save_qwen_fitment
|
||||
import services.inventory_vehicle_compat as ivc_service
|
||||
|
||||
mode = request.headers.get('X-Import-Mode', 'strict').lower()
|
||||
strategy = request.headers.get('X-Import-Strategy', 'qwen').lower()
|
||||
errors = []
|
||||
warnings = []
|
||||
created_ids = []
|
||||
skipped = 0
|
||||
created = 0
|
||||
|
||||
# ---------- 1. Parse input ----------
|
||||
rows = []
|
||||
if request.content_type and 'multipart/form-data' in request.content_type:
|
||||
file = request.files.get('file')
|
||||
if not file:
|
||||
return jsonify({'error': 'No file uploaded'}), 400
|
||||
try:
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext == '.csv':
|
||||
stream = io.TextIOWrapper(file.stream, encoding='utf-8-sig')
|
||||
reader = csv.DictReader(stream)
|
||||
rows = list(reader)
|
||||
elif ext in ('.xls', '.xlsx', '.xlsm'):
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
return jsonify({'error': 'Excel support requires openpyxl. Please convert to CSV or install openpyxl.'}), 400
|
||||
wb = openpyxl.load_workbook(file.stream, data_only=True)
|
||||
ws = wb.active
|
||||
headers = [str(c).strip().lower().replace(' ', '_') if c else '' for c in next(ws.iter_rows(values_only=True))]
|
||||
for raw in ws.iter_rows(min_row=2, values_only=True):
|
||||
rows.append({headers[i]: (str(v) if v is not None else '') for i, v in enumerate(raw) if i < len(headers)})
|
||||
else:
|
||||
return jsonify({'error': 'Unsupported file type. Use CSV or Excel.'}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to parse file: {e}'}), 400
|
||||
else:
|
||||
body = request.get_json() or {}
|
||||
rows = body.get('items')
|
||||
if not rows or not isinstance(rows, list):
|
||||
return jsonify({'error': 'Expected JSON body with an "items" array'}), 400
|
||||
|
||||
if not rows:
|
||||
return jsonify({'error': 'No data rows found'}), 400
|
||||
|
||||
# Normalise column names on first row
|
||||
if rows:
|
||||
first = rows[0]
|
||||
normalised_keys = {}
|
||||
for k in list(first.keys()):
|
||||
nk = str(k).strip().lower().replace(' ', '_')
|
||||
normalised_keys[k] = nk
|
||||
for r in rows:
|
||||
for old_k, new_k in normalised_keys.items():
|
||||
if old_k in r:
|
||||
r[new_k] = r.pop(old_k)
|
||||
|
||||
# Map common synonyms
|
||||
col_map = {
|
||||
'sku': 'part_number', 'numero_de_parte': 'part_number', 'parte': 'part_number',
|
||||
'nombre': 'name', 'producto': 'name', 'descripcion': 'name',
|
||||
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
||||
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
||||
'ubicacion': 'location', 'categoria': 'category',
|
||||
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
||||
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
||||
'motor': 'engine', 'codigo_motor': 'engine_code',
|
||||
}
|
||||
for r in rows:
|
||||
for old_k, new_k in col_map.items():
|
||||
if old_k in r and new_k not in r:
|
||||
r[new_k] = r.pop(old_k)
|
||||
|
||||
required = ['part_number', 'name']
|
||||
first_keys = set(rows[0].keys()) if rows else set()
|
||||
missing_required = [c for c in required if c not in first_keys]
|
||||
if missing_required:
|
||||
return jsonify({'error': f'Missing required columns: {missing_required}'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
from services.barcode_generator import generate_barcode
|
||||
from tenant_db import get_master_conn
|
||||
from services.inventory_engine import record_initial
|
||||
|
||||
# Pre-fetch tenant db_name for barcode generation
|
||||
mconn = get_master_conn()
|
||||
mcur = mconn.cursor()
|
||||
mcur.execute("SELECT db_name FROM tenants WHERE id = %s", (g.tenant_id,))
|
||||
db_name_row = mcur.fetchone()
|
||||
db_name = db_name_row[0] if db_name_row else None
|
||||
mcur.close(); mconn.close()
|
||||
|
||||
for row_num, row in enumerate(rows, start=1):
|
||||
part_number = str(row.get('part_number', '')).strip()
|
||||
name = str(row.get('name', '')).strip()
|
||||
if not part_number or not name:
|
||||
msg = f'Row {row_num}: part_number and name are required'
|
||||
if mode == 'strict':
|
||||
conn.rollback(); cur.close(); conn.close()
|
||||
return jsonify({'error': msg}), 400
|
||||
warnings.append(msg)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
branch_id = _to_int(row.get('branch_id'), g.branch_id)
|
||||
if not branch_id:
|
||||
msg = f'Row {row_num}: branch_id required (not set in row or session)'
|
||||
if mode == 'strict':
|
||||
conn.rollback(); cur.close(); conn.close()
|
||||
return jsonify({'error': msg}), 400
|
||||
warnings.append(msg)
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
brand = str(row.get('brand', '')).strip()
|
||||
price_1 = _to_decimal(row.get('price'), 0)
|
||||
stock = _to_int(row.get('stock'), 0)
|
||||
cost = _to_decimal(row.get('cost'), 0)
|
||||
location = str(row.get('location', '')).strip()
|
||||
description = str(row.get('description', '')).strip()
|
||||
category = str(row.get('category', '')).strip()
|
||||
|
||||
# Check if item already exists for this branch
|
||||
cur.execute("SELECT id FROM inventory WHERE branch_id = %s AND part_number = %s", (branch_id, part_number))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
item_id = existing[0]
|
||||
# Update existing item — add stock if provided
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE inventory SET
|
||||
name = %s,
|
||||
brand = COALESCE(NULLIF(%s,''), brand),
|
||||
cost = CASE WHEN %s > 0 THEN %s ELSE cost END,
|
||||
price_1 = CASE WHEN %s > 0 THEN %s ELSE price_1 END,
|
||||
stock = stock + %s,
|
||||
location = COALESCE(NULLIF(%s,''), location),
|
||||
description = COALESCE(NULLIF(%s,''), description),
|
||||
category = COALESCE(NULLIF(%s,''), category)
|
||||
WHERE id = %s
|
||||
""",
|
||||
(name, brand, cost, cost, price_1, price_1, stock, location, description, category, item_id)
|
||||
)
|
||||
was_inserted = False
|
||||
# Record stock adjustment for existing item if stock > 0
|
||||
if stock > 0:
|
||||
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
||||
else:
|
||||
# Generate barcode for new item
|
||||
barcode = generate_barcode(conn, db_name)
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory
|
||||
(branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, unit)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(branch_id, part_number, barcode, name, brand, cost, price_1, location, description, category, 'PZA')
|
||||
)
|
||||
item_id = cur.fetchone()[0]
|
||||
was_inserted = True
|
||||
|
||||
# Record initial stock if provided and new item
|
||||
if was_inserted and stock > 0:
|
||||
record_initial(conn, item_id, branch_id, stock, cost if cost > 0 else None)
|
||||
|
||||
conn.commit()
|
||||
created_ids.append(item_id)
|
||||
created += 1
|
||||
|
||||
# ---------- 2. Vehicle compatibility ----------
|
||||
make = str(row.get('make', '')).strip()
|
||||
model = str(row.get('model', '')).strip()
|
||||
year_str = str(row.get('year', '')).strip()
|
||||
engine = str(row.get('engine', '')).strip()
|
||||
engine_code = str(row.get('engine_code', '')).strip()
|
||||
|
||||
has_compat = any([make, model, year_str, engine, engine_code])
|
||||
|
||||
if has_compat:
|
||||
# Validate / resolve against vehicle tables
|
||||
year = _to_int(year_str, None)
|
||||
mye_id = None
|
||||
if make and model and year:
|
||||
# Try exact match against model_year_engine
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT mye.id FROM model_year_engine mye
|
||||
JOIN models m ON m.id = mye.model_id
|
||||
JOIN brands b ON b.id = m.brand_id
|
||||
JOIN years y ON y.id = mye.year_id
|
||||
WHERE LOWER(b.name) = LOWER(%s)
|
||||
AND LOWER(m.name) = LOWER(%s)
|
||||
AND y.year = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(make, model, year)
|
||||
)
|
||||
r = cur.fetchone()
|
||||
if r:
|
||||
mye_id = r[0]
|
||||
else:
|
||||
warnings.append(
|
||||
f'Row {row_num}: vehicle "{make} {model} {year}" not found in catalog; '
|
||||
'saving as text-only compatibility.'
|
||||
)
|
||||
|
||||
if mye_id:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_vehicle_compat
|
||||
(inventory_id, model_year_engine_id, make, model, year, engine, engine_code)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(item_id, mye_id, make, model, year_str, engine, engine_code)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_vehicle_compat
|
||||
(inventory_id, make, model, year, engine, engine_code)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(item_id, make, model, year_str, engine, engine_code)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
else:
|
||||
# No compatibility provided
|
||||
if strategy == 'reject':
|
||||
msg = f'Row {row_num}: missing vehicle compatibility (strategy=reject)'
|
||||
if mode == 'strict':
|
||||
conn.rollback(); cur.close(); conn.close()
|
||||
return jsonify({'error': msg}), 400
|
||||
warnings.append(msg)
|
||||
elif strategy == 'qwen':
|
||||
try:
|
||||
fitment = get_vehicle_fitment(part_number, name, brand)
|
||||
save_qwen_fitment(conn, item_id, fitment)
|
||||
conn.commit()
|
||||
except Exception as qe:
|
||||
warnings.append(f'Row {row_num}: QWEN fitment failed: {qe}')
|
||||
# strategy == 'skip' → do nothing
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'created': created,
|
||||
'skipped': skipped,
|
||||
'item_ids': created_ids,
|
||||
'warnings': warnings,
|
||||
'errors': errors,
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
try: cur.close()
|
||||
except Exception: pass
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@require_auth('inventory.edit')
|
||||
def update_item(item_id):
|
||||
@@ -1371,22 +1699,6 @@ def report_branch_comparison():
|
||||
|
||||
# ─── Categories and Brands ─────────────────────
|
||||
|
||||
@inventory_bp.route('/categories', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def list_categories():
|
||||
"""Get distinct categories from inventory."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT category_id FROM inventory
|
||||
WHERE is_active = true AND category_id IS NOT NULL
|
||||
ORDER BY category_id
|
||||
""")
|
||||
categories = [r[0] for r in cur.fetchall()]
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': categories})
|
||||
|
||||
|
||||
@inventory_bp.route('/brands', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def list_brands():
|
||||
@@ -1718,6 +2030,89 @@ def auto_match_item_vehicles(item_id):
|
||||
return jsonify({'error': 'No compatibility source configured'}), 400
|
||||
|
||||
|
||||
# ─── SKU Aliases (multiple part numbers per item) ───────────────────────
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/skus', methods=['GET'])
|
||||
@require_auth('inventory.view')
|
||||
def get_item_sku_aliases(item_id):
|
||||
"""Return active SKU aliases for an inventory item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, sku, label, created_at
|
||||
FROM inventory_sku_aliases
|
||||
WHERE inventory_id = %s AND is_active = true
|
||||
ORDER BY created_at
|
||||
""",
|
||||
(item_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return jsonify({
|
||||
'aliases': [
|
||||
{'id': r[0], 'sku': r[1], 'label': r[2], 'created_at': r[3]}
|
||||
for r in rows
|
||||
]
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/skus', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def add_item_sku_alias(item_id):
|
||||
"""Add an SKU alias to an inventory item."""
|
||||
data = request.get_json() or {}
|
||||
sku = (data.get('sku') or '').strip()
|
||||
label = (data.get('label') or '').strip()
|
||||
if not sku:
|
||||
return jsonify({'error': 'sku is required'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT (inventory_id, sku) DO UPDATE SET
|
||||
is_active = true, label = EXCLUDED.label
|
||||
RETURNING id
|
||||
""",
|
||||
(item_id, sku, label or None),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return jsonify({'id': row[0], 'message': 'SKU alias added'}), 201
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/skus/<int:alias_id>', methods=['DELETE'])
|
||||
@require_auth('inventory.edit')
|
||||
def delete_item_sku_alias(item_id, alias_id):
|
||||
"""Soft-delete an SKU alias."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE inventory_sku_aliases SET is_active = false WHERE id = %s AND inventory_id = %s
|
||||
""",
|
||||
(alias_id, item_id),
|
||||
)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return jsonify({'message': 'SKU alias removed'})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/mye/search', methods=['GET'])
|
||||
@require_auth()
|
||||
def search_mye_endpoint():
|
||||
@@ -1735,6 +2130,178 @@ def search_mye_endpoint():
|
||||
master.close()
|
||||
|
||||
|
||||
# ─── Manual Vehicle Compatibility (text-based) ────────────────────────────
|
||||
|
||||
@inventory_bp.route('/items/<int:item_id>/vehicles/manual', methods=['POST'])
|
||||
@require_auth('inventory.edit')
|
||||
def add_manual_vehicle_compat(item_id):
|
||||
"""Add a manual vehicle compatibility using free-text fields."""
|
||||
data = request.get_json() or {}
|
||||
make = (data.get('make') or '').strip()
|
||||
model = (data.get('model') or '').strip()
|
||||
year = data.get('year')
|
||||
engine = (data.get('engine') or '').strip()
|
||||
engine_code = (data.get('engine_code') or '').strip()
|
||||
|
||||
if not make or not model or not year:
|
||||
return jsonify({'error': 'make, model and year are required'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_vehicle_compat
|
||||
(inventory_id, make, model, year, engine, engine_code, source, model_year_engine_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, 'manual', NULL)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(item_id, make, model, year, engine or None, engine_code or None),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
cur.close()
|
||||
if not row:
|
||||
return jsonify({'error': 'Compatibility already exists or item not found'}), 409
|
||||
return jsonify({'id': row[0], 'message': 'Compatibility added'}), 201
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/vehicles/makes', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_vehicle_makes():
|
||||
"""Return distinct vehicle makes from master DB."""
|
||||
master = get_master_conn()
|
||||
try:
|
||||
cur = master.cursor()
|
||||
cur.execute("SELECT id_brand, name_brand FROM brands ORDER BY name_brand")
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return jsonify({'makes': [{'id': r[0], 'name': r[1]} for r in rows]})
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/vehicles/models', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_vehicle_models():
|
||||
"""Return models for a given brand."""
|
||||
brand_id = request.args.get('brand_id', type=int)
|
||||
if not brand_id:
|
||||
return jsonify({'error': 'brand_id required'}), 400
|
||||
master = get_master_conn()
|
||||
try:
|
||||
cur = master.cursor()
|
||||
cur.execute(
|
||||
"SELECT id_model, name_model FROM models WHERE brand_id = %s ORDER BY name_model",
|
||||
(brand_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return jsonify({'models': [{'id': r[0], 'name': r[1]} for r in rows]})
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/vehicles/years', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_vehicle_years():
|
||||
"""Return distinct years available for a model."""
|
||||
model_id = request.args.get('model_id', type=int)
|
||||
if not model_id:
|
||||
return jsonify({'error': 'model_id required'}), 400
|
||||
master = get_master_conn()
|
||||
try:
|
||||
cur = master.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT y.id_year, y.year_car
|
||||
FROM model_year_engine mye
|
||||
JOIN years y ON y.id_year = mye.year_id
|
||||
WHERE mye.model_id = %s
|
||||
ORDER BY y.year_car DESC
|
||||
""",
|
||||
(model_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return jsonify({'years': [{'id': r[0], 'year': r[1]} for r in rows]})
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/vehicles/engines', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_vehicle_engines():
|
||||
"""Return engines available for a model+year."""
|
||||
model_id = request.args.get('model_id', type=int)
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
if not model_id or not year_id:
|
||||
return jsonify({'error': 'model_id and year_id required'}), 400
|
||||
master = get_master_conn()
|
||||
try:
|
||||
cur = master.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT DISTINCT e.id_engine, e.name_engine, e.engine_code
|
||||
FROM model_year_engine mye
|
||||
JOIN engines e ON e.id_engine = mye.engine_id
|
||||
WHERE mye.model_id = %s AND mye.year_id = %s
|
||||
ORDER BY e.name_engine
|
||||
""",
|
||||
(model_id, year_id),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return jsonify({'engines': [{'id': r[0], 'name': r[1], 'code': r[2]} for r in rows]})
|
||||
finally:
|
||||
master.close()
|
||||
|
||||
|
||||
# ─── Categories ──────────────────────────────────
|
||||
|
||||
@inventory_bp.route('/categories', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_inventory_categories():
|
||||
"""Return active categories (root only). Optional ?parent_id= for subcategories."""
|
||||
parent_id = request.args.get('parent_id')
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
if parent_id:
|
||||
cur.execute(
|
||||
"SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name",
|
||||
(parent_id,)
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT id, name FROM categories WHERE parent_id IS NULL AND is_active = true ORDER BY name"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'categories': [{'id': r[0], 'name': r[1]} for r in rows]})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@inventory_bp.route('/categories/<int:category_id>/subcategories', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_inventory_subcategories(category_id):
|
||||
"""Return subcategories for a given category."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, name FROM categories WHERE parent_id = %s AND is_active = true ORDER BY name",
|
||||
(category_id,)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return jsonify({'subcategories': [{'id': r[0], 'name': r[1]} for r in rows]})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Global Tier Discounts ───────────────────────
|
||||
|
||||
@inventory_bp.route('/tier-discounts', methods=['GET'])
|
||||
|
||||
@@ -28,6 +28,23 @@ from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services import marketplace_external_service as meli_svc
|
||||
|
||||
|
||||
def _get_public_base_url() -> str:
|
||||
"""Build the tenant's public base URL from request headers (handles reverse proxy)."""
|
||||
proto = request.headers.get("X-Forwarded-Proto", request.scheme)
|
||||
host = request.headers.get("X-Forwarded-Host", request.host)
|
||||
|
||||
# Cloudflare specific header
|
||||
cf_visitor = request.headers.get("CF-Visitor")
|
||||
if cf_visitor and '"scheme":"https"' in cf_visitor:
|
||||
proto = "https"
|
||||
|
||||
# Force https for production domain if we detect http behind a TLS terminator
|
||||
if proto == "http" and ("nexusautoparts.com.mx" in host or request.headers.get("X-Forwarded-Ssl") == "on"):
|
||||
proto = "https"
|
||||
|
||||
return f"{proto}://{host}/"
|
||||
from services.meli_service import MeliService, MeliAuthError
|
||||
|
||||
marketplace_ext_bp = Blueprint(
|
||||
@@ -148,6 +165,10 @@ def search_categories():
|
||||
return jsonify({"error": "MercadoLibre not connected"}), 400
|
||||
result = svc.search_categories(site_id, q)
|
||||
return jsonify({"categories": result})
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -167,6 +188,10 @@ def list_listings():
|
||||
try:
|
||||
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
|
||||
return jsonify(result)
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -199,6 +224,7 @@ def create_listings():
|
||||
listing_type_id=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
@@ -220,7 +246,7 @@ def inventory_check():
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.check_inventory_ml_status(conn, inventory_ids)
|
||||
result = meli_svc.check_inventory_ml_status(conn, inventory_ids, base_url=_get_public_base_url())
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
@@ -242,6 +268,8 @@ def category_attributes(category_id):
|
||||
# Filter to required attributes only for the UI
|
||||
required = [a for a in attrs if a.get("tags", {}).get("required")]
|
||||
return jsonify({"attributes": required, "all": attrs})
|
||||
except MeliAuthError:
|
||||
return jsonify({"error": "MercadoLibre token expired. Please reconnect."}), 401
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
@@ -277,6 +305,7 @@ def validate_listings():
|
||||
listing_type_id=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
@@ -316,6 +345,7 @@ def create_listings_async():
|
||||
listing_type=listing_type,
|
||||
shipping_mode=shipping_mode,
|
||||
custom_data=custom_data,
|
||||
base_url=_get_public_base_url(),
|
||||
)
|
||||
return jsonify({"task_id": task.id, "status": "queued"}), 202
|
||||
except Exception as e:
|
||||
@@ -413,6 +443,85 @@ def delete_listing(listing_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/listings/<int:listing_id>/permanent", methods=["DELETE"])
|
||||
@require_auth()
|
||||
def delete_listing_permanent(listing_id):
|
||||
"""Hard-delete a closed listing from the local DB."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.delete_listing_permanently(conn, listing_id)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# QUESTIONS & ANSWERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_ext_bp.route("/questions", methods=["GET"])
|
||||
@require_auth()
|
||||
def list_questions():
|
||||
"""List questions from local DB. Query param: ?status=unanswered"""
|
||||
status = request.args.get("status")
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
items = meli_svc.list_local_questions(conn, status=status)
|
||||
return jsonify({"items": items})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/questions/sync", methods=["POST"])
|
||||
@require_auth()
|
||||
def sync_questions():
|
||||
"""Force sync questions from ML for all active listings."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.sync_questions(conn)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_ext_bp.route("/questions/<int:question_id>/answer", methods=["POST"])
|
||||
@require_auth()
|
||||
def answer_question(question_id):
|
||||
"""Answer a buyer question via ML API."""
|
||||
err = _require_meli_manage()
|
||||
if err:
|
||||
return err
|
||||
data = request.get_json() or {}
|
||||
text = data.get("text", "").strip()
|
||||
if not text:
|
||||
return jsonify({"error": "Answer text is required"}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = meli_svc.answer_question(conn, question_id, text)
|
||||
return jsonify(result)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
278
pos/blueprints/supplier_catalog_bp.py
Normal file
278
pos/blueprints/supplier_catalog_bp.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Supplier Catalog Blueprint — parts from suppliers with vehicle compatibility.
|
||||
|
||||
Independent from inventory. Supports:
|
||||
- Browse by supplier/category
|
||||
- Search by text or vehicle (MYE or make/model/year)
|
||||
- Part detail with compatibilities and interchanges
|
||||
- Bulk import via Excel
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g, render_template
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from tenant_db import get_master_conn
|
||||
from middleware import require_auth
|
||||
|
||||
supplier_catalog_bp = Blueprint('supplier_catalog', __name__, url_prefix='/pos/api/supplier-catalog')
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_master_conn():
|
||||
return get_master_conn()
|
||||
|
||||
|
||||
def _json_response(data, status=200):
|
||||
return jsonify(data), status
|
||||
|
||||
|
||||
# ─── Brands ────────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/brands', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_brands():
|
||||
"""Return distinct makes (vehicle brands) present in the supplier catalog."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT make, COUNT(*) as cnt
|
||||
FROM supplier_catalog_compat
|
||||
WHERE make IS NOT NULL AND make != ''
|
||||
GROUP BY make
|
||||
ORDER BY make ASC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'brands': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||
|
||||
|
||||
# ─── Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/search', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def search_items():
|
||||
"""Search supplier catalog by text and/or vehicle."""
|
||||
q = (request.args.get('q') or '').strip()
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
make = (request.args.get('make') or '').strip()
|
||||
model = (request.args.get('model') or '').strip()
|
||||
year = request.args.get('year', type=int)
|
||||
supplier = (request.args.get('supplier') or '').strip()
|
||||
category = (request.args.get('category') or '').strip()
|
||||
page = max(1, request.args.get('page', 1, type=int))
|
||||
per_page = min(100, request.args.get('per_page', 30, type=int))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Build query dynamically
|
||||
where_parts = ["sc.is_active = true"]
|
||||
params = []
|
||||
|
||||
if supplier:
|
||||
where_parts.append("sc.supplier_name = %s")
|
||||
params.append(supplier)
|
||||
if category:
|
||||
where_parts.append("sc.category = %s")
|
||||
params.append(category)
|
||||
|
||||
# Text search on SKU, name, or interchange part_number
|
||||
if q:
|
||||
where_parts.append("""
|
||||
(sc.sku ILIKE %s OR sc.name ILIKE %s
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM supplier_catalog_interchange sci2
|
||||
WHERE sci2.catalog_id = sc.id AND sci2.part_number ILIKE %s
|
||||
))
|
||||
""")
|
||||
like_q = f'%{q}%'
|
||||
params.extend([like_q, like_q, like_q])
|
||||
|
||||
# Vehicle filter
|
||||
vehicle_join = ""
|
||||
if mye_id:
|
||||
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
|
||||
where_parts.append("scc.model_year_engine_id = %s")
|
||||
params.append(mye_id)
|
||||
elif make or model or year:
|
||||
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
|
||||
if make:
|
||||
where_parts.append("scc.make ILIKE %s")
|
||||
params.append(f'%{make}%')
|
||||
if model:
|
||||
where_parts.append("scc.model ILIKE %s")
|
||||
params.append(f'%{model}%')
|
||||
if year:
|
||||
where_parts.append("scc.year = %s")
|
||||
params.append(year)
|
||||
|
||||
where_sql = " AND ".join(where_parts)
|
||||
|
||||
# Count total
|
||||
count_sql = f"""
|
||||
SELECT COUNT(DISTINCT sc.id)
|
||||
FROM supplier_catalog sc
|
||||
{vehicle_join}
|
||||
WHERE {where_sql}
|
||||
"""
|
||||
cur.execute(count_sql, params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Fetch page
|
||||
fetch_sql = f"""
|
||||
SELECT DISTINCT
|
||||
sc.id, sc.supplier_name, sc.sku, sc.name,
|
||||
sc.category, sc.description, sc.image_url
|
||||
FROM supplier_catalog sc
|
||||
{vehicle_join}
|
||||
WHERE {where_sql}
|
||||
ORDER BY sc.name ASC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cur.execute(fetch_sql, params + [per_page, offset])
|
||||
rows = cur.fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
'id': r[0],
|
||||
'supplier_name': r[1],
|
||||
'sku': r[2],
|
||||
'name': r[3],
|
||||
'category': r[4],
|
||||
'description': r[5],
|
||||
'image_url': r[6],
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'data': items,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': total,
|
||||
'total_pages': (total + per_page - 1) // per_page,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# ─── Item Detail ───────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def get_item_detail(item_id):
|
||||
"""Return full detail for a supplier catalog item including compat + interchanges."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, supplier_name, sku, name, category, description, image_url, created_at
|
||||
FROM supplier_catalog WHERE id = %s AND is_active = true
|
||||
""", (item_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
item = {
|
||||
'id': row[0],
|
||||
'supplier_name': row[1],
|
||||
'sku': row[2],
|
||||
'name': row[3],
|
||||
'category': row[4],
|
||||
'description': row[5],
|
||||
'image_url': row[6],
|
||||
'created_at': str(row[7]) if row[7] else None,
|
||||
}
|
||||
|
||||
# Compatibilities — deduplicate by (make, model, year, engine) because
|
||||
# the same vehicle may map to multiple MYE ids (especially when engine
|
||||
# text is empty from the supplier catalog).
|
||||
cur.execute("""
|
||||
SELECT make, model, year, engine, model_year_engine_id, source
|
||||
FROM supplier_catalog_compat
|
||||
WHERE catalog_id = %s
|
||||
ORDER BY make, model, year, engine
|
||||
""", (item_id,))
|
||||
seen_compat = set()
|
||||
compatibilities = []
|
||||
for r in cur.fetchall():
|
||||
key = (r[0], r[1], r[2], r[3])
|
||||
if key in seen_compat:
|
||||
continue
|
||||
seen_compat.add(key)
|
||||
compatibilities.append({
|
||||
'make': r[0], 'model': r[1], 'year': r[2], 'engine': r[3],
|
||||
'model_year_engine_id': r[4], 'source': r[5]
|
||||
})
|
||||
item['compatibilities'] = compatibilities
|
||||
|
||||
# Interchanges
|
||||
cur.execute("""
|
||||
SELECT brand, part_number
|
||||
FROM supplier_catalog_interchange
|
||||
WHERE catalog_id = %s
|
||||
ORDER BY brand, part_number
|
||||
""", (item_id,))
|
||||
item['interchanges'] = [
|
||||
{'brand': r[0], 'part_number': r[1]}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify(item)
|
||||
|
||||
|
||||
# ─── Categories ────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/categories', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_categories():
|
||||
"""Return distinct categories with counts."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT category, COUNT(*) as cnt
|
||||
FROM supplier_catalog
|
||||
WHERE is_active = true
|
||||
GROUP BY category
|
||||
ORDER BY cnt DESC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'categories': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||
|
||||
|
||||
# ─── Suppliers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/suppliers', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_suppliers():
|
||||
"""Return distinct suppliers with counts."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT supplier_name, COUNT(*) as cnt
|
||||
FROM supplier_catalog
|
||||
WHERE is_active = true
|
||||
GROUP BY supplier_name
|
||||
ORDER BY supplier_name ASC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'suppliers': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||
|
||||
|
||||
# ─── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('inventory.edit')
|
||||
def delete_item(item_id):
|
||||
"""Soft-delete a supplier catalog item."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE supplier_catalog SET is_active = false WHERE id = %s", (item_id,))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'success': True})
|
||||
@@ -12,6 +12,7 @@ supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_tenant_conn
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
@@ -26,48 +27,47 @@ class DecimalEncoder(json.JSONEncoder):
|
||||
def get_demand():
|
||||
"""Aggregated demand by zone, part group, and time range."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
group_id = request.args.get('group_id', type=int)
|
||||
branch_id = request.args.get('branch_id', type=int)
|
||||
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
params = [since]
|
||||
filters = "s.created_at >= %s"
|
||||
if group_id:
|
||||
filters += " AND p.group_id = %s"
|
||||
params.append(group_id)
|
||||
if branch_id:
|
||||
filters += " AND s.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
try:
|
||||
params = [since]
|
||||
filters = "s.created_at >= %s"
|
||||
if branch_id:
|
||||
filters += " AND s.branch_id = %s"
|
||||
params.append(branch_id)
|
||||
|
||||
rows = db.execute(
|
||||
f"""SELECT g.name as group_name, b.name as branch_name,
|
||||
COUNT(DISTINCT s.id_sale) as orders,
|
||||
SUM(si.quantity) as qty_requested,
|
||||
COALESCE(SUM(si.total), 0) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
JOIN part_groups g ON p.group_id = g.id_group
|
||||
LEFT JOIN branches b ON s.branch_id = b.id_branch
|
||||
WHERE {filters}
|
||||
GROUP BY g.name, b.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 100""", tuple(params)
|
||||
).fetchall()
|
||||
cur.execute(
|
||||
f"""SELECT b.name as branch_name,
|
||||
COUNT(DISTINCT s.id) as orders,
|
||||
SUM(si.quantity) as qty_requested,
|
||||
COALESCE(SUM(si.subtotal), 0) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
LEFT JOIN branches b ON s.branch_id = b.id
|
||||
WHERE {filters}
|
||||
GROUP BY b.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 100""", tuple(params)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'days': days,
|
||||
'demand': [
|
||||
{'group': row['group_name'], 'branch': row['branch_name'],
|
||||
'orders': row['orders'], 'quantity': row['qty_requested'],
|
||||
'revenue': row['revenue']}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'days': days,
|
||||
'demand': [
|
||||
{'branch': row[0] or 'Sin sucursal',
|
||||
'orders': row[1], 'quantity': row[2],
|
||||
'revenue': float(row[3]) if row[3] is not None else 0}
|
||||
for row in rows
|
||||
]
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
@supplier_portal_bp.route('/top-parts', methods=['GET'])
|
||||
@@ -75,31 +75,31 @@ def get_demand():
|
||||
def get_top_parts():
|
||||
"""Top moving parts for suppliers to restock."""
|
||||
days = request.args.get('days', 30, type=int)
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
rows = db.execute(
|
||||
"""SELECT p.oem_part_number, p.name, g.name as group_name,
|
||||
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue,
|
||||
COALESCE(SUM(wi.stock_quantity), 0) as current_stock
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
JOIN part_groups g ON p.group_id = g.id_group
|
||||
LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id
|
||||
WHERE s.created_at >= %s
|
||||
GROUP BY p.oem_part_number, p.name, g.name
|
||||
ORDER BY sold DESC
|
||||
LIMIT 50""", (since,)
|
||||
).fetchall()
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT si.part_number, si.name,
|
||||
SUM(si.quantity) as sold, COALESCE(SUM(si.subtotal), 0) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id
|
||||
WHERE s.created_at >= %s
|
||||
GROUP BY si.part_number, si.name
|
||||
ORDER BY sold DESC
|
||||
LIMIT 50""", (since,)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'parts': [
|
||||
{'oem': row['oem_part_number'], 'name': row['name'],
|
||||
'group': row['group_name'], 'sold': row['sold'],
|
||||
'revenue': row['revenue'], 'stock': row['current_stock']}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
return jsonify({
|
||||
'since': since.isoformat(),
|
||||
'parts': [
|
||||
{'part_number': row[0], 'name': row[1],
|
||||
'sold': row[2], 'revenue': float(row[3]) if row[3] is not None else 0}
|
||||
for row in rows
|
||||
]
|
||||
})
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -386,3 +386,4 @@ CREATE TABLE IF NOT EXISTS tenant_config (
|
||||
|
||||
-- Barcode sequence
|
||||
CREATE SEQUENCE IF NOT EXISTS barcode_seq START 1;
|
||||
|
||||
|
||||
30
pos/migrations/v3.5_meli_questions.sql
Normal file
30
pos/migrations/v3.5_meli_questions.sql
Normal 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);
|
||||
18
pos/migrations/v3.6_dropshipping.sql
Normal file
18
pos/migrations/v3.6_dropshipping.sql
Normal 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;
|
||||
22
pos/migrations/v3.7_sku_aliases.sql
Normal file
22
pos/migrations/v3.7_sku_aliases.sql
Normal 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;
|
||||
63
pos/migrations/v3.8_supplier_catalog.sql
Normal file
63
pos/migrations/v3.8_supplier_catalog.sql
Normal 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);
|
||||
157
pos/services/catalog_import_service.py
Normal file
157
pos/services/catalog_import_service.py
Normal 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
168
pos/services/dropshipping_service.py
Normal file
168
pos/services/dropshipping_service.py
Normal 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
|
||||
@@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
65
pos/services/webhook_service.py
Normal file
65
pos/services/webhook_service.py
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -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, '"');
|
||||
}
|
||||
|
||||
// ─── 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) + ' > ' + 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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
// =====================================================================
|
||||
|
||||
@@ -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() {
|
||||
|
||||
299
pos/static/js/supplier_catalog.js
Normal file
299
pos/static/js/supplier_catalog.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ─── 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();
|
||||
}
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
14
pos/tasks.py
14
pos/tasks.py
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')">×</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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
135
pos/templates/supplier_catalog.html
Normal file
135
pos/templates/supplier_catalog.html
Normal 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()">×</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>
|
||||
109
pos/tests/test_bulk_import.py
Normal file
109
pos/tests/test_bulk_import.py
Normal 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()
|
||||
Reference in New Issue
Block a user