Compare commits

...

2 Commits

Author SHA1 Message Date
4866823ba9 Merge branch 'main' of https://git.consultoria-as.com/consultoria-as/Autoparts-DB 2026-05-26 04:24:15 +00:00
a236187f3a feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search
- New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py
- New marketplace_external.html/js with ML management UI
- Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors
- Inventory: new .btn--meli styles, select/label CSS fixes
- WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog
- DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue
- Add Celery tasks for ML sync and webhook processing
- Sidebar: MercadoLibre navigation link
2026-05-26 04:24:07 +00:00
66 changed files with 7335 additions and 498 deletions

View File

@@ -28,6 +28,10 @@ Environment=NEXUS_SERVER_HOST=127.0.0.1
# ─── Security (CHANGE THIS) ────────────────────────────────────────────────
Environment=MANAGER_JWT_SECRET=change-me-to-a-random-64-char-hex-string
Environment=INTERNAL_API_KEY=c58db62766712e618a881dbe8de580960812e57a069ef92c9dd00e7e69158cb2
# ─── POS Internal API (for WhatsApp bridge orchestration) ──────────────────
Environment=POS_INTERNAL_URL=http://192.168.10.91:5001
# ─── Redis (optional, health check only) ───────────────────────────────────
Environment=REDIS_URL=redis://127.0.0.1:6379/0

View File

@@ -59,6 +59,9 @@ def create_app():
from blueprints.marketplace_bp import marketplace_bp
app.register_blueprint(marketplace_bp)
from blueprints.marketplace_external_bp import marketplace_ext_bp
app.register_blueprint(marketplace_ext_bp)
from blueprints.peer_bp import peer_bp
app.register_blueprint(peer_bp)
@@ -180,6 +183,14 @@ def create_app():
def pos_marketplace():
return render_template('marketplace.html')
@app.route('/pos/marketplace-external')
def pos_marketplace_external():
return render_template('marketplace_external.html')
@app.route('/pos/marketplace-external/callback')
def pos_marketplace_external_callback():
return render_template('marketplace_external.html')
@app.route('/pos/static/<path:filename>')
def pos_static(filename):
return send_from_directory('static', filename)

View File

@@ -741,3 +741,45 @@ def close_period():
cur.close()
conn.close()
return jsonify({'error': str(e)}), 500
@accounting_bp.route('/stats', methods=['GET'])
@require_auth('accounting.read')
def api_accounting_stats():
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Count asset accounts with positive balance (cuentas por cobrar)
cur.execute("""
SELECT COUNT(*) FROM (
SELECT a.id
FROM accounts a
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
WHERE a.type = 'activo' AND a.is_active = true
GROUP BY a.id
HAVING COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) > 0
) x
""")
cxc = cur.fetchone()[0] or 0
# Count liability accounts with positive balance (cuentas por pagar)
cur.execute("""
SELECT COUNT(*) FROM (
SELECT a.id
FROM accounts a
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
WHERE a.type = 'pasivo' AND a.is_active = true
GROUP BY a.id
HAVING COALESCE(SUM(l.credit), 0) - COALESCE(SUM(l.debit), 0) > 0
) x
""")
cxp = cur.fetchone()[0] or 0
cur.close()
conn.close()
return jsonify({
'cuentas_cobrar': cxc,
'cuentas_pagar': cxp,
})

View File

@@ -35,6 +35,25 @@ def _oem_blocked():
return None
def _get_allowed_brands(tenant_conn):
"""Read allowed part brands from tenant_config. Returns list or None."""
import json
cur = tenant_conn.cursor()
try:
cur.execute("SELECT value FROM tenant_config WHERE key = 'allowed_part_brands'")
row = cur.fetchone()
if row and row[0]:
try:
brands = json.loads(row[0])
if isinstance(brands, list) and brands:
return brands
except (json.JSONDecodeError, ValueError):
pass
finally:
cur.close()
return None
def _with_conns(fn):
"""Helper: open master + tenant connections, call fn, close both.
fn receives (master_conn, tenant_conn, branch_id).
@@ -71,6 +90,32 @@ def _master_only(fn):
except: pass
def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands):
"""Filter a list of part dicts to only include those with aftermarket equivalents
from allowed brands. parts_data items must have 'id_part' or 'id' key."""
if not allowed_brands or not parts_data:
return parts_data
part_ids = []
for p in parts_data:
pid = p.get('id_part') or p.get('id')
if pid is not None:
part_ids.append(pid)
if not part_ids:
return parts_data
cur = master_conn.cursor()
try:
cur.execute("""
SELECT DISTINCT ap.oem_part_id
FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = ANY(%s) AND UPPER(m.name_manufacture) = ANY(%s)
""", (part_ids, allowed_brands))
allowed_ids = {r[0] for r in cur.fetchall()}
finally:
cur.close()
return [p for p in parts_data if (p.get('id_part') or p.get('id')) in allowed_ids]
# ─── Hierarchy navigation (master DB only) ───
@catalog_bp.route('/brands', methods=['GET'])
@@ -150,13 +195,14 @@ def categories():
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):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
if mode == 'local':
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
else:
data = catalog_service.get_categories(master, mye_id)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
data = catalog_service.get_categories(master, mye_id, allowed_brands)
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
return _with_conns(_do)
@catalog_bp.route('/groups', methods=['GET'])
@@ -317,6 +363,7 @@ def parts():
return blocked
def _do(master, tenant, branch_id):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
if use_nexpart_nav:
result = catalog_service.get_parts_for_nexpart_triple(
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
@@ -330,6 +377,9 @@ def parts():
result = catalog_service.get_parts(
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
)
if allowed_brands:
result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands)
result['allowed_brands'] = allowed_brands or []
return jsonify(result)
return _with_conns(_do)
@@ -358,8 +408,11 @@ def search():
limit = request.args.get('limit', 50, type=int)
mye_id = request.args.get('mye_id', type=int)
def _do(master, tenant, branch_id):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
data = catalog_service.smart_search(master, q, tenant, branch_id, limit, mye_id)
return jsonify({'data': data})
if allowed_brands:
data = _filter_parts_by_allowed_brands(master, data, allowed_brands)
return jsonify({'data': data, 'allowed_brands': allowed_brands or []})
return _with_conns(_do)
@@ -635,10 +688,20 @@ def brand_categories():
if not brand:
return jsonify({'error': 'brand parameter required'}), 400
def _query(master):
def _query(master, tenant, branch_id):
cur = master.cursor()
try:
cur.execute("""
allowed_brands = _get_allowed_brands(tenant) if tenant else None
brand_filter = ""
params = [brand]
if allowed_brands:
brand_filter = """AND EXISTS (
SELECT 1 FROM aftermarket_parts ap2
JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id
WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s)
)"""
params.append(allowed_brands)
cur.execute(f"""
SELECT pc.id_part_category,
COALESCE(NULLIF(pc.name_es, ''), pc.name_part_category) as name,
pc.slug,
@@ -648,20 +711,22 @@ def brand_categories():
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE pvp.name_brand = %s
{brand_filter}
GROUP BY pc.id_part_category, pc.name_part_category, pc.name_es, pc.slug
ORDER BY part_count DESC
""", (brand,))
""", params)
rows = cur.fetchall()
return jsonify({
'brand': brand,
'categories': [
{'id': r[0], 'name': r[1], 'slug': r[2], 'part_count': r[3]}
for r in rows
]
],
'allowed_brands': allowed_brands or []
})
finally:
cur.close()
return _master_only(_query)
return _with_conns(_query)
@catalog_bp.route('/brand-parts', methods=['GET'])
@@ -680,21 +745,110 @@ def brand_parts():
def _query(master, tenant, branch_id):
cur = master.cursor()
try:
# Build dynamic filters
params = [brand]
allowed_brands = _get_allowed_brands(tenant) if tenant else None
cat_filter = ""
search_filter = ""
params = [brand]
if category_id:
cat_filter = "AND pc.id_part_category = %s"
params.append(category_id)
# --- Brand-filtered mode: return aftermarket parts directly ---
if allowed_brands:
am_search = ""
am_params = list(params)
if search:
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
like_term = f"%{search}%"
am_params.extend([like_term, like_term])
query_params = list(am_params)
cur.execute(f"""
SELECT DISTINCT ap.id_aftermarket_parts,
ap.part_number,
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
m.name_manufacture,
ap.price_usd,
p.id_part,
pg.id_part_group, pg.name_part_group,
pc.id_part_category, pc.name_part_category
FROM part_vehicle_preview pvp
JOIN parts p ON p.id_part = pvp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE pvp.name_brand = %s
{cat_filter}
{am_search}
AND UPPER(m.name_manufacture) = ANY(%s)
ORDER BY m.name_manufacture, ap.part_number
LIMIT %s OFFSET %s
""", query_params + [allowed_brands, limit, offset])
part_rows = cur.fetchall()
oem_ids = [r[5] for r in part_rows]
count_params = list(am_params)
cur.execute(f"""
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
FROM part_vehicle_preview pvp
JOIN parts p ON p.id_part = pvp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE pvp.name_brand = %s
{cat_filter}
{am_search}
AND UPPER(m.name_manufacture) = ANY(%s)
""", count_params + [allowed_brands])
total = cur.fetchone()[0]
local_stock = {}
if tenant and oem_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, oem_ids)
except Exception:
pass
items = []
for r in part_rows:
oem_id = r[5]
stock_info = local_stock.get(oem_id, {})
items.append({
'id': r[0],
'oem_part_number': r[1],
'name': r[2],
'manufacturer': r[3],
'price_usd': float(r[4]) if r[4] is not None else None,
'oem_id': oem_id,
'group': {'id': r[6], 'name': r[7]},
'category': {'id': r[8], 'name': r[9]},
'local_stock': stock_info.get('stock', 0),
'local_price': stock_info.get('price', None),
})
return jsonify({
'brand': brand,
'category_id': category_id,
'search': search,
'items': items,
'total': total,
'limit': limit,
'offset': offset,
'allowed_brands': allowed_brands
})
# --- Normal mode: return OEM parts ---
if search:
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
like_term = f"%{search}%"
params.extend([like_term, like_term])
# Get parts from the brand catalog
query_params = list(params)
cur.execute(f"""
SELECT DISTINCT p.id_part, p.oem_part_number,
@@ -715,7 +869,7 @@ def brand_parts():
part_rows = cur.fetchall()
part_ids = [r[0] for r in part_rows]
# Count total
count_params = list(params)
cur.execute(f"""
SELECT COUNT(DISTINCT p.id_part)
FROM part_vehicle_preview pvp
@@ -725,10 +879,9 @@ def brand_parts():
WHERE pvp.name_brand = %s
{cat_filter}
{search_filter}
""", params)
""", count_params)
total = cur.fetchone()[0]
# Enrich with local stock if available
local_stock = {}
if tenant and part_ids:
try:
@@ -759,6 +912,7 @@ def brand_parts():
'total': total,
'limit': limit,
'offset': offset,
'allowed_brands': []
})
finally:
cur.close()
@@ -784,7 +938,8 @@ def mye_parts():
def _query(master, tenant, branch_id):
cur = master.cursor()
try:
# Build dynamic filters
allowed_brands = _get_allowed_brands(tenant) if tenant else None
cat_filter = ""
search_filter = ""
params = [mye_id]
@@ -793,12 +948,103 @@ def mye_parts():
cat_filter = "AND pc.id_part_category = %s"
params.append(category_id)
# --- Brand-filtered mode: return aftermarket parts directly ---
if allowed_brands:
am_search = ""
am_params = list(params)
if search:
am_search = "AND (ap.part_number ILIKE %s OR COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) ILIKE %s)"
like_term = f"%{search}%"
am_params.extend([like_term, like_term])
# Get aftermarket parts
query_params = list(am_params)
cur.execute(f"""
SELECT DISTINCT ap.id_aftermarket_parts,
ap.part_number,
COALESCE(NULLIF(ap.name_aftermarket_parts, ''), p.name_part) as name,
m.name_manufacture,
ap.price_usd,
p.id_part,
pg.id_part_group, pg.name_part_group,
pc.id_part_category, pc.name_part_category
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE vp.model_year_engine_id = %s
{cat_filter}
{am_search}
AND UPPER(m.name_manufacture) = ANY(%s)
ORDER BY m.name_manufacture, ap.part_number
LIMIT %s OFFSET %s
""", query_params + [allowed_brands, limit, offset])
part_rows = cur.fetchall()
oem_ids = [r[5] for r in part_rows]
# Count total
count_params = list(am_params)
cur.execute(f"""
SELECT COUNT(DISTINCT ap.id_aftermarket_parts)
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
JOIN part_categories pc ON pc.id_part_category = pg.category_id
WHERE vp.model_year_engine_id = %s
{cat_filter}
{am_search}
AND UPPER(m.name_manufacture) = ANY(%s)
""", count_params + [allowed_brands])
total = cur.fetchone()[0]
# Local stock keyed by OEM part id
local_stock = {}
if tenant and oem_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, oem_ids)
except Exception:
pass
items = []
for r in part_rows:
oem_id = r[5]
stock_info = local_stock.get(oem_id, {})
items.append({
'id': r[0],
'oem_part_number': r[1],
'name': r[2],
'manufacturer': r[3],
'price_usd': float(r[4]) if r[4] is not None else None,
'oem_id': oem_id,
'group': {'id': r[6], 'name': r[7]},
'category': {'id': r[8], 'name': r[9]},
'local_stock': stock_info.get('stock', 0),
'local_price': stock_info.get('price', None),
})
return jsonify({
'mye_id': mye_id,
'category_id': category_id,
'search': search,
'items': items,
'total': total,
'limit': limit,
'offset': offset,
'allowed_brands': allowed_brands
})
# --- Normal mode: return OEM parts ---
if search:
search_filter = "AND (p.oem_part_number ILIKE %s OR COALESCE(NULLIF(p.name_es, ''), p.name_part) ILIKE %s)"
like_term = f"%{search}%"
params.extend([like_term, like_term])
# Get parts
query_params = list(params)
cur.execute(f"""
SELECT DISTINCT p.id_part, p.oem_part_number,
@@ -819,7 +1065,7 @@ def mye_parts():
part_rows = cur.fetchall()
part_ids = [r[0] for r in part_rows]
# Count total
count_params = list(params)
cur.execute(f"""
SELECT COUNT(DISTINCT p.id_part)
FROM vehicle_parts vp
@@ -829,10 +1075,9 @@ def mye_parts():
WHERE vp.model_year_engine_id = %s
{cat_filter}
{search_filter}
""", params)
""", count_params)
total = cur.fetchone()[0]
# Enrich with local stock if available
local_stock = {}
if tenant and part_ids:
try:
@@ -863,6 +1108,7 @@ def mye_parts():
'total': total,
'limit': limit,
'offset': offset,
'allowed_brands': []
})
finally:
cur.close()

View File

@@ -91,7 +91,7 @@ def get_customer(customer_id):
cur.execute("""
SELECT id, branch_id, name, rfc, razon_social, regimen_fiscal, uso_cfdi,
cp, email, phone, address, price_tier, credit_limit, credit_balance,
is_active, vehicle_info, created_at
is_active, vehicle_info, created_at, max_discount_pct
FROM customers WHERE id = %s
""", (customer_id,))
row = cur.fetchone()
@@ -103,7 +103,7 @@ def get_customer(customer_id):
customer = dict(zip(cols, row))
# Convert Decimal to float
for k in ('credit_limit', 'credit_balance'):
for k in ('credit_limit', 'credit_balance', 'max_discount_pct'):
if customer.get(k) is not None:
customer[k] = float(customer[k])
@@ -213,7 +213,7 @@ def update_customer(customer_id):
# Build dynamic update
allowed = ['name', 'rfc', 'razon_social', 'regimen_fiscal', 'uso_cfdi',
'cp', 'email', 'phone', 'address', 'price_tier', 'credit_limit',
'vehicle_info', 'is_active', 'branch_id']
'max_discount_pct', 'vehicle_info', 'is_active', 'branch_id']
sets = []
vals = []
for field in allowed:

View File

@@ -26,6 +26,26 @@ from tasks import sync_vehicle_compatibility_task
inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory')
def _get_tier_discounts(conn):
"""Read global tier discounts from DB. Returns dict {tier_id: discount_pct}."""
cur = conn.cursor()
cur.execute("SELECT tier_id, discount_pct FROM tier_discounts")
rows = cur.fetchall()
cur.close()
return {r[0]: float(r[1]) for r in rows}
def _apply_tier_discounts(price_1, discounts):
"""Given a base price and discount dict, return (price_2, price_3)."""
if not price_1:
return 0, 0
disc2 = discounts.get(2, 0)
disc3 = discounts.get(3, 0)
p2 = round(float(price_1) * (1 - disc2 / 100), 2)
p3 = round(float(price_1) * (1 - disc3 / 100), 2)
return p2, p3
# ─── AI Classification ───────────────────────────
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
@@ -254,6 +274,16 @@ def create_item():
mcur.close(); mconn.close()
barcode = generate_barcode(conn, db_name)
# Auto-calculate tier prices from global discounts
discounts = _get_tier_discounts(conn)
price_1 = data.get('price_1', 0)
price_2, price_3 = _apply_tier_discounts(price_1, discounts)
# Allow override if explicitly sent (backward compat)
if 'price_2' in data:
price_2 = data['price_2']
if 'price_3' in data:
price_3 = data['price_3']
try:
cur.execute("""
INSERT INTO inventory
@@ -267,7 +297,7 @@ def create_item():
data.get('description'), data.get('category_id'), data.get('brand'),
json.dumps(data.get('vehicle_compatibility')) if data.get('vehicle_compatibility') else None,
data.get('unit', 'PZA'), data.get('cost', 0),
data.get('price_1', 0), data.get('price_2', 0), data.get('price_3', 0),
price_1, price_2, price_3,
data.get('tax_rate', 0.16),
data.get('min_stock', 0), data.get('max_stock', 0),
data.get('location'), data.get('image_url'), data.get('catalog_part_id')
@@ -365,6 +395,15 @@ def update_item(item_id):
if changing_prices and not has_permission('config.edit_prices'):
return jsonify({'error': 'Permission config.edit_prices required to change prices'}), 403
# Auto-calculate tier prices if price_1 changes and no explicit override
discounts = _get_tier_discounts(conn)
if 'price_1' in data and ('price_2' not in data or 'price_3' not in data):
p2, p3 = _apply_tier_discounts(data['price_1'], discounts)
if 'price_2' not in data:
data['price_2'] = p2
if 'price_3' not in data:
data['price_3'] = p3
# Build dynamic update
allowed = ['part_number', 'barcode', 'name', 'description', 'category_id', 'brand',
'vehicle_compatibility', 'unit', 'cost', 'price_1', 'price_2', 'price_3',
@@ -536,6 +575,33 @@ def delete_image(item_id):
return jsonify({'message': 'Image deleted'})
@inventory_bp.route('/items/<int:item_id>', methods=['DELETE'])
@require_auth('inventory.edit')
def delete_item(item_id):
"""Soft-delete an inventory item (mark is_active = false).
Keeps historical data (sales, movements) intact while removing
the item from the active catalog and stock views.
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT id, part_number, name FROM inventory WHERE id = %s", (item_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Item not found'}), 404
cur.execute("UPDATE inventory SET is_active = false WHERE id = %s", (item_id,))
conn.commit()
log_action(conn, 'INVENTORY_DELETE', 'inventory', item_id,
old_value={'part_number': row[1], 'name': row[2]})
cur.close(); conn.close()
return jsonify({'message': 'Item deleted', 'id': item_id})
# ─── Bulk Image Import ─────────────────────────
@inventory_bp.route('/bulk-images', methods=['POST'])
@@ -973,6 +1039,70 @@ def api_inventory_stats():
})
@inventory_bp.route('/summary', methods=['GET'])
@require_auth('inventory.view')
def api_inventory_summary():
"""Get high-level summary counts for the inventory dashboard badges."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
branch_id = getattr(g, 'branch_id', None)
where_branch = ""
params = []
if branch_id:
where_branch = "AND i.branch_id = %s"
params.append(branch_id)
# 1. Total active SKUs
cur.execute(f"""
SELECT COUNT(*) FROM inventory i
WHERE i.is_active = true {where_branch}
""", params.copy())
total_skus = cur.fetchone()[0] or 0
# 2. Total inventory value (cost * stock)
cur.execute(f"""
SELECT COALESCE(SUM(i.cost * COALESCE(s.stock, 0)), 0)
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true {where_branch}
""", params.copy())
total_value = float(cur.fetchone()[0] or 0)
# 3. Low stock count (below min_stock)
cur.execute(f"""
SELECT COUNT(*)
FROM inventory i
LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id
WHERE i.is_active = true {where_branch}
AND i.min_stock IS NOT NULL AND i.min_stock > 0
AND COALESCE(s.stock, 0) < i.min_stock
""", params.copy())
low_stock = cur.fetchone()[0] or 0
# 4. No movement in last 60 days
cutoff = datetime.utcnow() - timedelta(days=60)
cur.execute(f"""
SELECT COUNT(*)
FROM inventory i
WHERE i.is_active = true {where_branch}
AND i.id NOT IN (
SELECT inventory_id FROM inventory_operations
WHERE created_at > %s
)
""", params + [cutoff])
no_movement = cur.fetchone()[0] or 0
cur.close(); conn.close()
return jsonify({
'total_skus': total_skus,
'total_value': round(total_value, 2),
'low_stock': low_stock,
'no_movement': no_movement,
})
# ─── Alerts and History ────────────────────────
@inventory_bp.route('/alerts', methods=['GET'])
@@ -1594,3 +1724,46 @@ def search_mye_endpoint():
return jsonify({'data': results})
finally:
master.close()
# ─── Global Tier Discounts ───────────────────────
@inventory_bp.route('/tier-discounts', methods=['GET'])
@require_auth()
def get_tier_discounts_endpoint():
"""Return global tier discount percentages."""
conn = get_tenant_conn(g.tenant_id)
try:
discounts = _get_tier_discounts(conn)
return jsonify({
'data': [
{'tier_id': 2, 'tier_name': 'Taller', 'discount_pct': discounts.get(2, 0)},
{'tier_id': 3, 'tier_name': 'Mayoreo', 'discount_pct': discounts.get(3, 0)},
]
})
finally:
conn.close()
@inventory_bp.route('/tier-discounts', methods=['PUT'])
@require_auth('config.edit_prices')
def update_tier_discounts_endpoint():
"""Update global tier discount percentages."""
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
try:
for tier_id in (2, 3):
key = f'discount_pct_{tier_id}'
if key in data:
val = max(0, min(100, float(data[key])))
cur.execute("""
INSERT INTO tier_discounts (tier_id, tier_name, discount_pct)
VALUES (%s, %s, %s)
ON CONFLICT (tier_id) DO UPDATE SET discount_pct = EXCLUDED.discount_pct
""", (tier_id, 'Taller' if tier_id == 2 else 'Mayoreo', val))
conn.commit()
return jsonify({'message': 'Descuentos actualizados'})
finally:
cur.close()
conn.close()

View File

@@ -397,3 +397,30 @@ def get_sale_pdf(sale_id):
'customer': customer,
'cfdi': cfdi_info,
})
@invoicing_bp.route('/stats', methods=['GET'])
@require_auth('invoicing.read')
def api_invoicing_stats():
"""Return counts for tab badges: invoices, credit notes, payment complements, cancellations."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT
COUNT(*) FILTER (WHERE type = 'ingreso' AND status IN ('pending', 'stamped', 'retry')) as facturas,
COUNT(*) FILTER (WHERE type = 'egreso' AND status IN ('pending', 'stamped', 'retry')) as notas_credito,
COUNT(*) FILTER (WHERE type = 'pago' AND status IN ('pending', 'stamped', 'retry')) as complementos,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelaciones
FROM cfdi_queue
""")
row = cur.fetchone()
cur.close()
conn.close()
return jsonify({
'facturas': row[0] or 0,
'notas_credito': row[1] or 0,
'complementos': row[2] or 0,
'cancelaciones': row[3] or 0,
})

View File

@@ -190,6 +190,16 @@ def bodegas_with_part(part_id):
return _with_master(_do)
@marketplace_bp.route('/inventory/listing/<int:wi_id>', methods=['GET'])
@require_auth()
def bodegas_with_listing(wi_id):
"""Return bodegas stocking a specific seller listing (wi_id)."""
def _do(master):
data = mkt.get_bodegas_with_listing(master, wi_id)
return jsonify({'data': data, 'count': len(data)})
return _with_master(_do)
# ═══════════════════════════════════════════════════════════════════════════
# PURCHASE ORDERS
# ═══════════════════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,404 @@
"""MercadoLibre external marketplace REST endpoints.
Routes:
Config
GET /pos/api/marketplace-ext/config
POST /pos/api/marketplace-ext/connect
DELETE /pos/api/marketplace-ext/connect
GET /pos/api/marketplace-ext/categories
Listings
GET /pos/api/marketplace-ext/listings
POST /pos/api/marketplace-ext/listings
POST /pos/api/marketplace-ext/listings/<id>/sync
POST /pos/api/marketplace-ext/listings/<id>/pause
POST /pos/api/marketplace-ext/listings/<id>/activate
DELETE /pos/api/marketplace-ext/listings/<id>
Orders
GET /pos/api/marketplace-ext/orders
GET /pos/api/marketplace-ext/orders/<id>
POST /pos/api/marketplace-ext/orders/<id>/convert
Webhook (public)
POST /pos/api/marketplace-ext/webhook/meli
"""
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
from services.meli_service import MeliService, MeliAuthError
marketplace_ext_bp = Blueprint(
"marketplace_ext", __name__, url_prefix="/pos/api/marketplace-ext"
)
# ─── Helpers ───────────────────────────────────────────────────────────────
def _require_meli_manage():
if not has_permission("marketplace.manage"):
return jsonify({"error": "Missing permission: marketplace.manage"}), 403
return None
# ═══════════════════════════════════════════════════════════════════════════
# CONFIG
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/config", methods=["GET"])
@require_auth()
def get_config():
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
# Never return tokens to frontend
safe = {
k: v for k, v in cfg.items()
if k not in ("meli_access_token", "meli_refresh_token", "meli_client_secret")
}
safe["connected"] = bool(cfg.get("meli_access_token"))
return jsonify(safe)
finally:
conn.close()
@marketplace_ext_bp.route("/connect", methods=["POST"])
@require_auth()
def connect_meli():
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
code = data.get("code")
client_id = data.get("client_id")
client_secret = data.get("client_secret")
redirect_uri = data.get("redirect_uri", "")
if not code or not client_id or not client_secret:
return jsonify({"error": "code, client_id and client_secret required"}), 400
try:
token_data = MeliService.exchange_code(code, client_id, client_secret, redirect_uri)
except MeliAuthError as e:
return jsonify({"error": str(e)}), 400
access_token = token_data.get("access_token")
refresh_token = token_data.get("refresh_token")
user_id = token_data.get("user_id")
# Validate token by fetching user
svc = MeliService(access_token)
try:
user = svc.get_user()
except MeliAuthError as e:
return jsonify({"error": f"Invalid token: {e}"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
meli_svc.save_meli_config(conn, {
"meli_access_token": access_token,
"meli_refresh_token": refresh_token,
"meli_user_id": str(user_id or user.get("id")),
"meli_site_id": user.get("site_id", "MLM"),
"meli_enabled": "true",
"meli_client_id": client_id,
"meli_client_secret": client_secret,
})
return jsonify({
"ok": True,
"user_id": user_id or user.get("id"),
"nickname": user.get("nickname"),
"site_id": user.get("site_id"),
})
finally:
conn.close()
@marketplace_ext_bp.route("/connect", methods=["DELETE"])
@require_auth()
def disconnect_meli():
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
meli_svc.delete_meli_config(conn)
return jsonify({"ok": True})
finally:
conn.close()
@marketplace_ext_bp.route("/categories", methods=["GET"])
@require_auth()
def search_categories():
q = request.args.get("q", "")
site_id = request.args.get("site_id", "MLM")
if not q or len(q) < 2:
return jsonify({"categories": []})
conn = get_tenant_conn(g.tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg)
if not svc:
return jsonify({"error": "MercadoLibre not connected"}), 400
result = svc.search_categories(site_id, q)
return jsonify({"categories": result})
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# LISTINGS
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/listings", methods=["GET"])
@require_auth()
def list_listings():
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 200)
status = request.args.get("status")
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.get_listings(conn, page=page, per_page=per_page, status=status)
return jsonify(result)
finally:
conn.close()
@marketplace_ext_bp.route("/listings", methods=["POST"])
@require_auth()
def create_listings():
err = _require_meli_manage()
if err:
return err
data = request.get_json() or {}
inventory_ids = data.get("inventory_ids", [])
category_id = data.get("category_id")
listing_type = data.get("listing_type", "gold_special")
shipping_mode = data.get("shipping_mode", "me2")
if not inventory_ids:
return jsonify({"error": "inventory_ids required"}), 400
if not category_id:
return jsonify({"error": "category_id required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.publish_items(
conn,
inventory_ids=inventory_ids,
meli_category_id=category_id,
listing_type_id=listing_type,
shipping_mode=shipping_mode,
)
return jsonify(result), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
conn.close()
@marketplace_ext_bp.route("/listings/<int:listing_id>/sync", methods=["POST"])
@require_auth()
def sync_listing(listing_id):
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.sync_listing(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()
@marketplace_ext_bp.route("/listings/<int:listing_id>/pause", methods=["POST"])
@require_auth()
def pause_listing(listing_id):
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.pause_listing(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()
@marketplace_ext_bp.route("/listings/<int:listing_id>/activate", methods=["POST"])
@require_auth()
def activate_listing(listing_id):
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.activate_listing(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()
@marketplace_ext_bp.route("/listings/<int:listing_id>", methods=["DELETE"])
@require_auth()
def delete_listing(listing_id):
err = _require_meli_manage()
if err:
return err
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.close_listing(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()
# ═══════════════════════════════════════════════════════════════════════════
# ORDERS
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/orders", methods=["GET"])
@require_auth()
def list_orders():
page = int(request.args.get("page", 1))
per_page = min(int(request.args.get("per_page", 50)), 200)
status = request.args.get("status")
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.get_orders(conn, page=page, per_page=per_page, status=status)
return jsonify(result)
finally:
conn.close()
@marketplace_ext_bp.route("/orders/<int:order_id>", methods=["GET"])
@require_auth()
def get_order(order_id):
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.get_order_detail(conn, order_id)
return jsonify(result)
except ValueError as e:
return jsonify({"error": str(e)}), 404
finally:
conn.close()
@marketplace_ext_bp.route("/orders/<int:order_id>/convert", methods=["POST"])
@require_auth("pos.sell")
def convert_order(order_id):
data = request.get_json() or {}
register_id = data.get("register_id")
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.convert_order_to_sale(
conn, order_id, employee_id=g.employee_id, register_id=register_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()
@marketplace_ext_bp.route("/orders/<int:order_id>/status", methods=["POST"])
@require_auth()
def update_order_status_route(order_id):
data = request.get_json() or {}
new_status = data.get("status")
if not new_status:
return jsonify({"error": "status required"}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = meli_svc.update_order_status(conn, order_id, new_status)
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()
# ═══════════════════════════════════════════════════════════════════════════
# WEBHOOK (public — no auth)
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_ext_bp.route("/webhook/meli", methods=["POST"])
def meli_webhook():
"""Receive MercadoLibre notifications.
ML sends a lightweight payload with topic + resource URL.
We ack immediately and enqueue Celery for async processing.
"""
data = request.get_json(force=True, silent=True) or {}
topic = data.get("topic", "")
resource = data.get("resource", "")
user_id = data.get("user_id")
# Resolve tenant by meli_user_id
tenant_id = None
if user_id:
try:
mconn = get_master_conn()
mcur = mconn.cursor()
mcur.execute(
"""
SELECT t.id FROM tenants t
JOIN tenant_config c ON c.key = 'meli_user_id' AND c.value = %s
WHERE t.is_active = true
LIMIT 1
""",
(str(user_id),),
)
row = mcur.fetchone()
if row:
tenant_id = row[0]
mcur.close()
mconn.close()
except Exception:
pass
if tenant_id and topic:
try:
from tasks import process_meli_webhook_task
process_meli_webhook_task.delay(tenant_id, topic, resource)
except Exception as e:
print(f"[ML Webhook] Failed to enqueue task: {e}")
return jsonify({"ok": True})

View File

@@ -15,21 +15,37 @@ from flask import Blueprint, request, jsonify, g
from middleware import require_auth
from tenant_db import get_tenant_conn, get_master_conn
from services import whatsapp_service
from config import WHATSAPP_BRIDGE_URL, WHATSAPP_BRIDGE_KEY
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
def _get_whatsapp_config(conn):
"""Read WhatsApp bridge configuration from tenant_config.
Returns dict with bridge_url, enabled, etc."""
Falls back to global server config (config.py / env vars) when tenant
has no explicit WhatsApp settings. This allows the shared bridge to work
out of the box for all tenants.
"""
cur = conn.cursor()
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'whatsapp_%'")
config = {row[0]: row[1] for row in cur.fetchall()}
cur.close()
bridge_url = config.get('whatsapp_bridge_url', '') or WHATSAPP_BRIDGE_URL or ''
bridge_key = config.get('whatsapp_bridge_key', '') or WHATSAPP_BRIDGE_KEY or ''
enabled_raw = config.get('whatsapp_enabled', '').lower()
if enabled_raw == 'true':
enabled = True
elif enabled_raw == 'false':
enabled = False
else:
# No explicit tenant setting: auto-enable if a bridge URL is configured
enabled = bool(bridge_url)
return {
'bridge_url': config.get('whatsapp_bridge_url', ''),
'bridge_key': config.get('whatsapp_bridge_key', ''),
'enabled': config.get('whatsapp_enabled', 'false').lower() == 'true',
'bridge_url': bridge_url,
'bridge_key': bridge_key,
'enabled': enabled,
'phone_number': config.get('whatsapp_phone_number', ''),
}
@@ -194,27 +210,9 @@ def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn, master_conn=N
fallback_rows = _do_search(use_compat=False)
if not rows and not fallback_rows:
# Truly nothing found — return a conversational message that doesn't kill the chat
v_str = ""
if vehicle and vehicle.get('brand'):
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}".strip()
msg_parts = [
"🔍 Revisé nuestro inventario y no encontré esas partes en este momento."
]
if v_str:
msg_parts.append(f"Para tu {v_str}, puedo:")
else:
msg_parts.append("Te puedo ayudar de estas formas:")
msg_parts.extend([
"",
"• *Pedirlas por encargo* — te doy tiempo y precio estimado",
"• *Buscar alternativas* — equivalentes de otra marca que sí tengamos",
"• *Sugerir refaccionarias cercanas* — si es urgente",
"",
"¿Qué prefieres? O dime si quieres buscar otra parte."
])
return '\n'.join(msg_parts), None
# Nothing found in local inventory — let the AI's original response stand.
# The webhook will append a soft note instead of replacing the message.
return None, None
# Use fallback rows if primary search returned nothing
using_fallback = False
@@ -366,6 +364,11 @@ def webhook():
except Exception:
tenant_id = None
# Prepare phone and reply target early
reply_to = msg.get('jid') or msg['phone']
media_kind = msg.get('media_kind', 'text')
clean_phone = msg.get('phone', '')
tenant_conn = None
master_conn = None
inventory_context = None
@@ -394,11 +397,34 @@ def webhook():
print(f"[WA-AI] inventory_context failed: {e}")
inventory_context = None
# 2c. Urgency detection — if customer signals urgency, add a note
try:
from services.part_kits import is_urgent, urgency_note
if msg.get('text') and is_urgent(msg['text']):
if inventory_context:
inventory_context += urgency_note()
else:
inventory_context = urgency_note().strip()
except Exception as e:
print(f"[WA-AI] urgency detection failed: {e}")
# 2d. Purchase history — append recent confirmed orders for this customer
try:
from services.part_kits import get_purchase_history
history = get_purchase_history(clean_phone, tenant_conn)
if history:
if inventory_context:
inventory_context += "\n\n" + history
else:
inventory_context = history
except Exception as e:
print(f"[WA-AI] Purchase history failed: {e}")
# 2b. Append previously-detected vehicle so the AI keeps context
# even when we don't send full conversation history (Hermes is slow with it)
try:
from services.wa_quotation import get_vehicle
saved_vehicle = get_vehicle(clean_phone)
saved_vehicle = get_vehicle(tenant_conn, clean_phone)
if saved_vehicle and inventory_context:
v_str = f"{saved_vehicle.get('brand','')} {saved_vehicle.get('model','')} {saved_vehicle.get('year','')}".strip()
if v_str:
@@ -411,25 +437,68 @@ def webhook():
print(f"[WA-AI] vehicle_context failed: {e}")
except Exception as e:
print(f"[WA-AI] tenant connection failed: {e}")
if tenant_conn:
try:
tenant_conn.rollback()
except Exception:
pass
# 3. Dispatch by media kind + quotation commands
reply = None
reply_to = msg.get('jid') or msg['phone']
media_kind = msg.get('media_kind', 'text')
clean_phone = msg.get('phone', '')
# ── Abandoned quotation follow-up ──
# If customer has an active quote and hasn't interacted in 15+ min,
# send a gentle nudge before processing their current message.
try:
from services.part_kits import should_send_followup
followup = should_send_followup(clean_phone, tenant_conn)
if followup:
whatsapp_service.send_message(reply_to, followup, bridge_url=wa_config.get('bridge_url'))
if tenant_conn:
cur_fu = tenant_conn.cursor()
cur_fu.execute(
"INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)",
(clean_phone, followup)
)
tenant_conn.commit()
cur_fu.close()
except Exception as fu_err:
print(f"[WA-AI] Follow-up send failed: {fu_err}")
# ── Location message → nearest branch ──
if media_kind == 'location' and msg.get('latitude') is not None and msg.get('longitude') is not None:
from services.geo_branches import find_nearest_branch
nearest = find_nearest_branch(tenant_conn, msg['latitude'], msg['longitude'])
if nearest:
reply = (
f"📍 *Sucursal más cercana:*\n\n"
f"*{nearest['name']}*\n"
f"📌 {nearest['address']}\n"
f"📞 {nearest['phone']}\n"
f"🚗 Aprox. *{nearest['distance_km']} km* de tu ubicación\n\n"
f"¿Te gustaría recoger tu pedido ahí o prefieres envío a domicilio?"
)
else:
reply = (
"📍 Gracias por tu ubicación.\n\n"
"Actualmente no tenemos sucursales registradas con coordenadas. "
"¿En qué ciudad te encuentras? Te puedo indicar nuestras opciones de envío."
)
# ── Check for quotation commands FIRST (before AI) ──
if media_kind == 'text' and msg.get('text'):
if not reply and media_kind == 'text' and msg.get('text'):
from services.wa_quotation import (
detect_quote_intent, get_open_quotation, create_quotation,
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
)
from services.quote_image import generate_quote_image
from services.whatsapp_service import send_image
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
if intent == 'add':
last_part = get_last_shown_part(clean_phone)
last_part = get_last_shown_part(tenant_conn, clean_phone)
if not last_part:
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
elif tenant_conn:
@@ -444,6 +513,14 @@ def webhook():
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
)
# Smart kit suggestion — cross-sell related parts
try:
from services.part_kits import build_kit_text
kit_text = build_kit_text(last_part.get('name', ''))
if kit_text:
reply += kit_text
except Exception as kit_err:
print(f"[WA-AI] Kit suggestion failed: {kit_err}")
elif intent == 'send':
if tenant_conn:
@@ -453,6 +530,32 @@ def webhook():
reply = format_quotation_wa(detail)
if not reply:
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
else:
# Generate rich visual quote image and send it
try:
quote_items = []
for it in detail.get('items', []):
quote_items.append({
'name': it.get('name', ''),
'sku': it.get('sku', ''),
'qty': it.get('quantity', 1),
'price': float(it.get('unit_price', 0)),
'total': float(it.get('total', 0)),
})
totals = {
'subtotal': float(detail.get('subtotal', 0)),
'tax': float(detail.get('tax', 0)),
'total': float(detail.get('total', 0)),
}
tenant_name = tenant_config.get('business_name', 'Autopartes')
b64_img = generate_quote_image(quote_items, totals, tenant_name=tenant_name)
img_result = send_image(clean_phone, caption="Aquí está tu cotización 👇", base64_image=b64_img, bridge_url=bridge_url)
if img_result.get('success'):
reply = "📎 *Te envié tu cotización en imagen.*\n\n" + reply
else:
print(f"[WA-AI] Image send failed: {img_result}")
except Exception as img_err:
print(f"[WA-AI] Quote image generation failed: {img_err}")
else:
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
@@ -522,7 +625,7 @@ def webhook():
# Load conversation history so the AI remembers context (vehicle, parts, etc.)
conversation_history = []
if tenant_conn:
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=2)
conversation_history = _get_conversation_history(clean_phone, tenant_conn, limit=4)
if conversation_history:
print(f"[WA-AI] Loaded {len(conversation_history)} history messages for {clean_phone}")
@@ -570,6 +673,26 @@ def webhook():
'Puedes escribirme el mensaje?')
elif msg.get('text'):
txt = msg['text'].strip().lower()
# Quick welcome menu for new customers with no vehicle
is_greeting = txt in ('hola', 'buenos dias', 'buenas tardes', 'buenas noches', 'hey', 'que onda', 'saludos')
if is_greeting:
try:
from services.wa_quotation import get_vehicle
veh = get_vehicle(tenant_conn, clean_phone)
if not veh:
reply = (
"¡Qué onda! Bienvenido a *Autopartes Estrada*.\n\n"
"Soy Juan, tu vendedor. Para ayudarte rápido, dime:\n\n"
"1⃣ *Marca, modelo y año* de tu vehículo\n"
"2⃣ La *parte* que necesitas\n"
"3⃣ O escribe *menú* para ver opciones\n\n"
'_Ejemplo: "Necesito balatas para Tsuru 2015"_'
)
except Exception:
pass
if not reply:
# Plain text message — standard chatbot flow
from services.ai_chat import chat
ai_resp = chat(msg['text'], conversation_history=conversation_history, inventory_context=inventory_context)
@@ -584,7 +707,7 @@ def webhook():
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
try:
from services.wa_quotation import set_vehicle
set_vehicle(clean_phone, vehicle)
set_vehicle(tenant_conn, clean_phone, vehicle)
except Exception as veh_err:
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
@@ -593,10 +716,14 @@ def webhook():
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
elif not found_part and vehicle and vehicle.get('brand'):
# Only say "not in stock" when we have a specific vehicle
# and still found nothing. Otherwise let the AI ask for vehicle info.
reply = reply + '\n\n' + "_No tengo esa pieza exacta en stock para tu modelo ahora, pero puedo pedirla por encargo o buscar alternativas. ¿Te interesa?_"
# Track the found part so "cotizar" can add it
if found_part:
from services.wa_quotation import set_last_shown_part
set_last_shown_part(clean_phone, found_part)
set_last_shown_part(tenant_conn, clean_phone, found_part)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")

View File

@@ -5,7 +5,7 @@ bind = "0.0.0.0:5001"
# gthread workers handle multiple concurrent requests per worker via threads.
# Ideal for I/O-bound Flask apps with DB queries.
# 4 workers × 4 threads = 16 concurrent requests.
workers = 4
workers = 8
threads = 4
worker_class = "gthread"
worker_connections = 1000

View File

@@ -29,7 +29,7 @@ def require_auth(*required_permissions):
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 401
if payload.get('type') != 'pos_access':
if payload.get('type') not in ('pos_access', 'access'):
return jsonify({'error': 'Invalid token type'}), 401
g.tenant_id = payload['tenant_id']

View File

@@ -55,7 +55,7 @@ def _lookup_tenant_by_subdomain(subdomain):
conn = get_master_conn()
cur = conn.cursor()
cur.execute(
"SELECT id, name FROM tenants WHERE subdomain = %s AND is_active = true",
"SELECT id, name FROM tenants WHERE LOWER(subdomain) = %s AND is_active = true",
(subdomain,)
)
row = cur.fetchone()

View File

@@ -0,0 +1,91 @@
-- ═══════════════════════════════════════════════════════════════════════
-- v3.3 — Marketplace accepts any part number (seller listings)
-- Target: nexus_autoparts (master DB)
-- Date: 2026-05-17
--
-- Makes warehouse_inventory part_id nullable and adds seller-defined
-- fields so any seller can list parts that don't exist in the OEM catalog.
-- Existing OEM-matched listings are untouched.
-- ═══════════════════════════════════════════════════════════════════════
-- ─── 1. WAREHOUSE_INVENTORY — add seller listing columns ─────────────
ALTER TABLE warehouse_inventory
ADD COLUMN IF NOT EXISTS seller_part_number VARCHAR(100),
ADD COLUMN IF NOT EXISTS seller_part_name VARCHAR(300),
ADD COLUMN IF NOT EXISTS seller_category VARCHAR(100),
ADD COLUMN IF NOT EXISTS tenant_inventory_id INTEGER;
-- Make part_id nullable so seller listings (without catalog match) can exist
ALTER TABLE warehouse_inventory ALTER COLUMN part_id DROP NOT NULL;
-- ─── 2. WAREHOUSE_INVENTORY — drop old unique, add partial uniques ───
-- The old constraint was on (user_id, part_id, warehouse_location).
-- We replace it with two partial unique indexes:
-- - OEM items: (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
-- - Seller items: (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
ALTER TABLE warehouse_inventory
DROP CONSTRAINT IF EXISTS warehouse_inventory_user_id_part_id_warehouse_location_key;
DROP INDEX IF EXISTS idx_wi_unique_composite;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_oem
ON warehouse_inventory(bodega_id, part_id, warehouse_location)
WHERE part_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wi_unique_seller
ON warehouse_inventory(bodega_id, seller_part_number, warehouse_location)
WHERE part_id IS NULL;
-- Ensure every row has either part_id or seller_part_number
ALTER TABLE warehouse_inventory
DROP CONSTRAINT IF EXISTS chk_wi_part_or_seller;
ALTER TABLE warehouse_inventory
ADD CONSTRAINT chk_wi_part_or_seller
CHECK (
(part_id IS NOT NULL AND seller_part_number IS NULL)
OR
(part_id IS NULL AND seller_part_number IS NOT NULL)
);
-- ─── 3. WAREHOUSE_INVENTORY — search indexes ─────────────────────────
CREATE INDEX IF NOT EXISTS idx_wi_seller_pn
ON warehouse_inventory (bodega_id, seller_part_number)
WHERE part_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_wi_seller_category
ON warehouse_inventory (seller_category)
WHERE part_id IS NULL;
-- GIN index for text search on seller listings
CREATE INDEX IF NOT EXISTS idx_wi_seller_search
ON warehouse_inventory
USING gin (to_tsvector('spanish',
COALESCE(seller_part_name, '') || ' ' || COALESCE(seller_part_number, '')
))
WHERE part_id IS NULL;
-- ─── 4. PURCHASE_ORDER_ITEMS — make part_id nullable ─────────────────
ALTER TABLE purchase_order_items
ALTER COLUMN part_id DROP NOT NULL;
-- Add a flag so seller listings can be distinguished in POs
ALTER TABLE purchase_order_items
ADD COLUMN IF NOT EXISTS is_seller_listing BOOLEAN NOT NULL DEFAULT FALSE;
-- ─── 5. Back-compat: ensure existing rows are valid ──────────────────
-- Existing rows should have part_id set and seller_part_number NULL.
-- If any row violates the new check, this will fail loudly.
UPDATE warehouse_inventory
SET seller_part_number = NULL
WHERE part_id IS NOT NULL AND seller_part_number IS NOT NULL;
UPDATE warehouse_inventory
SET part_id = NULL
WHERE part_id IS NULL AND seller_part_number IS NULL;

View File

@@ -0,0 +1,110 @@
-- ============================================================
-- v3.4 MercadoLibre Integration
-- ============================================================
-- Adds tables for external marketplace listings, orders,
-- order items, and a generic sync queue.
-- All tables live in the tenant DB.
-- ============================================================
-- Listings published on MercadoLibre (extensible to Amazon later)
CREATE TABLE IF NOT EXISTS marketplace_listings (
id SERIAL PRIMARY KEY,
inventory_id INTEGER REFERENCES inventory(id),
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
external_item_id VARCHAR(50) NOT NULL,
external_status VARCHAR(30) DEFAULT 'active',
external_permalink TEXT,
title TEXT,
meli_category_id VARCHAR(30),
publish_price NUMERIC(12,2),
last_sync_at TIMESTAMPTZ,
sync_errors TEXT,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_inventory
ON marketplace_listings(inventory_id);
CREATE INDEX IF NOT EXISTS idx_marketplace_listings_external
ON marketplace_listings(external_item_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_marketplace_listings_unique
ON marketplace_listings(inventory_id, channel) WHERE is_active = true;
-- Orders received from MercadoLibre
CREATE TABLE IF NOT EXISTS marketplace_orders (
id SERIAL PRIMARY KEY,
channel VARCHAR(20) NOT NULL DEFAULT 'mercadolibre',
external_order_id VARCHAR(50) NOT NULL UNIQUE,
external_status VARCHAR(30) NOT NULL,
buyer_name VARCHAR(200),
buyer_email VARCHAR(200),
buyer_phone VARCHAR(50),
buyer_nickname VARCHAR(100),
shipping_address JSONB,
total_amount NUMERIC(12,2),
shipping_cost NUMERIC(12,2),
meli_shipping_id VARCHAR(50),
nexus_sale_id INTEGER REFERENCES sales(id),
status VARCHAR(20) DEFAULT 'pending',
notes TEXT,
raw_json JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_status
ON marketplace_orders(status);
CREATE INDEX IF NOT EXISTS idx_marketplace_orders_external
ON marketplace_orders(external_order_id);
-- Items inside a marketplace order
CREATE TABLE IF NOT EXISTS marketplace_order_items (
id SERIAL PRIMARY KEY,
marketplace_order_id INTEGER REFERENCES marketplace_orders(id) ON DELETE CASCADE,
inventory_id INTEGER REFERENCES inventory(id),
external_item_id VARCHAR(50),
title VARCHAR(300),
quantity INTEGER NOT NULL,
unit_price NUMERIC(12,2),
total_price NUMERIC(12,2),
listing_id INTEGER REFERENCES marketplace_listings(id)
);
-- Generic sync queue (reusable for future Amazon integration)
CREATE TABLE IF NOT EXISTS marketplace_sync_queue (
id SERIAL PRIMARY KEY,
inventory_id INTEGER REFERENCES inventory(id),
channel VARCHAR(20) NOT NULL,
action VARCHAR(20) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
payload JSONB,
error_message TEXT,
retry_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_marketplace_sync_queue_pending
ON marketplace_sync_queue(status, channel) WHERE status = 'pending';
-- Add source column to sales to track origin (POS, ML, Amazon, etc.)
-- If the column already exists from another migration, do nothing.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'sales' AND column_name = 'source'
) THEN
ALTER TABLE sales ADD COLUMN source VARCHAR(30) DEFAULT 'pos';
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'sales' AND column_name = 'external_order_id'
) THEN
ALTER TABLE sales ADD COLUMN external_order_id VARCHAR(50);
END IF;
END $$;

View File

@@ -86,11 +86,42 @@ def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temp
return None
SYSTEM_PROMPT_SHORT = """Eres un asistente de refaccionaria automotriz mexicana. Ayuda a encontrar autopartes.
SYSTEM_PROMPT_SHORT = """Eres Juan, vendedor estrella de Autopartes Estrada. Llevas 10 años ayudando a mecanicos y dueños de taller. Tu estilo: directo, calido, sin rollos tecnicos. Hablas como un compa que sabe de carros.
IMPORTANTE: NO prometas stock hasta verificar. Usa "Reviso...", "Busco...", "Déjame checar..." en vez de "Tengo..." a menos que estes 100% seguro.
Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}}
search_query va EN INGLES cuando el usuario pide una parte. Traducciones: Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector.
No preguntes mas si ya puedes buscar. Si el usuario describe un sintoma, diagnostica y sugiere partes.
Cuando pida cotizacion o multiples partes, search_query DEBE usar | para separar cada parte: "Brake Pad|Air Filter|Oil Filter|Spark Plug".
REGLAS DE VENTA AVANZADAS:
1. PRECIO AL FRENTE: Si hay stock, di precio y marca sin rodeos.
2. KIT INTELIGENTE: Siempre sugiere 1-2 productos relacionados que se necesitan para el mismo trabajo.
- Balatas → "Ya que vas a cambiar balatas, checa si los discos tambien estan gastados. Te armo paquete con descuento."
- Alternador → "Mientras cambias alternador, conviene cambiar la banda serpentina para que no se te rompa despues."
- Filtro de aceite → "¿Ya tienes filtro de aire y bujias? Para servicio completo conviene cambiar todo junto."
3. MANEJO DE OBJECIONES:
- "Esta caro""Te entiendo. Esta es marca original. Tambien manejo opcion economica. ¿Te mando las dos para comparar?"
- "Voy a checar en otro lado""Dale, te espero. Guardame este precio. Si encuentras mas barato, mandame foto de la cotizacion y veo si te la mejoro."
- "Lo necesito para hoy" / "Urgente""Perfecto. Tenemos entrega express en 2-4 horas o puedes pasar directo a la tienda. ¿Te lo armo ya?"
- "No se si sea esa""No hay problema. Dame los ultimos 4 digitos de tu VIN y te confirmo compatibilidad exacta."
- "Solo estoy cotizando""Claro, sin compromiso. Te armo la cotizacion y si decides despues, aqui queda guardada."
4. CIERRE SUAVE (termina SIEMPRE con pregunta):
- "¿Te lo aparto?"
- "¿Lo mando a tu taller o lo pasas a recoger?"
- "¿Con esto quedas o necesitas algo mas?"
- "¿Te armo el paquete completo? Sale mejor que por separado."
5. RECONOCIMIENTO DE CLIENTE: Si el contexto dice que compro antes, mencionalo. "Veo que compraste balatas hace 6 meses. ¿Ya es hora de cambiar las del otro eje?"
6. DIAGNOSTICO RAPIDO: Si describe sintoma, diagnostica en 1-2 frases y sugiere 2-3 partes mas probables.
TRADUCCIONES search_query (EN INGLES):
Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector, Banda de distribucion=Timing Belt, Tensor=Belt Tensioner, Junta homocinetica=CV Joint, Marcha=Starter Motor, Bateria=Battery, Aceite=Engine Oil, Refrigerante=Coolant.
FORMATO:
- search_query EN INGLES. NUNCA null si pide algo.
- vehicle: {"brand":"NISSAN","model":"Frontier","year":2019} marca en MAYUSCULAS.
- Multiples partes: "Brake Pad|Brake Disc|Brake Fluid"
- Mensaje maximo 4 lineas cortas. Lenguaje natural, nada robotico.
- Si ya detectaste vehiculo en conversacion anterior, NO vuelvas a pedirlo.
- Termina SIEMPRE con una pregunta de cierre.
"""
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
@@ -195,11 +226,24 @@ def get_inventory_context(tenant_conn, branch_id=None):
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
GROUP BY i.brand
ORDER BY cnt DESC
LIMIT 15
LIMIT 10
""", params)
brands = cur.fetchall()
brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0])
# Top categories with counts
cur.execute(f"""
SELECT c.name, COUNT(*) as cnt
FROM inventory i
JOIN part_categories c ON c.id = i.category_id
WHERE {where} AND c.name IS NOT NULL AND c.name != ''
GROUP BY c.name
ORDER BY cnt DESC
LIMIT 10
""", params)
categories = cur.fetchall()
category_list = ", ".join(f"{row[0]} ({row[1]})" for row in categories if row[0])
# Products with low stock (<=3)
cur.execute(f"""
SELECT COUNT(*) FROM inventory i
@@ -212,10 +256,12 @@ def get_inventory_context(tenant_conn, branch_id=None):
"CONTEXTO DEL INVENTARIO:",
f"Este negocio tiene {total} productos en inventario.",
]
if category_list:
lines.append(f"Categorias principales: {category_list}")
if brand_list:
lines.append(f"Marcas disponibles: {brand_list}")
lines.append(f"Marcas top: {brand_list}")
lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}")
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local.")
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local. Si no hay stock exacto, sugiere alternativa similar.")
return "\n".join(lines)
except Exception:
@@ -284,10 +330,10 @@ def chat_with_image(user_message, image_base64, conversation_history=None, inven
]
messages.append({"role": "user", "content": user_content})
# Try Hermes first for vision (if enabled), fallback to OpenRouter
# Vision backends: QWEN only, fallback to OpenRouter if key present
backends = []
if HERMES_ENABLED:
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_VISION_MODEL))
if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
if OPENROUTER_API_KEY:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
@@ -339,10 +385,10 @@ def classify_part(part_number):
{"role": "user", "content": prompt}
]
# Try Hermes first (if enabled), fallback to OpenRouter
# Backends: QWEN only, fallback to OpenRouter if key present
backends = []
if HERMES_ENABLED:
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL))
if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
if OPENROUTER_API_KEY:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL))
@@ -528,12 +574,10 @@ def chat(user_message, conversation_history=None, inventory_context=None):
last_error = None
# Build backend list: QWEN first (fast, ~1s), then Hermes (specialized, ~30s), then OpenRouter
# Build backend list: QWEN first, then OpenRouter fallback
backends = []
if QWEN_ENABLED:
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 35, SYSTEM_PROMPT_SHORT, 4000))
if HERMES_ENABLED:
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL, 45, SYSTEM_PROMPT, 800))
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 18, SYSTEM_PROMPT_SHORT, 1200))
if OPENROUTER_API_KEY:
for m in FALLBACK_MODELS:
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
@@ -548,14 +592,22 @@ def chat(user_message, conversation_history=None, inventory_context=None):
if conversation_history:
msgs.extend(conversation_history)
msgs.append({"role": "user", "content": user_message})
# Retry logic: QWEN gets 3 attempts with 2s delay because the API is flaky
max_retries = 3 if url == QWEN_CHAT_URL else 1
result = None
for attempt in range(1, max_retries + 1):
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
if result is not None:
break
if attempt < max_retries:
print(f"[AI] QWEN attempt {attempt} failed, retrying in 2s...")
_time_chat.sleep(2)
if result is None:
if url == QWEN_CHAT_URL:
print(f"[AI] QWEN failed, trying Hermes fallback...")
print(f"[AI] QWEN failed after {max_retries} attempts, trying fallback...")
last_error = "qwen_failed"
elif url == HERMES_CHAT_URL:
print(f"[AI] Hermes failed, trying OpenRouter fallback...")
last_error = "hermes_timeout"
else:
print(f"[AI] Rate limited on {model_id}, trying next model...")
last_error = "rate_limit"
@@ -589,7 +641,7 @@ def chat(user_message, conversation_history=None, inventory_context=None):
# All models exhausted — DON'T cache errors, we want retries next time
if last_error == "rate_limit":
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
if last_error == "hermes_timeout":
if last_error == "qwen_failed":
return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None}
return {
"message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.",

View File

@@ -230,29 +230,42 @@ def get_engines(master_conn, model_id, year_id):
return [{'id_mye': r[0], 'name_engine': r[1], 'trim_level': r[2] or ''} for r in rows]
def get_categories(master_conn, mye_id):
def get_categories(master_conn, mye_id, allowed_brands=None):
"""Get part categories that have parts for this vehicle (mye_id).
Uses a subquery on vehicle_parts filtered by mye_id (indexed),
then JOINs through parts -> part_groups -> part_categories.
Uses COUNT with a safety LIMIT on the subquery.
If allowed_brands is provided, only counts parts that have at least one
aftermarket equivalent from those manufacturers.
"""
cur = master_conn.cursor()
cur.execute("""
brand_filter = ""
params = [mye_id]
if allowed_brands:
brand_filter = """AND EXISTS (
SELECT 1 FROM aftermarket_parts ap2
JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id
WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s)
)"""
params.append(allowed_brands)
cur.execute(f"""
SELECT pc.id_part_category,
COALESCE(pc.name_es, pc.name_part_category) AS name,
sub.cnt
FROM (
SELECT pg.category_id, COUNT(*) AS cnt
SELECT pg.category_id, COUNT(DISTINCT p.id_part) AS cnt
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN part_groups pg ON pg.id_part_group = p.group_id
WHERE vp.model_year_engine_id = %s
{brand_filter}
GROUP BY pg.category_id
) sub
JOIN part_categories pc ON pc.id_part_category = sub.category_id
ORDER BY name
""", (mye_id,))
""", params)
rows = cur.fetchall()
cur.close()
return [{'id_part_category': r[0], 'name': translate_category(r[1]), 'part_count': r[2]} for r in rows]

View File

@@ -0,0 +1,56 @@
import math
def haversine(lat1, lon1, lat2, lon2):
"""Calculate the great-circle distance between two points on Earth in km."""
R = 6371.0 # Earth radius in km
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return R * c
def find_nearest_branch(tenant_conn, latitude, longitude):
"""
Find the nearest active branch with coordinates.
Returns a dict with branch info + distance_km, or None.
"""
if not tenant_conn or latitude is None or longitude is None:
return None
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, name, address, phone, latitude, longitude
FROM branches
WHERE is_active = TRUE AND latitude IS NOT NULL AND longitude IS NOT NULL
"""
)
branches = cur.fetchall()
cur.close()
nearest = None
min_dist = float('inf')
for row in branches:
bid, name, address, phone, b_lat, b_lon = row
if b_lat is None or b_lon is None:
continue
dist = haversine(float(latitude), float(longitude), float(b_lat), float(b_lon))
if dist < min_dist:
min_dist = dist
nearest = {
'id': bid,
'name': name,
'address': address or '',
'phone': phone or '',
'latitude': float(b_lat),
'longitude': float(b_lon),
'distance_km': round(dist, 1),
}
return nearest

View File

@@ -0,0 +1,978 @@
"""Business logic for MercadoLibre external marketplace integration.
Depends on:
- meli_service.py (HTTP client)
- pos_engine.py (sale creation)
- inventory_engine.py (stock queries)
- image_service.py (image URLs)
"""
import json
import logging
from typing import Optional
from decimal import Decimal
from services.meli_service import MeliService, MeliError, MeliAuthError
from services.pos_engine import process_sale, calculate_totals
from services.inventory_engine import get_stock, get_stock_bulk
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════════════════════════
# CONFIG HELPERS
# ═══════════════════════════════════════════════════════════════════════════
MELI_CONFIG_KEYS = [
"meli_access_token",
"meli_refresh_token",
"meli_user_id",
"meli_site_id",
"meli_enabled",
"meli_auto_publish",
"meli_sync_interval_min",
"meli_order_sync_interval_min",
"meli_default_category_id",
"meli_shipping_mode",
"meli_client_id",
"meli_client_secret",
]
def _get_config_value(cur, key: str, default=None):
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
row = cur.fetchone()
return row[0] if row else default
def get_meli_config(tenant_conn) -> dict:
"""Read ML config from tenant_config."""
cur = tenant_conn.cursor()
cfg = {}
for key in MELI_CONFIG_KEYS:
cfg[key] = _get_config_value(cur, key)
cur.close()
# Normalize booleans
cfg["meli_enabled"] = (cfg.get("meli_enabled") or "").lower() == "true"
cfg["meli_auto_publish"] = (cfg.get("meli_auto_publish") or "").lower() == "true"
cfg["meli_sync_interval_min"] = int(cfg.get("meli_sync_interval_min") or 15)
cfg["meli_order_sync_interval_min"] = int(cfg.get("meli_order_sync_interval_min") or 5)
return cfg
def save_meli_config(tenant_conn, updates: dict) -> None:
"""Upsert ML config keys into tenant_config."""
cur = tenant_conn.cursor()
for key, value in updates.items():
if value is None:
continue
cur.execute(
"""
INSERT INTO tenant_config (key, value, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
""",
(key, str(value)),
)
tenant_conn.commit()
cur.close()
def delete_meli_config(tenant_conn) -> None:
"""Remove all ML config keys."""
cur = tenant_conn.cursor()
cur.execute(
"DELETE FROM tenant_config WHERE key LIKE 'meli_%'"
)
tenant_conn.commit()
cur.close()
def _get_meli_service(cfg: dict) -> Optional[MeliService]:
"""Build MeliService from config dict."""
token = cfg.get("meli_access_token")
if not token:
return None
return MeliService(
access_token=token,
refresh_token=cfg.get("meli_refresh_token"),
client_id=cfg.get("meli_client_id"),
client_secret=cfg.get("meli_client_secret"),
)
# ═══════════════════════════════════════════════════════════════════════════
# ITEM PAYLOAD BUILDER
# ═══════════════════════════════════════════════════════════════════════════
def _extract_meli_error(err: MeliError) -> str:
"""Extract the most useful error message from a MeliError response."""
base = str(err)
body = err.response_body
if not body:
return base
try:
data = json.loads(body)
msg = data.get("message") or data.get("error")
causes = data.get("cause", [])
if causes and isinstance(causes, list):
cause_msgs = [c.get("message") for c in causes if c.get("message")]
if cause_msgs:
msg = (msg + " | " if msg else "") + "; ".join(cause_msgs)
if msg:
return msg
except Exception:
pass
return base
def build_item_payload(
inventory_row: dict,
images: list[str],
meli_category_id: str,
price: float,
stock: int,
shipping_mode: str = "me2",
listing_type_id: str = "gold_special",
) -> dict:
"""Convert a Nexus inventory row into a MercadoLibre item payload."""
title = f"{inventory_row['name']} {inventory_row['brand'] or ''} {inventory_row['part_number'] or ''}".strip()
# ML title limit is 60 chars; truncate smartly
if len(title) > 60:
title = title[:57] + "..."
payload = {
"title": title,
"category_id": meli_category_id,
"price": round(float(price), 2),
"currency_id": "MXN",
"available_quantity": max(int(stock), 0),
"buying_mode": "buy_it_now",
"listing_type_id": listing_type_id,
"condition": "new",
"pictures": [{"source": url} for url in images if url],
"shipping": {"mode": shipping_mode},
"attributes": [],
}
if inventory_row.get("brand"):
payload["attributes"].append(
{"id": "BRAND", "value_name": inventory_row["brand"]}
)
if inventory_row.get("part_number"):
payload["attributes"].append(
{"id": "PART_NUMBER", "value_name": inventory_row["part_number"]}
)
# Vehicle compatibility as attributes (if available)
vehicle_compat = inventory_row.get("vehicle_compatibility")
if vehicle_compat:
if isinstance(vehicle_compat, str):
try:
vehicle_compat = json.loads(vehicle_compat)
except Exception:
vehicle_compat = None
if isinstance(vehicle_compat, list) and vehicle_compat:
first = vehicle_compat[0]
if isinstance(first, dict):
if first.get("brand"):
payload["attributes"].append(
{"id": "VEHICLE_MODEL", "value_name": first["brand"]}
)
if first.get("model"):
payload["attributes"].append(
{"id": "VEHICLE_MODEL_NAME", "value_name": first["model"]}
)
return payload
# ═══════════════════════════════════════════════════════════════════════════
# LISTINGS CRUD
# ═══════════════════════════════════════════════════════════════════════════
def publish_items(
tenant_conn,
inventory_ids: list[int],
meli_category_id: str,
listing_type_id: str = "gold_special",
shipping_mode: str = "me2",
) -> dict:
"""Publish one or more inventory items to MercadoLibre.
Returns summary dict with success/failure per item.
"""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg)
if not svc:
raise ValueError("MercadoLibre not configured")
cur = tenant_conn.cursor()
# Batch fetch inventory rows
cur.execute(
"""
SELECT id, part_number, name, brand, price_1, vehicle_compatibility,
image_url, unit, is_active
FROM inventory
WHERE id = ANY(%s) AND is_active = true
""",
(inventory_ids,),
)
rows = {r[0]: r for r in cur.fetchall()}
# Batch fetch stock
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
results = {"success": [], "failed": []}
for inv_id in inventory_ids:
row = rows.get(inv_id)
if not row:
results["failed"].append({"inventory_id": inv_id, "error": "Not found or inactive"})
continue
inv = {
"id": row[0],
"part_number": row[1],
"name": row[2],
"brand": row[3],
"price_1": float(row[4]) if row[4] else 0,
"vehicle_compatibility": row[5],
"image_url": row[6],
"unit": row[7],
}
stock = stock_map.get(inv_id, 0)
if stock <= 0:
results["failed"].append({"inventory_id": inv_id, "error": "Sin stock disponible"})
continue
if inv["price_1"] <= 0:
results["failed"].append({"inventory_id": inv_id, "error": "El precio debe ser mayor a 0"})
continue
# Build image list
images = []
if inv.get("image_url"):
images.append(inv["image_url"])
# TODO: fetch additional images from a separate table if we have gallery support
if not images:
results["failed"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen. ML requiere imagen para publicar."})
continue
payload = build_item_payload(
inv, images, meli_category_id, inv["price_1"], stock,
shipping_mode=shipping_mode, listing_type_id=listing_type_id,
)
try:
ml_item = svc.create_item(payload)
external_item_id = ml_item.get("id")
permalink = ml_item.get("permalink")
# Persist listing
cur.execute(
"""
INSERT INTO marketplace_listings
(inventory_id, channel, external_item_id, external_status,
external_permalink, title, meli_category_id, publish_price,
last_sync_at, sync_errors, is_active)
VALUES (%s, 'mercadolibre', %s, 'active', %s, %s, %s, %s, NOW(), NULL, true)
ON CONFLICT (inventory_id, channel) WHERE is_active = true
DO UPDATE SET
external_item_id = EXCLUDED.external_item_id,
external_status = EXCLUDED.external_status,
external_permalink = EXCLUDED.external_permalink,
title = EXCLUDED.title,
meli_category_id = EXCLUDED.meli_category_id,
publish_price = EXCLUDED.publish_price,
last_sync_at = NOW(),
sync_errors = NULL,
is_active = true
""",
(
inv_id,
external_item_id,
permalink,
payload["title"],
meli_category_id,
inv["price_1"],
),
)
tenant_conn.commit()
results["success"].append(
{"inventory_id": inv_id, "external_item_id": external_item_id, "permalink": permalink}
)
except MeliError as e:
tenant_conn.rollback()
err_msg = _extract_meli_error(e)
logger.warning("ML publish failed for inventory_id=%s: %s | payload=%s | response=%s", inv_id, err_msg, json.dumps(payload), e.response_body)
results["failed"].append({"inventory_id": inv_id, "error": err_msg})
except Exception as e:
tenant_conn.rollback()
logger.exception("Unexpected error publishing inventory_id=%s", inv_id)
results["failed"].append({"inventory_id": inv_id, "error": str(e)})
cur.close()
return results
def get_listings(tenant_conn, page: int = 1, per_page: int = 50, status: str = None):
cur = tenant_conn.cursor()
where = ["1=1"]
params = []
if status:
where.append("external_status = %s")
params.append(status)
count_sql = f"SELECT COUNT(*) FROM marketplace_listings WHERE {' AND '.join(where)}"
cur.execute(count_sql, params)
total = cur.fetchone()[0]
sql = f"""
SELECT l.id, l.inventory_id, l.external_item_id, l.external_status,
l.external_permalink, l.title, l.meli_category_id, l.publish_price,
l.last_sync_at, l.sync_errors, l.is_active, l.created_at,
i.part_number, i.name, i.price_1, i.brand
FROM marketplace_listings l
JOIN inventory i ON i.id = l.inventory_id
WHERE {' AND '.join(where)}
ORDER BY l.created_at DESC
LIMIT %s OFFSET %s
"""
cur.execute(sql, params + [per_page, (page - 1) * per_page])
rows = cur.fetchall()
cur.close()
items = []
for r in rows:
items.append({
"id": r[0],
"inventory_id": r[1],
"external_item_id": r[2],
"external_status": r[3],
"external_permalink": r[4],
"title": r[5],
"meli_category_id": r[6],
"publish_price": float(r[7]) if r[7] else None,
"last_sync_at": str(r[8]) if r[8] else None,
"sync_errors": r[9],
"is_active": r[10],
"created_at": str(r[11]),
"part_number": r[12],
"inventory_name": r[13],
"current_price": float(r[14]) if r[14] else None,
"brand": r[15],
})
return {"items": items, "total": total, "page": page, "per_page": per_page}
def sync_listing(tenant_conn, listing_id: int) -> dict:
"""Force a manual sync of stock/price for a single listing."""
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, inventory_id, external_item_id, meli_category_id
FROM marketplace_listings WHERE id = %s AND is_active = true
""",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
listing = {
"id": row[0],
"inventory_id": row[1],
"external_item_id": row[2],
"meli_category_id": row[3],
}
# Get current stock/price
cur.execute(
"SELECT price_1 FROM inventory WHERE id = %s",
(listing["inventory_id"],),
)
price_row = cur.fetchone()
current_price = float(price_row[0]) if price_row and price_row[0] else 0
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
current_stock = stock_map.get(listing["inventory_id"], 0)
try:
svc.update_item(
listing["external_item_id"],
{"price": round(current_price, 2), "available_quantity": max(current_stock, 0)},
)
cur.execute(
"""
UPDATE marketplace_listings
SET last_sync_at = NOW(), sync_errors = NULL, publish_price = %s
WHERE id = %s
""",
(current_price, listing_id),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "price": current_price, "stock": current_stock}
except MeliError as e:
tenant_conn.rollback()
cur.execute(
"UPDATE marketplace_listings SET sync_errors = %s WHERE id = %s",
(str(e)[:500], listing_id),
)
tenant_conn.commit()
cur.close()
raise
def pause_listing(tenant_conn, listing_id: int) -> dict:
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_item_id FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
svc.pause_item(row[0])
cur.execute(
"UPDATE marketplace_listings SET external_status = 'paused' WHERE id = %s",
(listing_id,),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "status": "paused"}
def activate_listing(tenant_conn, listing_id: int) -> dict:
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_item_id FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
svc.activate_item(row[0])
cur.execute(
"UPDATE marketplace_listings SET external_status = 'active' WHERE id = %s",
(listing_id,),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "status": "active"}
def close_listing(tenant_conn, listing_id: int) -> dict:
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_item_id FROM marketplace_listings WHERE id = %s",
(listing_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Listing not found")
svc.close_item(row[0])
cur.execute(
"UPDATE marketplace_listings SET external_status = 'closed', is_active = false WHERE id = %s",
(listing_id,),
)
tenant_conn.commit()
cur.close()
return {"ok": True, "status": "closed"}
# ═══════════════════════════════════════════════════════════════════════════
# ORDERS
# ═══════════════════════════════════════════════════════════════════════════
def fetch_and_save_orders(tenant_conn, date_from: Optional[str] = None) -> dict:
"""Pull orders from ML and upsert into marketplace_orders."""
cfg = get_meli_config(tenant_conn)
svc = _get_meli_service(cfg)
user_id = cfg.get("meli_user_id")
if not svc or not user_id:
raise ValueError("MercadoLibre not configured")
ml_resp = svc.get_orders(user_id, status="paid", date_from=date_from)
orders = ml_resp.get("results", [])
cur = tenant_conn.cursor()
created = 0
updated = 0
for order_summary in orders:
order_id = order_summary.get("id")
if not order_id:
continue
# Fetch full order detail
try:
full = svc.get_order(str(order_id))
except MeliError:
continue
external_status = full.get("status")
buyer = full.get("buyer", {})
shipping = full.get("shipping", {})
order_items = full.get("order_items", [])
# Build shipping address JSON
shipping_address = None
if shipping:
shipping_address = {
"id": shipping.get("id"),
"status": shipping.get("status"),
"tracking_number": shipping.get("tracking_number"),
"shipping_method": shipping.get("shipping_option", {}).get("name"),
}
total_amount = full.get("total_amount")
shipping_cost = shipping.get("cost") if shipping else None
# Upsert order
cur.execute(
"""
INSERT INTO marketplace_orders
(channel, external_order_id, external_status, buyer_name,
buyer_email, buyer_phone, buyer_nickname, shipping_address,
total_amount, shipping_cost, meli_shipping_id, raw_json, updated_at)
VALUES ('mercadolibre', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (external_order_id) DO UPDATE SET
external_status = EXCLUDED.external_status,
buyer_name = EXCLUDED.buyer_name,
buyer_email = EXCLUDED.buyer_email,
buyer_phone = EXCLUDED.buyer_phone,
shipping_address = EXCLUDED.shipping_address,
total_amount = EXCLUDED.total_amount,
shipping_cost = EXCLUDED.shipping_cost,
meli_shipping_id = EXCLUDED.meli_shipping_id,
raw_json = EXCLUDED.raw_json,
updated_at = NOW()
RETURNING id
""",
(
str(order_id),
external_status,
buyer.get("first_name", "") + " " + buyer.get("last_name", ""),
buyer.get("email"),
buyer.get("phone", {}).get("number"),
buyer.get("nickname"),
json.dumps(shipping_address) if shipping_address else None,
total_amount,
shipping_cost,
str(shipping.get("id")) if shipping else None,
json.dumps(full),
),
)
row = cur.fetchone()
mpo_id = row[0] if row else None
if mpo_id:
# Check if this was insert or update
cur.execute(
"SELECT created_at FROM marketplace_orders WHERE id = %s",
(mpo_id,),
)
created_at = cur.fetchone()[0]
# Simple heuristic: if updated_at == created_at (within 1s), it's new
is_new = True # ON CONFLICT always returns id; we count as updated for simplicity
updated += 1
# Upsert order items
if mpo_id and order_items:
# Clear old items and re-insert
cur.execute(
"DELETE FROM marketplace_order_items WHERE marketplace_order_id = %s",
(mpo_id,),
)
for it in order_items:
item_data = it.get("item", {})
cur.execute(
"""
INSERT INTO marketplace_order_items
(marketplace_order_id, external_item_id, title,
quantity, unit_price, total_price)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
mpo_id,
item_data.get("id"),
item_data.get("title"),
it.get("quantity"),
it.get("unit_price"),
it.get("full_unit_price"),
),
)
tenant_conn.commit()
cur.close()
return {"processed": len(orders), "updated": updated}
def get_orders(tenant_conn, page=1, per_page=50, status=None):
cur = tenant_conn.cursor()
where = ["1=1"]
params = []
if status:
where.append("status = %s")
params.append(status)
cur.execute(
f"SELECT COUNT(*) FROM marketplace_orders WHERE {' AND '.join(where)}",
params,
)
total = cur.fetchone()[0]
cur.execute(
f"""
SELECT o.id, o.external_order_id, o.external_status, o.buyer_name,
o.buyer_nickname, o.total_amount, o.status, o.created_at,
o.nexus_sale_id
FROM marketplace_orders o
WHERE {' AND '.join(where)}
ORDER BY o.created_at DESC
LIMIT %s OFFSET %s
""",
params + [per_page, (page - 1) * per_page],
)
rows = cur.fetchall()
cur.close()
items = []
for r in rows:
items.append({
"id": r[0],
"external_order_id": r[1],
"external_status": r[2],
"buyer_name": r[3],
"buyer_nickname": r[4],
"total_amount": float(r[5]) if r[5] else None,
"status": r[6],
"created_at": str(r[7]),
"nexus_sale_id": r[8],
})
return {"items": items, "total": total, "page": page, "per_page": per_page}
def get_order_detail(tenant_conn, order_id: int) -> dict:
cur = tenant_conn.cursor()
cur.execute(
"""
SELECT id, external_order_id, external_status, buyer_name, buyer_email,
buyer_phone, buyer_nickname, shipping_address, total_amount,
shipping_cost, meli_shipping_id, nexus_sale_id, status, notes,
raw_json, created_at, updated_at
FROM marketplace_orders WHERE id = %s
""",
(order_id,),
)
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Order not found")
order = {
"id": row[0],
"external_order_id": row[1],
"external_status": row[2],
"buyer_name": row[3],
"buyer_email": row[4],
"buyer_phone": row[5],
"buyer_nickname": row[6],
"shipping_address": row[7],
"total_amount": float(row[8]) if row[8] else None,
"shipping_cost": float(row[9]) if row[9] else None,
"meli_shipping_id": row[10],
"nexus_sale_id": row[11],
"status": row[12],
"notes": row[13],
"raw_json": row[14],
"created_at": str(row[15]),
"updated_at": str(row[16]),
}
cur.execute(
"""
SELECT id, inventory_id, external_item_id, title, quantity,
unit_price, total_price
FROM marketplace_order_items WHERE marketplace_order_id = %s
""",
(order_id,),
)
items = []
for r in cur.fetchall():
items.append({
"id": r[0],
"inventory_id": r[1],
"external_item_id": r[2],
"title": r[3],
"quantity": r[4],
"unit_price": float(r[5]) if r[5] else None,
"total_price": float(r[6]) if r[6] else None,
})
order["items"] = items
cur.close()
return order
def update_order_status(tenant_conn, order_id: int, new_status: str) -> dict:
valid = {"pending", "confirmed", "packed", "shipped", "delivered", "cancelled", "rejected"}
if new_status not in valid:
raise ValueError(f"Invalid status. Allowed: {valid}")
cur = tenant_conn.cursor()
cur.execute(
"UPDATE marketplace_orders SET status = %s, updated_at = NOW() WHERE id = %s RETURNING external_order_id",
(new_status, order_id),
)
row = cur.fetchone()
tenant_conn.commit()
cur.close()
if not row:
raise ValueError("Order not found")
return {"ok": True, "status": new_status, "external_order_id": row[0]}
# ═══════════════════════════════════════════════════════════════════════════
# CONVERT ORDER → SALE
# ═══════════════════════════════════════════════════════════════════════════
def convert_order_to_sale(
tenant_conn, marketplace_order_id: int, employee_id: int = None, register_id: int = None
) -> dict:
"""Convert a marketplace order into a Nexus sale.
1. Look up marketplace_order + items
2. Map items to inventory_id (via marketplace_listings external_item_id)
3. Build sale_data compatible with process_sale()
4. Call process_sale()
5. Link sale back to marketplace_order
"""
order = get_order_detail(tenant_conn, marketplace_order_id)
if order.get("nexus_sale_id"):
raise ValueError("Order already converted to sale")
cur = tenant_conn.cursor()
# Build sale items
sale_items = []
for it in order["items"]:
# Map external_item_id -> inventory_id via marketplace_listings
cur.execute(
"SELECT inventory_id FROM marketplace_listings WHERE external_item_id = %s LIMIT 1",
(it["external_item_id"],),
)
inv_row = cur.fetchone()
inventory_id = inv_row[0] if inv_row else None
if not inventory_id:
# Try to match by title fuzzy? Skip for now.
cur.close()
raise ValueError(f"Could not map item {it['external_item_id']} to inventory")
sale_items.append({
"inventory_id": inventory_id,
"quantity": it["quantity"],
"unit_price": float(it["unit_price"] or 0),
"discount_pct": 0,
"tax_rate": 0.16,
})
# Find or create generic "MercadoLibre" customer
cur.execute(
"SELECT id FROM customers WHERE name = 'MercadoLibre' LIMIT 1"
)
cust = cur.fetchone()
if cust:
customer_id = cust[0]
else:
cur.execute(
"""
INSERT INTO customers (name, email, phone, is_active, price_tier)
VALUES ('MercadoLibre', 'marketplace@mercadolibre.com', '', true, 1)
RETURNING id
"""
)
customer_id = cur.fetchone()[0]
# Build sale_data
sale_data = {
"items": sale_items,
"customer_id": customer_id,
"payment_method": "transferencia",
"sale_type": "cash",
"register_id": register_id,
"amount_paid": float(order["total_amount"] or 0),
"notes": f"MercadoLibre order #{order['external_order_id']}",
}
# We need to run process_sale inside the same connection.
# process_sale expects the caller to commit. We'll do that.
# However, process_sale uses flask.g for employee_id and branch_id.
# We need to set those or pass them explicitly.
# For now, we'll create the sale manually to avoid flask.g dependency.
sale = _create_sale_manual(tenant_conn, sale_data, employee_id=employee_id)
# Link order to sale
cur.execute(
"UPDATE marketplace_orders SET nexus_sale_id = %s, status = 'confirmed', updated_at = NOW() WHERE id = %s",
(sale["id"], marketplace_order_id),
)
tenant_conn.commit()
cur.close()
return {"sale_id": sale["id"], "marketplace_order_id": marketplace_order_id}
def _create_sale_manual(tenant_conn, sale_data: dict, employee_id: int = None) -> dict:
"""Create a sale record without relying on flask.g.
Simplified version of process_sale for background / external use.
"""
from services.inventory_engine import record_sale as inventory_record_sale
from decimal import Decimal, ROUND_HALF_UP
cur = tenant_conn.cursor()
items = sale_data.get("items", [])
customer_id = sale_data.get("customer_id")
payment_method = sale_data.get("payment_method", "efectivo")
sale_type = sale_data.get("sale_type", "cash")
register_id = sale_data.get("register_id")
amount_paid = float(sale_data.get("amount_paid", 0))
notes = sale_data.get("notes")
if not items:
raise ValueError("No items in sale")
# Enrich items
inv_ids = [it["inventory_id"] for it in items]
cur.execute(
"""
SELECT id, part_number, name, cost, price_1, tax_rate
FROM inventory WHERE id = ANY(%s) AND is_active = true
ORDER BY id FOR UPDATE
""",
(inv_ids,),
)
inv_map = {r[0]: r for r in cur.fetchall()}
enriched = []
for it in items:
inv_id = it["inventory_id"]
inv = inv_map.get(inv_id)
if not inv:
raise ValueError(f"Inventory item {inv_id} not found")
qty = int(it.get("quantity", 1))
unit_price = float(it.get("unit_price", inv[4] or 0))
discount_pct = float(it.get("discount_pct", 0))
tax_rate = float(it.get("tax_rate", inv[5] or 0.16))
unit_cost = float(inv[3]) if inv[3] else 0
enriched.append({
"inventory_id": inv_id,
"part_number": inv[1],
"name": inv[2],
"quantity": qty,
"unit_price": unit_price,
"unit_cost": unit_cost,
"discount_pct": discount_pct,
"tax_rate": tax_rate,
})
totals = calculate_totals(enriched)
# Insert sale
cur.execute(
"""
INSERT INTO sales
(customer_id, employee_id, register_id, sale_type, payment_method,
subtotal, discount_total, tax_total, total, amount_paid, change_given,
status, notes, source, external_order_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'completed', %s, 'mercadolibre', %s)
RETURNING id
""",
(
customer_id,
employee_id,
register_id,
sale_type,
payment_method,
totals["subtotal"],
totals["discount_total"],
totals["tax_total"],
totals["total"],
amount_paid,
0,
notes,
sale_data.get("external_order_id"),
),
)
sale_id = cur.fetchone()[0]
# Insert sale_items
for it in totals["items"]:
cur.execute(
"""
INSERT INTO sale_items
(sale_id, inventory_id, part_number, name, quantity, unit_price,
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
sale_id,
it["inventory_id"],
it["part_number"],
it["name"],
it["quantity"],
it["unit_price"],
it.get("unit_cost", 0),
it.get("discount_pct", 0),
it.get("discount_amount", 0),
it.get("tax_rate", 0.16),
it.get("tax_amount", 0),
it["subtotal"],
),
)
# Deduct inventory
for it in enriched:
inventory_record_sale(
tenant_conn, it["inventory_id"], it["quantity"], reference=f"ML sale {sale_id}"
)
tenant_conn.commit()
cur.close()
return {"id": sale_id, **totals}

View File

@@ -136,15 +136,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
Expected columns (case-insensitive, whitespace-tolerant):
part_number, stock, price
Optional:
min_order, warehouse_location, currency
name, min_order, warehouse_location, currency
Resolution rules:
- part_number matches `parts.oem_part_number` exactly (case-sensitive).
- Parts not found in the master catalog are skipped and reported.
- Existing rows for (bodega_id, part_id, warehouse_location) are updated
via UPSERT; new rows are inserted.
- part_number matches `parts.oem_part_number` or `part_cross_references.cross_reference_number`.
- If matched → linked to catalog (part_id set, seller fields NULL).
- If NOT matched → created as seller listing (part_id NULL, seller_part_number set).
- Existing rows are updated via UPSERT on the composite unique key.
Returns a summary dict: {ok, inserted, updated, skipped, errors}
Returns a summary dict: {ok, inserted, updated, skipped, errors, oem_count, seller_count}
"""
reader = csv.DictReader(io.StringIO(csv_text))
# Normalize header names
@@ -166,9 +166,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
cur.close()
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
# Pre-load cross-reference map for fast lookup
cur.execute("SELECT cross_reference_number, oem_part_id FROM part_cross_references")
xref_map = {row[0].strip(): row[1] for row in cur.fetchall()}
inserted = 0
updated = 0
skipped = 0
oem_count = 0
seller_count = 0
errors = []
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
@@ -176,6 +182,7 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
part_number = norm.get('part_number', '')
stock_str = norm.get('stock', '0')
price_str = norm.get('price', '0')
part_name = norm.get('name', '')
if not part_number:
errors.append(f'Fila {i}: part_number vacio')
@@ -190,17 +197,20 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
skipped += 1
continue
# Resolve part_number → part_id
# Resolve part_number → part_id (OEM catalog or cross-reference)
part_id = None
cur.execute(
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
(part_number,)
)
row_part = cur.fetchone()
if not row_part:
errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo')
skipped += 1
continue
if row_part:
part_id = row_part[0]
else:
# Try cross-reference
xref_id = xref_map.get(part_number)
if xref_id:
part_id = xref_id
# Resolve user_id from the bodega (use bodega_id as fallback if null)
user_id = norm.get('user_id') or bodega_id # backward compat
@@ -213,24 +223,48 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
currency = (norm.get('currency') or 'MXN').upper()
min_order = int(norm.get('min_order') or 1)
# UPSERT on (user_id, part_id, warehouse_location) — the existing
# unique constraint. Don't block if user_id FK fails.
# UPSERT on composite unique (bodega_id, part_id, seller_part_number, warehouse_location)
try:
if part_id:
# OEM-matched listing
cur.execute("""
INSERT INTO warehouse_inventory
(user_id, part_id, price, stock_quantity, min_order_quantity,
(user_id, part_id, seller_part_number, seller_part_name,
price, stock_quantity, min_order_quantity,
warehouse_location, bodega_id, currency, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (user_id, part_id, warehouse_location)
VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
DO UPDATE SET
price = EXCLUDED.price,
stock_quantity = EXCLUDED.stock_quantity,
min_order_quantity = EXCLUDED.min_order_quantity,
bodega_id = EXCLUDED.bodega_id,
user_id = EXCLUDED.user_id,
currency = EXCLUDED.currency,
updated_at = NOW()
RETURNING (xmax = 0) AS inserted
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
oem_count += 1
else:
# Seller listing (no catalog match)
cur.execute("""
INSERT INTO warehouse_inventory
(user_id, part_id, seller_part_number, seller_part_name,
price, stock_quantity, min_order_quantity,
warehouse_location, bodega_id, currency, updated_at)
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
DO UPDATE SET
price = EXCLUDED.price,
stock_quantity = EXCLUDED.stock_quantity,
min_order_quantity = EXCLUDED.min_order_quantity,
seller_part_name = EXCLUDED.seller_part_name,
user_id = EXCLUDED.user_id,
currency = EXCLUDED.currency,
updated_at = NOW()
RETURNING (xmax = 0) AS inserted
""", (user_id, part_number, part_name or part_number, price, stock, min_order, location, bodega_id, currency))
seller_count += 1
was_insert = cur.fetchone()[0]
if was_insert:
inserted += 1
@@ -250,6 +284,8 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
'inserted': inserted,
'updated': updated,
'skipped': skipped,
'oem_count': oem_count,
'seller_count': seller_count,
'errors': errors[:20], # cap to avoid huge responses
'total_errors': len(errors),
}
@@ -262,70 +298,114 @@ def search_inventory(master_conn, *, query: str = None, brand: str = None,
Returns parts WITH stock > 0 from VERIFIED bodegas only.
Aggregates identical parts across bodegas so the buyer sees each part once
with a list of bodegas that have it in stock.
Includes both OEM-matched parts (part_id IS NOT NULL) and seller listings
(part_id IS NULL) in a single unified result set.
"""
cur = master_conn.cursor()
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
params = []
like = f'%{query}%' if query else None
city_lower = city.lower() if city else None
params_common = []
# Build city filter once
city_clause = ""
if city_lower:
city_clause = "AND LOWER(b.city) = LOWER(%s)"
params_common.append(city)
# ─── Part A: OEM-matched parts (JOIN with parts catalog) ──────────
clauses_oem = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NOT NULL"]
params_oem = []
if query:
clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
like = f'%{query}%'
params.extend([like, like, like])
clauses_oem.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
params_oem.extend([like, like, like])
if brand:
# Search by vehicle brand via vehicle_parts → model_year_engine → models → brands.
# Too slow for this MVP. Instead, match on aftermarket manufacturer name.
clauses.append("""
clauses_oem.append("""
EXISTS (
SELECT 1 FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s)
)
""")
params.append(brand)
params_oem.append(brand)
if city:
clauses.append("LOWER(b.city) = LOWER(%s)")
params.append(city)
where_oem = " AND ".join(clauses_oem)
where_sql = " AND ".join(clauses)
# ─── Part B: Seller listings (no parts catalog join) ──────────────
clauses_seller = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NULL"]
params_seller = []
cur.execute(f"""
if query:
clauses_seller.append("(wi.seller_part_number ILIKE %s OR wi.seller_part_name ILIKE %s)")
params_seller.extend([like, like])
where_seller = " AND ".join(clauses_seller)
# Combined query with UNION ALL
sql = f"""
SELECT * FROM (
-- OEM-matched parts
SELECT
p.id_part,
p.oem_part_number,
p.id_part AS id,
p.oem_part_number AS part_number,
COALESCE(p.name_es, p.name_part) AS name,
p.image_url,
COUNT(DISTINCT b.id_bodega) AS bodega_count,
MIN(wi.price) AS min_price,
MAX(wi.price) AS max_price,
SUM(wi.stock_quantity) AS total_stock,
-- List of bodega names that have this part in stock
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
'oem' AS listing_type
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
JOIN parts p ON p.id_part = wi.part_id
WHERE {where_sql}
WHERE {where_oem} {city_clause}
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
UNION ALL
-- Seller listings
SELECT
wi.id_inventory AS id,
wi.seller_part_number AS part_number,
wi.seller_part_name AS name,
NULL AS image_url,
COUNT(DISTINCT b.id_bodega) AS bodega_count,
MIN(wi.price) AS min_price,
MAX(wi.price) AS max_price,
SUM(wi.stock_quantity) AS total_stock,
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
'seller' AS listing_type
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
WHERE {where_seller} {city_clause}
GROUP BY wi.id_inventory, wi.seller_part_number, wi.seller_part_name
) combined
ORDER BY total_stock DESC
LIMIT %s
""", params + [limit])
"""
all_params = params_oem + params_common + params_seller + params_common + [limit]
cur.execute(sql, all_params)
rows = cur.fetchall()
cur.close()
return [
{
'id_part': r[0],
'oem_part_number': r[1],
'id': r[0],
'part_number': r[1],
'name': r[2],
'image_url': r[3],
'bodega_count': r[4],
'min_price': float(r[5]) if r[5] is not None else None,
'max_price': float(r[6]) if r[6] is not None else None,
'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar',
'bodega_names': r[8], # may expose; adjust if sensitive
'bodega_names': r[8],
'listing_type': r[9],
}
for r in rows
]
@@ -358,6 +438,33 @@ def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
]
def get_bodegas_with_listing(master_conn, wi_id: int) -> list[dict]:
"""Return the list of verified bodegas that have a specific seller listing
(warehouse_inventory row with part_id IS NULL) in stock.
"""
cur = master_conn.cursor()
cur.execute("""
SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone,
wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
WHERE wi.id_inventory = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
ORDER BY wi.price ASC
""", (wi_id,))
rows = cur.fetchall()
cur.close()
return [
{
'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3],
'price': float(r[4]) if r[4] is not None else None,
'stock_hint': 'En stock',
'min_order': r[6] or 1,
'currency': r[7] or 'MXN',
}
for r in rows
]
# ═══════════════════════════════════════════════════════════════════════════
# PURCHASE ORDERS
# ═══════════════════════════════════════════════════════════════════════════
@@ -397,12 +504,15 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
# Insert items
total = 0.0
for item in items:
part_id = int(item['part_id'])
part_id = item.get('part_id')
wi_id = item.get('wi_id')
quantity = int(item['quantity'])
if quantity < 1:
continue
# Lookup part info + price
if part_id:
# OEM-matched part
part_id = int(part_id)
cur.execute("""
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
FROM parts p
@@ -420,10 +530,35 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
cur.execute("""
INSERT INTO purchase_order_items
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, FALSE)
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
elif wi_id:
# Seller listing (no catalog match)
wi_id = int(wi_id)
cur.execute("""
SELECT seller_part_number, seller_part_name, price
FROM warehouse_inventory
WHERE id_inventory = %s AND bodega_id = %s LIMIT 1
""", (wi_id, bodega_id))
r = cur.fetchone()
if not r:
continue
seller_pn, seller_name, db_price = r
unit_price = float(item.get('unit_price') or db_price or 0)
subtotal = round(unit_price * quantity, 2)
total += subtotal
cur.execute("""
INSERT INTO purchase_order_items
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, TRUE)
""", (po_id, seller_pn, seller_name or seller_pn, quantity, unit_price, subtotal, item.get('notes')))
else:
continue
# Update header total
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
(round(total, 2), po_id))

View File

@@ -0,0 +1,233 @@
"""MercadoLibre API client with OAuth2 auto-refresh.
Endpoints used:
- GET /users/me
- POST /items
- PUT /items/{id}
- GET /items/{id}
- GET /orders/search
- GET /orders/{id}
- POST /shipments/{id}/dispatch
- POST /oauth/token
References:
https://developers.mercadolibre.com.ar/es_ar/api-docs-es
"""
import time
import requests
from typing import Optional
BASE_URL = "https://api.mercadolibre.com"
AUTH_URL = "https://api.mercadolibre.com/oauth/token"
class MeliError(Exception):
def __init__(self, message, status_code=None, response_body=None):
super().__init__(message)
self.status_code = status_code
self.response_body = response_body
class MeliAuthError(MeliError):
pass
class MeliService:
def __init__(
self,
access_token: str,
refresh_token: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
):
self.access_token = access_token
self.refresh_token = refresh_token
self.client_id = client_id
self.client_secret = client_secret
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Bearer {access_token}"})
# ─── Low-level request ───────────────────────────────────────────────
def _request(
self,
method: str,
path: str,
params: Optional[dict] = None,
json_payload: Optional[dict] = None,
retry_on_401: bool = True,
) -> dict:
url = f"{BASE_URL}{path}"
resp = self._session.request(
method, url, params=params, json=json_payload, timeout=30
)
if resp.status_code == 401 and retry_on_401 and self.refresh_token:
self._refresh_token()
# Retry once with new token
self._session.headers.update(
{"Authorization": f"Bearer {self.access_token}"}
)
resp = self._session.request(
method, url, params=params, json=json_payload, timeout=30
)
if resp.status_code == 401:
raise MeliAuthError(
"Unauthorized. Token may be expired or invalid.",
status_code=401,
response_body=resp.text,
)
if not resp.ok:
raise MeliError(
f"Meli API error {resp.status_code}: {resp.text}",
status_code=resp.status_code,
response_body=resp.text,
)
# Some endpoints return 204 No Content
if resp.status_code == 204:
return {}
try:
return resp.json()
except Exception:
return {"raw": resp.text}
def _refresh_token(self) -> dict:
if not self.client_id or not self.client_secret or not self.refresh_token:
raise MeliAuthError("Missing credentials for token refresh")
payload = {
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": self.refresh_token,
}
resp = requests.post(AUTH_URL, data=payload, timeout=30)
if not resp.ok:
raise MeliAuthError(
f"Token refresh failed: {resp.status_code} {resp.text}",
status_code=resp.status_code,
response_body=resp.text,
)
data = resp.json()
self.access_token = data["access_token"]
if "refresh_token" in data:
self.refresh_token = data["refresh_token"]
return data
# ─── Auth / User ─────────────────────────────────────────────────────
def get_user(self) -> dict:
return self._request("GET", "/users/me")
@staticmethod
def exchange_code(
code: str, client_id: str, client_secret: str, redirect_uri: str
) -> dict:
"""Exchange authorization code for tokens."""
payload = {
"grant_type": "authorization_code",
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
}
resp = requests.post(AUTH_URL, data=payload, timeout=30)
if not resp.ok:
raise MeliAuthError(
f"Code exchange failed: {resp.status_code} {resp.text}",
status_code=resp.status_code,
response_body=resp.text,
)
return resp.json()
# ─── Items (listings) ────────────────────────────────────────────────
def create_item(self, payload: dict) -> dict:
return self._request("POST", "/items", json_payload=payload)
def update_item(self, item_id: str, payload: dict) -> dict:
return self._request("PUT", f"/items/{item_id}", json_payload=payload)
def get_item(self, item_id: str) -> dict:
return self._request("GET", f"/items/{item_id}")
def pause_item(self, item_id: str) -> dict:
return self.update_item(item_id, {"status": "paused"})
def activate_item(self, item_id: str) -> dict:
return self.update_item(item_id, {"status": "active"})
def close_item(self, item_id: str) -> dict:
return self.update_item(item_id, {"status": "closed"})
# ─── Categories ──────────────────────────────────────────────────────
def get_category(self, category_id: str) -> dict:
return self._request("GET", f"/categories/{category_id}")
def search_categories(self, site_id: str, query: str) -> dict:
# ML does not have a direct category search; we use the predictor
return self._request(
"GET",
f"/sites/{site_id}/domain_discovery/search",
params={"q": query},
)
def get_category_attributes(self, category_id: str) -> list:
return self._request("GET", f"/categories/{category_id}/attributes")
# ─── Orders ──────────────────────────────────────────────────────────
def get_orders(
self,
seller_id: str,
status: Optional[str] = None,
date_from: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> dict:
params = {"seller": seller_id, "limit": limit, "offset": offset}
if status:
params["order.status"] = status
if date_from:
params["order.date_created.from"] = date_from
return self._request("GET", "/orders/search", params=params)
def get_order(self, order_id: str) -> dict:
return self._request("GET", f"/orders/{order_id}")
# ─── Shipments ───────────────────────────────────────────────────────
def get_shipment(self, shipment_id: str) -> dict:
return self._request("GET", f"/shipments/{shipment_id}")
def mark_ready_to_ship(self, shipment_id: str) -> dict:
return self._request(
"POST",
f"/shipments/{shipment_id}/dispatch",
json_payload={},
)
# ─── Notifications / Webhooks validation ─────────────────────────────
@staticmethod
def validate_webhook_signature(
secret: str, data: bytes, signature_header: str
) -> bool:
"""Validate MercadoLibre webhook signature.
ML sends: X-Signature: sha256=<hex_hmac>
"""
import hmac
import hashlib
if not signature_header or "=" not in signature_header:
return False
_, expected_hex = signature_header.split("=", 1)
computed = hmac.new(
secret.encode(), data, hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed, expected_hex)

186
pos/services/part_kits.py Normal file
View File

@@ -0,0 +1,186 @@
"""
Smart part kits — automatic cross-sell recommendations.
When a customer adds a part to their quotation, suggest related
parts that are typically needed together for a complete job.
"""
# Spanish keywords in part name → related parts to suggest (in Spanish)
# These appear after a successful "cotizar" command.
KIT_SUGGESTIONS = {
"balata": ["disco de freno", "líquido de frenos", "balero de rueda"],
"disco de freno": ["balata", "líquido de frenos"],
"alternador": ["banda serpentina", "batería", "regulador de alternador"],
"batería": ["alternador", "cable de bujía"],
"marcha": ["batería", "solenoide de marcha"],
"bujía": ["bobina de encendido", "filtro de aire", "filtro de gasolina"],
"bobina": ["bujía", "cable de bujía"],
"bomba de agua": ["termostato", "refrigerante", "manguera de radiador"],
"radiador": ["manguera de radiador", "termostato", "tapón de radiador"],
"termostato": ["refrigerante", "manguera de radiador"],
"amortiguador": ["base de amortiguador", "goma de suspensión", "rótula"],
"rótula": ["terminal de dirección", "brazo de suspensión", "bujes"],
"terminal": ["rótula", "brazo de suspensión"],
"filtro de aceite": ["filtro de aire", "filtro de gasolina", "filtro de habitáculo"],
"filtro de aire": ["filtro de aceite", "filtro de gasolina", "bujía"],
"filtro de gasolina": ["filtro de aire", "filtro de aceite", "inyector"],
"clutch": ["collarín", "disco de clutch", "plato de presión"],
"collarín": ["clutch", "disco de clutch"],
"banda de distribución": ["bomba de agua", "tensor", "polea loca"],
"banda serpentina": ["tensor de banda", "polea loca"],
"foco": ["foco trasero", "cuarto"],
"faro": ["foco trasero", "cuarto"],
"aceite": ["filtro de aceite", "filtro de aire"],
}
def get_kit_suggestions(part_name: str) -> list:
"""Return related part names for a given part (Spanish)."""
if not part_name:
return []
name_lower = part_name.lower()
for keyword, related in KIT_SUGGESTIONS.items():
if keyword in name_lower:
return related
return []
def build_kit_text(part_name: str) -> str:
"""Build a WhatsApp-friendly kit suggestion text.
Returns empty string if no kit is found.
"""
suggestions = get_kit_suggestions(part_name)
if not suggestions:
return ""
items = "\n".join(f"{s.title()}" for s in suggestions[:3])
return (
"\n\n🔧 *¿Ya que estás en eso, checa si también necesitas:*\n"
+ items
+ '\n\n_Escribe la parte que te interese y la agregamos._'
)
# ── Urgency detection ────────────────────────────────────────────────
URGENCY_KEYWORDS = [
"urgente", "urgencia", "emergencia", "ya", "ahora", "hoy",
"lo necesito", "se me paro", "no arranca", "no jala",
"rapido", "apúrate", "apurate", "prisa", "de volada",
"para hoy", "para ahora", "lo mas pronto", "lo más pronto",
"inmediato", "express", "exprés",
]
def is_urgent(text: str) -> bool:
"""Detect if the customer message signals urgency."""
if not text:
return False
t = text.lower()
return any(kw in t for kw in URGENCY_KEYWORDS)
def urgency_note() -> str:
return (
"\n\n⚡ NOTA DE URGENCIA: El cliente necesita la pieza lo antes posible. "
"Prioriza stock local y ofrece entrega express (2-4 horas) o recolección inmediata en tienda. "
"Si no hay stock exacto, ofrece alternativa disponible inmediatamente."
)
# ── Abandoned quotation follow-up ────────────────────────────────────
FOLLOW_UP_MINUTES = 15
def should_send_followup(phone: str, tenant_conn) -> str:
"""Check if we should send a follow-up message for an abandoned quotation.
Returns the follow-up text if yes, empty string if no.
"""
if not tenant_conn or not phone:
return ""
try:
cur = tenant_conn.cursor()
# 1. Check if there's an active quotation for this phone
cur.execute("""
SELECT id FROM quotations
WHERE notes LIKE %s AND status = 'active'
ORDER BY created_at DESC LIMIT 1
""", (f'%WA:{phone}%',))
row = cur.fetchone()
if not row:
cur.close()
return ""
# 2. Check last bot message mentioning "cotización" or "cotizar"
cur.execute("""
SELECT created_at, message_text
FROM whatsapp_messages
WHERE phone = %s AND direction = 'outgoing'
AND (message_text ILIKE '%cotización%' OR message_text ILIKE '%cotizar%')
ORDER BY created_at DESC LIMIT 1
""", (phone,))
last_quote_msg = cur.fetchone()
cur.close()
if not last_quote_msg:
return ""
from datetime import datetime, timezone
last_time = last_quote_msg[0]
now = datetime.now(timezone.utc)
if last_time.tzinfo is None:
last_time = last_time.replace(tzinfo=timezone.utc)
minutes_since = (now - last_time).total_seconds() / 60
if minutes_since >= FOLLOW_UP_MINUTES:
return (
"👋 *¿Todo bien?*\n\n"
"Veo que estabas armando tu cotización. ¿Te falta algo más o quieres que te la envíe ahora?\n\n"
"_Escribe *enviar cotización* para ver el total, o dime si necesitas otra parte._"
)
except Exception as e:
print(f"[WA-AI] Follow-up check failed: {e}")
return ""
# ── Customer purchase history awareness ──────────────────────────────
def get_purchase_history(phone: str, tenant_conn, limit: int = 3) -> str:
"""Build a short text summary of recent confirmed quotations for this customer.
Returns empty string if no history.
"""
if not tenant_conn or not phone:
return ""
try:
cur = tenant_conn.cursor()
cur.execute("""
SELECT q.id, q.created_at, q.total,
ARRAY_AGG(qi.name ORDER BY qi.name) AS items
FROM quotations q
JOIN quotation_items qi ON qi.quotation_id = q.id
WHERE q.notes LIKE %s AND q.status = 'converted'
GROUP BY q.id, q.created_at, q.total
ORDER BY q.created_at DESC
LIMIT %s
""", (f'%WA:{phone}%', limit))
rows = cur.fetchall()
cur.close()
if not rows:
return ""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
parts = []
for qid, created, total, items in rows:
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
months_ago = (now - created).days // 30
time_str = f"hace {months_ago} meses" if months_ago > 0 else "recientemente"
item_list = ", ".join(items[:3])
parts.append(f"- {time_str}: {item_list} (total ${float(total):,.2f})")
return "HISTORIAL DE COMPRAS DEL CLIENTE:\n" + "\n".join(parts)
except Exception as e:
print(f"[WA-AI] Purchase history failed: {e}")
return ""

127
pos/services/quote_image.py Normal file
View File

@@ -0,0 +1,127 @@
import io
import base64
from PIL import Image, ImageDraw, ImageFont
def generate_quote_image(quote_items, totals, tenant_name="Autopartes", logo_text="NEXUS"):
"""
Generate a visually appealing quote image.
quote_items: list of dicts with keys: name, sku, qty, price, total
totals: dict with keys: subtotal, tax, total
Returns: base64 encoded PNG string
"""
# Dimensions
WIDTH = 800
HEADER_H = 120
FOOTER_H = 100
ITEM_H = 60
PADDING = 30
total_height = HEADER_H + len(quote_items) * ITEM_H + FOOTER_H + PADDING * 3
# Colors
BG_COLOR = (250, 250, 252)
PRIMARY = (0, 82, 155) # Dark blue
ACCENT = (230, 57, 70) # Red accent
TEXT_DARK = (30, 30, 30)
TEXT_MED = (80, 80, 80)
TEXT_LIGHT = (150, 150, 150)
WHITE = (255, 255, 255)
ROW_ALT = (245, 247, 250)
img = Image.new('RGB', (WIDTH, total_height), BG_COLOR)
draw = ImageDraw.Draw(img)
# Try to load fonts, fallback to default
try:
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
font_sub = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)
font_item = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
font_bold = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22)
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
except Exception:
font_title = ImageFont.load_default()
font_sub = font_title
font_item = font_title
font_bold = font_title
font_small = font_title
# --- Header ---
draw.rectangle([0, 0, WIDTH, HEADER_H], fill=PRIMARY)
# Logo text
draw.text((PADDING, 25), logo_text, font=font_title, fill=WHITE)
draw.text((PADDING, 70), tenant_name, font=font_sub, fill=(200, 210, 230))
# Date and Quote label
from datetime import datetime
date_str = datetime.now().strftime("%d/%m/%Y %H:%M")
draw.text((WIDTH - PADDING - 200, 30), "COTIZACIÓN", font=font_title, fill=WHITE)
draw.text((WIDTH - PADDING - 200, 75), date_str, font=font_sub, fill=(200, 210, 230))
# --- Items Header ---
y = HEADER_H + PADDING
draw.rectangle([PADDING, y, WIDTH - PADDING, y + ITEM_H], fill=(230, 235, 240))
draw.text((PADDING + 10, y + 18), "PRODUCTO", font=font_bold, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 220, y + 18), "CANT.", font=font_bold, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 130, y + 18), "P.UNIT", font=font_bold, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 50, y + 18), "TOTAL", font=font_bold, fill=TEXT_DARK)
y += ITEM_H
# --- Items ---
for idx, item in enumerate(quote_items):
row_y = y + idx * ITEM_H
bg = ROW_ALT if idx % 2 == 0 else WHITE
draw.rectangle([PADDING, row_y, WIDTH - PADDING, row_y + ITEM_H], fill=bg)
name = item.get('name', 'Producto')
sku = item.get('sku', '')
qty = str(item.get('qty', 1))
price = f"${item.get('price', 0):,.2f}"
total = f"${item.get('total', 0):,.2f}"
# Truncate name if too long
name_display = name
if len(name_display) > 35:
name_display = name_display[:32] + "..."
draw.text((PADDING + 10, row_y + 8), name_display, font=font_item, fill=TEXT_DARK)
draw.text((PADDING + 10, row_y + 32), f"SKU: {sku}", font=font_small, fill=TEXT_MED)
draw.text((WIDTH - PADDING - 220, row_y + 18), qty, font=font_item, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 130, row_y + 18), price, font=font_item, fill=TEXT_DARK)
draw.text((WIDTH - PADDING - 50, row_y + 18), total, font=font_item, fill=TEXT_DARK)
y += len(quote_items) * ITEM_H + PADDING
# --- Totals ---
draw.line([(PADDING, y), (WIDTH - PADDING, y)], fill=(200, 200, 200), width=2)
y += 20
subtotal = totals.get('subtotal', 0)
tax = totals.get('tax', 0)
total = totals.get('total', 0)
draw.text((WIDTH - PADDING - 300, y), "Subtotal:", font=font_sub, fill=TEXT_MED)
draw.text((WIDTH - PADDING - 50, y), f"${subtotal:,.2f}", font=font_sub, fill=TEXT_DARK)
y += 30
draw.text((WIDTH - PADDING - 300, y), "IVA (16%):", font=font_sub, fill=TEXT_MED)
draw.text((WIDTH - PADDING - 50, y), f"${tax:,.2f}", font=font_sub, fill=TEXT_DARK)
y += 35
draw.text((WIDTH - PADDING - 300, y), "TOTAL:", font=font_bold, fill=ACCENT)
draw.text((WIDTH - PADDING - 50, y), f"${total:,.2f}", font=font_bold, fill=ACCENT)
y += 50
# --- Footer ---
draw.rectangle([0, total_height - FOOTER_H, WIDTH, total_height], fill=PRIMARY)
footer_text = "Validez: 5 días hábiles | Envíos a todo México | Contacto: ventas@nexusautoparts.com"
draw.text((PADDING, total_height - FOOTER_H + 35), footer_text, font=font_small, fill=(200, 210, 230))
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
return base64.b64encode(buffer.read()).decode('utf-8')

View File

@@ -135,41 +135,34 @@ def _ensure_sessions_table(tenant_conn):
cur.close()
def set_last_shown_part(phone, part_info):
def set_last_shown_part(tenant_conn, phone, part_info):
"""Store the last part shown to this phone number.
part_info: dict with keys inventory_id, part_number, name, brand,
price, stock, unit
"""
# In-memory fallback for when tenant_conn is not available
from tenant_db import get_tenant_conn
try:
conn = get_tenant_conn(11)
_ensure_sessions_table(conn)
cur = conn.cursor()
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
import json
cur.execute("""
INSERT INTO whatsapp_sessions (phone, last_shown, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW()
""", (phone, json.dumps(part_info)))
conn.commit()
tenant_conn.commit()
cur.close()
conn.close()
except Exception as e:
print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}")
def get_last_shown_part(phone):
from tenant_db import get_tenant_conn
def get_last_shown_part(tenant_conn, phone):
try:
conn = get_tenant_conn(11)
_ensure_sessions_table(conn)
cur = conn.cursor()
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,))
row = cur.fetchone()
cur.close()
conn.close()
if row and row[0]:
return row[0]
except Exception as e:
@@ -177,54 +170,45 @@ def get_last_shown_part(phone):
return None
def clear_last_shown(phone):
from tenant_db import get_tenant_conn
def clear_last_shown(tenant_conn, phone):
try:
conn = get_tenant_conn(11)
_ensure_sessions_table(conn)
cur = conn.cursor()
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
conn.commit()
tenant_conn.commit()
cur.close()
conn.close()
except Exception as e:
print(f"[WA-SESSION] Failed to clear last_shown for {phone}: {e}")
def set_vehicle(phone, vehicle):
def set_vehicle(tenant_conn, phone, vehicle):
"""Store the detected vehicle for this phone number.
vehicle: dict with keys brand, model, year
"""
from tenant_db import get_tenant_conn
try:
conn = get_tenant_conn(11)
_ensure_sessions_table(conn)
cur = conn.cursor()
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
import json
cur.execute("""
INSERT INTO whatsapp_sessions (phone, vehicle, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW()
""", (phone, json.dumps(vehicle)))
conn.commit()
tenant_conn.commit()
cur.close()
conn.close()
except Exception as e:
print(f"[WA-SESSION] Failed to persist vehicle for {phone}: {e}")
def get_vehicle(phone):
def get_vehicle(tenant_conn, phone):
"""Retrieve the stored vehicle for this phone number."""
from tenant_db import get_tenant_conn
try:
conn = get_tenant_conn(11)
_ensure_sessions_table(conn)
cur = conn.cursor()
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,))
row = cur.fetchone()
cur.close()
conn.close()
if row and row[0]:
return row[0]
except Exception as e:
@@ -232,17 +216,14 @@ def get_vehicle(phone):
return None
def clear_session(phone):
def clear_session(tenant_conn, phone):
"""Clear all session data (last_shown + vehicle) for this phone."""
from tenant_db import get_tenant_conn
try:
conn = get_tenant_conn(11)
_ensure_sessions_table(conn)
cur = conn.cursor()
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
conn.commit()
tenant_conn.commit()
cur.close()
conn.close()
except Exception as e:
print(f"[WA-SESSION] Failed to clear session for {phone}: {e}")

View File

@@ -105,6 +105,7 @@ def process_incoming(webhook_data):
# - For 'text' messages → conversation or extendedTextMessage
# - For 'image'/'video' → the caption (may be empty)
# - For 'audio' → empty (filled in later by Whisper transcription)
# - For 'location' → synthetic text with coordinates
if media_kind == 'text':
text = (
message.get('conversation', '')
@@ -114,6 +115,10 @@ def process_incoming(webhook_data):
else:
text = media_caption
# Location fields (from bridge classification)
latitude = data.get('latitude')
longitude = data.get('longitude')
return {
'phone': phone,
'jid': remote_jid,
@@ -125,4 +130,20 @@ def process_incoming(webhook_data):
'media_mimetype': media_mimetype,
'is_voice_note': is_voice_note,
'push_name': push_name,
'latitude': latitude,
'longitude': longitude,
}
def send_image(phone, caption, base64_image, bridge_url=None):
"""Send an image message via the Baileys bridge."""
url = _get_url(bridge_url)
try:
return requests.post(
f'{url}/send-image',
headers=HEADERS,
json={'phone': phone, 'caption': caption, 'base64': base64_image},
timeout=15
).json()
except Exception as e:
return {'error': str(e)}

View File

@@ -732,6 +732,20 @@
font-size: var(--text-caption);
}
.btn--meli {
background: #FFE600;
color: #2D3277;
border-color: transparent;
font-weight: 700;
}
.btn--meli:hover {
background: #e6cf00;
color: #1a1f5c;
}
.btn--meli svg {
stroke: currentColor;
}
/* =========================================================================
DATA TABLE
========================================================================= */
@@ -1261,7 +1275,7 @@
.inv-field label {
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
color: var(--color-text-secondary);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
@@ -1282,6 +1296,23 @@
box-shadow: 0 0 0 2px var(--color-primary-muted);
}
.inv-field select {
padding: var(--space-2) var(--space-3);
background: var(--color-surface-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--text-body-sm);
width: 100%;
}
.inv-field select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-muted);
}
.count-row {
display: flex;
gap: var(--space-2);
@@ -1301,3 +1332,48 @@
/* History table inside modal */
.inv-modal .data-table { width: 100%; }
/* ─── MercadoLibre Category Autocomplete ─────────────────────────────── */
.meli-cat-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 200;
background: var(--color-surface-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
max-height: 240px;
overflow-y: auto;
margin-top: 4px;
}
.meli-cat-item {
padding: 10px 14px;
cursor: pointer;
font-size: var(--text-body-sm);
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border);
transition: background var(--transition-fast);
display: flex;
justify-content: space-between;
align-items: center;
}
.meli-cat-item:last-child { border-bottom: none; }
.meli-cat-item:hover,
.meli-cat-item.is-active {
background: var(--color-surface-2);
}
.meli-cat-item .cat-id {
font-size: var(--text-caption);
color: var(--color-text-muted);
margin-left: 8px;
font-family: var(--font-mono);
}
.meli-cat-loading,
.meli-cat-empty {
padding: 12px 14px;
font-size: var(--text-caption);
color: var(--color-text-muted);
text-align: center;
}

View File

@@ -18,14 +18,22 @@ body {
SIDEBAR — Glass treatment
========================================================================== */
/* Prevent flash/stun while sidebar.js replaces static sidebar markup */
.sidebar,
.pos-sidebar {
opacity: 0;
transition: opacity 0.15s ease;
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border) !important;
}
body.sidebar-ready .sidebar,
body.sidebar-ready .pos-sidebar {
opacity: 1;
}
.sidebar__logo {
position: relative;
}

View File

@@ -1 +0,0 @@
!function(){"use strict";var e=localStorage.getItem("pos_token");if(e){try{var t=JSON.parse(atob(e.split(".")[1]));if(1e3*t.exp<Date.now())return localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee"),void(window.location.href="/pos/login")}catch(e){return localStorage.removeItem("pos_token"),void(window.location.href="/pos/login")}var o={};try{o=JSON.parse(localStorage.getItem("pos_employee")||"{}")}catch(e){}var n=o.name||t.name||"Usuario",a=o.role||t.role||"",r="function"==typeof window.t?window.t:function(e){return e},i={owner:r("role_owner"),admin:r("role_admin"),cashier:r("role_cashier"),warehouse:r("role_warehouse"),accountant:r("role_accountant")}[a]||a,c=n.split(" ").map((function(e){return e[0]})).join("").toUpperCase().substring(0,2);document.querySelectorAll(".sidebar__user-name").forEach((function(e){e.textContent=n})),document.querySelectorAll(".sidebar__user-role").forEach((function(e){e.textContent=i})),document.querySelectorAll(".sidebar__user-avatar, .sidebar__avatar").forEach((function(e){e.textContent=c})),document.querySelectorAll(".profile-info__name").forEach((function(e){e.textContent=n})),document.querySelectorAll(".profile-info__role").forEach((function(e){e.textContent=i})),document.querySelectorAll(".theme-bar__label").forEach((function(e){-1===e.textContent.indexOf("Usuario:")&&-1===e.textContent.indexOf("Sucursal")||(e.textContent="Sucursal Principal — "+n)})),document.querySelectorAll(".status-bar .user-name, .status-info span").forEach((function(e){var t=e.textContent;["Hugo M.","Hugo García","J. Ramírez","José Ramírez","Carlos M.","Admin"].forEach((function(o){-1!==t.indexOf(o)&&(e.textContent=t.replace(o,n))}))}));var l=window.location.pathname;document.querySelectorAll(".nav-item, .nav-link").forEach((function(e){e.classList.remove("is-active","active"),(e.getAttribute("href")||"")===l&&(e.classList.add("is-active"),e.classList.add("active"))})),window.posLogout=function(){localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee"),localStorage.removeItem("pos_tenant_id"),localStorage.removeItem("pos_cart"),window.location.href="/pos/login"},document.querySelectorAll('[data-action="logout"], .btn-logout, .logout-btn').forEach((function(e){e.addEventListener("click",(function(e){e.preventDefault(),posLogout()}))}));var s=localStorage.getItem("pos_theme");s||(s=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"industrial":"modern"),document.documentElement.setAttribute("data-theme",s),document.querySelectorAll(".theme-bar").forEach((function(e){e.style.display="none"})),window.posSetTheme=function(e){document.documentElement.setAttribute("data-theme",e),localStorage.setItem("pos_theme",e)},window.setTheme=window.posSetTheme,window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",(function(e){if(!localStorage.getItem("pos_theme")){var t=e.matches?"industrial":"modern";document.documentElement.setAttribute("data-theme",t)}})),setTimeout((function(){document.documentElement.setAttribute("data-theme",s)}),100),window.POS_USER={name:n,role:a,roleLabel:i,initials:c,token:e,tenantId:t.tenant_id,employeeId:t.employee_id,branchId:t.branch_id,permissions:t.permissions||[]}}else window.location.href="/pos/login"}();

View File

@@ -6,6 +6,7 @@
_offset: 0,
_limit: 50,
_total: 0,
_allowedBrands: [],
// Navigation state
nav: {
@@ -71,7 +72,9 @@
},
loading: function(on) {
this.el('brandCatalogLoading').style.display = on ? 'block' : 'none';
var el = this.el('brandCatalogLoading');
if (on) el.classList.add('is-visible');
else el.classList.remove('is-visible');
},
setContent: function(html) {
@@ -88,23 +91,23 @@
buildBreadcrumb: function() {
var parts = [];
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.loadBrands()" style="color:var(--color-primary);text-decoration:none;">Marcas</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick="BrandCatalog.loadBrands()">Marcas</a>');
if (this.nav.brand) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.brand) + '</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')\'>' + escapeHtml(this.nav.brand) + '</a>');
}
if (this.nav.model) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.model) + '</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ')\'>' + escapeHtml(this.nav.model) + '</a>');
}
if (this.nav.year) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')" style="color:var(--color-primary);text-decoration:none;">' + this.nav.year + '</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')\'>' + this.nav.year + '</a>');
}
if (this.nav.engine) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectEngine(' + this.nav.myeId + ',' + JSON.stringify(this.nav.engine) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.engine) + '</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectEngine(' + this.nav.myeId + ',' + JSON.stringify(this.nav.engine) + ')\'>' + escapeHtml(this.nav.engine) + '</a>');
}
if (this.nav.category) {
parts.push('<strong>' + escapeHtml(this.nav.category) + '</strong>');
parts.push('<span class="breadcrumb__current">' + escapeHtml(this.nav.category) + '</span>');
}
this.setBreadcrumb(parts.join(' &rsaquo; '));
this.setBreadcrumb('<nav class="breadcrumb">' + parts.join('<span class="breadcrumb__sep">&rsaquo;</span>') + '</nav>');
},
// ---------- BRANDS ----------
@@ -112,12 +115,10 @@
this.loading(true);
this.state = 'brands';
this.reset();
this.setBreadcrumb('<strong>Marcas de vehiculo</strong>');
this.setBreadcrumb('<nav class="breadcrumb"><span class="breadcrumb__current">Marcas de vehiculo</span></nav>');
this.setSearch(
'<input type="text" id="brandSearchInput" placeholder="Buscar marca..." ' +
'style="width:100%;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);' +
'font-size:var(--text-body);background:var(--color-surface);color:var(--color-text-primary);' +
'outline:none;" oninput="BrandCatalog.filterBrands(this.value)">'
'class="level-filter" oninput="BrandCatalog.filterBrands(this.value)">'
);
var self = this;
fetch('/pos/api/catalog/vehicle-brands', { headers: this._headers() })
@@ -130,24 +131,25 @@
self.loading(false);
self._allBrands = data.brands || [];
if (!self._allBrands.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron marcas.</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron marcas.</div></div>');
return;
}
self.renderBrandList(self._allBrands);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar marcas: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar marcas</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderBrandList: function(brands) {
var html = '';
var html = '<div class="nav-grid">';
brands.forEach(function(b) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectBrand(' + JSON.stringify(b.name) + ',' + b.id + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(b.name) + '</div>' +
html += '<div class="nav-card" onclick=\'BrandCatalog.selectBrand(' + JSON.stringify(b.name) + ',' + b.id + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(b.name) + '</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
@@ -186,23 +188,28 @@
self.loading(false);
var models = data.data || [];
if (!models.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron modelos.</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron modelos.</div></div>');
return;
}
var html = '';
models.forEach(function(m) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(m.display_name || m.name_model) + '</div>' +
'</div>';
});
self.setContent(html);
self.renderModelList(models);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar modelos: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar modelos</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderModelList: function(models) {
var html = '<div class="nav-grid">';
models.forEach(function(m) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(m.display_name || m.name_model) + '</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
selectModel: function(modelId, modelName) {
this.nav.model = modelName;
this.nav.modelId = modelId;
@@ -226,23 +233,28 @@
self.loading(false);
var years = data.data || [];
if (!years.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron años.</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron años.</div></div>');
return;
}
var html = '';
years.forEach(function(y) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectYear(' + y.id_year + ',' + y.year_car + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + y.year_car + '</div>' +
'</div>';
});
self.setContent(html);
self.renderYearList(years);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar años: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar años</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderYearList: function(years) {
var html = '<div class="nav-grid nav-grid--years">';
years.forEach(function(y) {
html += '<div class="nav-card nav-card--year" onclick=\'BrandCatalog.selectYear(' + y.id_year + ',' + y.year_car + ')\'>' +
'<div class="nav-card__name">' + y.year_car + '</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
selectYear: function(yearId, yearCar) {
this.nav.year = yearCar;
this.nav.yearId = yearId;
@@ -266,24 +278,29 @@
self.loading(false);
var engines = data.data || [];
if (!engines.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron motores.</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron motores.</div></div>');
return;
}
var html = '';
engines.forEach(function(e) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(e.name_engine) + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(e.name_engine) + '</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + escapeHtml(e.trim_level || '') + '</div>' +
'</div>';
});
self.setContent(html);
self.renderEngineList(engines);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar motores: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar motores</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderEngineList: function(engines) {
var html = '<div class="nav-grid">';
engines.forEach(function(e) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(e.name_engine) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(e.name_engine) + '</div>' +
'<div class="nav-card__sub">' + escapeHtml(e.trim_level || '') + '</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
selectEngine: function(myeId, engineName) {
this.nav.engine = engineName;
this.nav.myeId = myeId;
@@ -305,26 +322,36 @@
.then(function(data) {
if (!data) return;
self.loading(false);
self._allowedBrands = data.allowed_brands || [];
var categories = data.data || [];
if (!categories.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron categorias.</p>');
var msg = 'No se encontraron categorias.';
if (self._allowedBrands.length) {
msg = 'Este vehiculo no tiene cobertura de ' + self._allowedBrands.join(', ') + '.';
}
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">' + msg + '</div><div class="empty-state__subtitle">Prueba con otro vehiculo o contacta a soporte para ampliar el catalogo.</div></div>');
return;
}
var html = '';
categories.forEach(function(c) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectCategory(' + c.id_part_category + ',' + JSON.stringify(c.name) + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(c.name) + '</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (c.part_count || 0) + ' refacciones</div>' +
'</div>';
});
self.setContent(html);
self.renderCategoryList(categories);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar categorias: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar categorias</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderCategoryList: function(categories) {
var html = '<div class="nav-grid">';
categories.forEach(function(c) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectCategory(' + c.id_part_category + ',' + JSON.stringify(c.name) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(c.name) + '</div>' +
'<div class="nav-card__sub">' + (c.part_count || 0) + ' refacciones</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
selectCategory: function(catId, catName) {
this.nav.category = catName;
this.nav.categoryId = catId;
@@ -340,11 +367,10 @@
this.setSearch(
'<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;align-items:center;">' +
'<input type="text" id="partsSearchInput" placeholder="Buscar refaccion..." value="' + escapeHtml(searchTerm || '') + '" ' +
'style="flex:1;min-width:200px;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);' +
'font-size:var(--text-body);background:var(--color-surface);color:var(--color-text-primary);outline:none;" ' +
'class="level-filter" ' +
'onkeydown="if(event.key===\'Enter\')BrandCatalog.searchParts(this.value)">' +
'<button class="btn btn--primary btn--sm" onclick="BrandCatalog.searchParts(document.getElementById(\'partsSearchInput\').value)">Buscar</button>' +
'<button class="btn btn--secondary btn--sm" onclick="BrandCatalog.clearPartsSearch()">Limpiar</button>' +
'<button class="btn btn-primary" onclick="BrandCatalog.searchParts(document.getElementById(\'partsSearchInput\').value)">Buscar</button>' +
'<button class="btn btn-ghost" onclick="BrandCatalog.clearPartsSearch()">Limpiar</button>' +
'</div>'
);
var url = '/pos/api/catalog/mye-parts?mye_id=' + encodeURIComponent(myeId) + '&category_id=' + encodeURIComponent(categoryId) +
@@ -361,6 +387,7 @@
.then(function(data) {
if (!data) return;
self.loading(false);
self._allowedBrands = data.allowed_brands || [];
self._lastItems = data.items || [];
self._total = data.total || 0;
self._offset = data.offset || 0;
@@ -368,16 +395,20 @@
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar refacciones: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar refacciones</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderPartsList: function(items, searchTerm) {
var html = '';
if (!items.length) {
html += '<div style="grid-column:1/-1;text-align:center;padding:var(--space-8);">' +
'<p style="color:var(--color-text-muted);font-size:var(--text-body-lg);">No se encontraron refacciones.</p>' +
'<button class="btn btn--primary" style="margin-top:var(--space-3);" onclick="BrandCatalog.loadCategories(' + this.nav.myeId + ')">Volver a categorias</button>' +
var msg = 'No se encontraron refacciones.';
if (this._allowedBrands.length) {
msg = 'No hay refacciones de ' + this._allowedBrands.join(', ') + ' en esta categoria.';
}
html += '<div class="empty-state is-visible">' +
'<div class="empty-state__title">' + msg + '</div>' +
'<div class="empty-state__subtitle"><button class="btn btn-primary" onclick="BrandCatalog.loadCategories(' + this.nav.myeId + ')">Volver a categorias</button></div>' +
'</div>';
this.setContent(html);
return;
@@ -389,40 +420,55 @@
'Mostrando ' + startIdx + '-' + endIdx + ' de ' + this._total + ' refacciones' +
'</div>';
html += '<div style="grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3);">';
html += '<div class="nav-grid nav-grid--parts">';
items.forEach(function(p) {
var price = p.local_price ? '$' + Number(p.local_price).toFixed(2) : 'Consultar precio';
var img = '/pos/static/images/placeholder-part.png';
var hasAm = !!p.manufacturer;
var price = p.local_price
? '$' + Number(p.local_price).toFixed(2)
: (p.price_usd ? '$' + Number(p.price_usd).toFixed(2) : 'Consultar precio');
var stockBadge = p.local_stock > 0
? '<span style="display:inline-block;background:var(--color-success);color:#fff;font-size:11px;padding:2px 8px;border-radius:var(--radius-sm);margin-left:6px;">' + p.local_stock + ' en stock</span>'
: '<span style="display:inline-block;background:var(--color-text-muted);color:#fff;font-size:11px;padding:2px 8px;border-radius:var(--radius-sm);margin-left:6px;">Sin stock local</span>';
html += '<div class="catalog-category-card" style="padding:0;overflow:hidden;display:flex;flex-direction:column;">' +
'<div style="height:160px;background:#f5f5f5;display:flex;align-items:center;justify-content:center;">' +
'<img src="' + escapeHtml(img) + '" alt="" style="max-width:100%;max-height:100%;object-fit:contain;">' +
? '<span class="stock-badge stock-badge--local">En stock</span>'
: '<span class="stock-badge stock-badge--none">Sin stock local</span>';
var imgHtml = p.image_url
? '<img src="' + escapeHtml(p.image_url) + '" alt="">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>';
var brandLine = hasAm
? '<div style="font-size:var(--text-caption);color:var(--color-accent);font-weight:600;">' + escapeHtml(p.manufacturer) + '</div>'
: '';
html += '<div class="part-card">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
brandLine +
'<div class="part-card__oem">' + escapeHtml(p.oem_part_number || 'N/A') + '</div>' +
'<div class="part-card__name">' + escapeHtml(p.name || '') + '</div>' +
'</div>' +
'<div style="padding:var(--space-3);flex:1;display:flex;flex-direction:column;">' +
'<div style="font-weight:600;font-size:var(--text-body);margin-bottom:4px;">' + escapeHtml(p.oem_part_number || 'N/A') + stockBadge + '</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);margin-bottom:8px;flex:1;">' + escapeHtml(p.name || '') + '</div>' +
'<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-primary);margin-bottom:8px;">' + price + '</div>' +
'<button class="btn btn--primary btn--sm" style="width:100%;" onclick="BrandCatalog.addToCart(' + p.id + ', event)">Agregar</button>' +
'<div class="part-card__footer">' +
stockBadge +
'<span class="part-card__price">' + price + '</span>' +
'</div>' +
'<button class="btn btn-primary" onclick="BrandCatalog.addToCart(' + p.id + ', event)">Agregar</button>' +
'</div>';
});
html += '</div>';
html += this.renderPagination();
this.setContent(html);
},
renderPagination: function() {
var hasPrev = this._offset > 0;
var hasNext = (this._offset + this._limit) < this._total;
var pageNum = Math.floor(this._offset / this._limit) + 1;
var totalPages = Math.ceil(this._total / this._limit) || 1;
html += '<div style="grid-column:1/-1;display:flex;justify-content:center;align-items:center;gap:var(--space-3);padding:var(--space-4) 0;">' +
'<button class="btn btn--secondary" ' + (hasPrev ? '' : 'disabled style="opacity:0.5;cursor:not-allowed;"') +
var html = '<div class="pagination">' +
'<button class="page-item" ' + (hasPrev ? '' : 'disabled') +
' onclick="BrandCatalog.goToPage(' + (this._offset - this._limit) + ')">&larr; Anterior</button>' +
'<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">Pagina ' + pageNum + ' de ' + totalPages + '</span>' +
'<button class="btn btn--secondary" ' + (hasNext ? '' : 'disabled style="opacity:0.5;cursor:not-allowed;"') +
'<button class="page-item" ' + (hasNext ? '' : 'disabled') +
' onclick="BrandCatalog.goToPage(' + (this._offset + this._limit) + ')">Siguiente &rarr;</button>' +
'</div>';
this.setContent(html);
return html;
},
searchParts: function(term) {
@@ -451,16 +497,17 @@
return;
}
if (window.CatalogApp && CatalogApp.addToCart) {
var isAftermarket = !!part.manufacturer;
CatalogApp.addToCart({
id: part.id,
id: part.oem_id || part.id,
part_number: part.oem_part_number || 'N/A',
name: part.name || 'Refaccion',
brand: '',
price: part.local_price || 0,
brand: part.manufacturer || '',
price: part.local_price || part.price_usd || 0,
tax_rate: 0.16,
unit: 'PZA',
stock: part.local_stock || 0,
source: 'oem-brand',
source: isAftermarket ? 'aftermarket' : 'oem-brand',
inventory_id: null
}, 1);
var btn = event.target;

View File

@@ -349,10 +349,14 @@
var cacheKey = 'nexus:brands:' + catalogMode;
var cached = sessionStorage.getItem(cacheKey);
if (cached) {
try {
hideLoading();
var data = JSON.parse(cached);
renderBrands(data);
return;
} catch (e) {
sessionStorage.removeItem(cacheKey);
}
}
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
@@ -1631,8 +1635,13 @@
var cacheKey = 'nexus:years-all';
var cached = sessionStorage.getItem(cacheKey);
if (cached) {
try {
var data = JSON.parse(cached);
var years = data.data || data || [];
} catch (e) {
sessionStorage.removeItem(cacheKey);
var years = [];
}
if (!years.length) {
years = [];
for (var y = 2026; y >= 1990; y--) years.push({ id_year: y, year_car: y });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,11 @@ const Config = (() => {
return true;
}
function escapeHtml(text) {
if (!text) return '';
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function headers() {
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
}
@@ -623,6 +628,67 @@ const Config = (() => {
}
}
// -------------------------------------------------------------------------
// Allowed Part Brands
// -------------------------------------------------------------------------
async function loadAllowedBrands() {
var container = document.getElementById('allowed-brands-container');
if (!container) return;
try {
var res = await fetch(API + '/available-brands', { headers: headers() });
if (!res.ok) throw new Error('Failed to load brands');
var d = await res.json();
var allBrands = d.brands || [];
var res2 = await fetch(API + '/allowed-brands', { headers: headers() });
if (!res2.ok) throw new Error('Failed to load allowed brands');
var d2 = await res2.json();
var allowed = d2.brands || [];
if (!allBrands.length) {
container.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">No hay marcas disponibles.</p>';
return;
}
var html = '';
allBrands.forEach(function(b) {
var checked = allowed.indexOf(b) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);color:var(--color-text-primary);padding:var(--space-1);">' +
'<input type="checkbox" value="' + escapeHtml(b) + '" data-brand-checkbox ' + checked + ' style="width:16px;height:16px;cursor:pointer;">' +
escapeHtml(b) + '</label>';
});
container.innerHTML = html;
} catch (e) {
console.error('Config.loadAllowedBrands:', e);
if (container) container.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-body-sm);">Error al cargar marcas.</p>';
}
}
async function saveAllowedBrands() {
var btn = document.getElementById('btn-save-allowed-brands');
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
try {
var checked = [];
document.querySelectorAll('[data-brand-checkbox]').forEach(function(cb) {
if (cb.checked) checked.push(cb.value);
});
var res = await fetch(API + '/allowed-brands', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({ brands: checked })
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Save failed');
}
toast('Marcas permitidas actualizadas');
} catch (e) {
toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Marcas'; }
}
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -650,6 +716,12 @@ const Config = (() => {
btnCompat.addEventListener('click', saveVehicleCompatSource);
}
// Allowed brands save button
var btnBrands = document.getElementById('btn-save-allowed-brands');
if (btnBrands) {
btnBrands.addEventListener('click', saveAllowedBrands);
}
// Kiosk mode toggle
var kioskToggle = document.getElementById('cfg-kiosk-mode');
if (kioskToggle && window.NexusKiosk) {
@@ -671,12 +743,13 @@ const Config = (() => {
loadBusiness();
loadCurrency();
loadVehicleCompatSource();
loadAllowedBrands();
}
document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, selectThemeOption,
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,

File diff suppressed because one or more lines are too long

View File

@@ -104,7 +104,7 @@ const Customers = (() => {
const creditClass = usedPct >= 80 ? 'none' : usedPct >= 60 ? 'low' : '';
const num = String(c.id).padStart(5, '0');
const selClass = (currentCustomer && currentCustomer.id === c.id) ? 'selected' : '';
return '<tr class="' + selClass + '" onclick="selectCustomer(' + c.id + ')">' +
return '<tr class="' + selClass + '" onclick="Customers.selectCustomer(' + c.id + ')">' +
'<td class="cell-num">' + num + '</td>' +
'<td>' +
'<div class="cell-name">' + (c.name || '') + '</div>' +
@@ -263,6 +263,13 @@ const Customers = (() => {
bar.className = `progress-bar__fill ${pct < 40 ? 'low' : pct > 75 ? 'high' : ''}`;
}
// Discount
const discountEl = document.getElementById('detailMaxDiscount');
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
// Re-wire action buttons after detail panel is visible
wireActionButtons();
// Purchase History
const hbody = document.getElementById('historyBody');
if (hbody) {
@@ -363,7 +370,7 @@ const Customers = (() => {
const btns = document.querySelectorAll('.quick-actions .action-btn');
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial
if (btns.length >= 1) btns[0].onclick = () => {
if (currentCustomer) window.location.href = '/pos/?customer=' + currentCustomer.id;
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
};
if (btns.length >= 2) btns[1].onclick = () => editCurrent();
if (btns.length >= 3) btns[2].onclick = () => showStatement();
@@ -378,17 +385,19 @@ const Customers = (() => {
if (!modal) return;
document.getElementById('modalTitle').textContent = 'Nuevo Cliente';
document.getElementById('editId').value = '';
document.getElementById('fName').value = '';
document.getElementById('fRfc').value = '';
document.getElementById('fRazonSocial').value = '';
document.getElementById('fRegimenFiscal').value = '';
document.getElementById('fUsoCfdi').value = 'G03';
document.getElementById('fCp').value = '';
document.getElementById('fPhone').value = '';
document.getElementById('fEmail').value = '';
document.getElementById('fAddress').value = '';
document.getElementById('fPriceTier').value = '1';
document.getElementById('fCreditLimit').value = '0';
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
safeSet('fName', '');
safeSet('fRfc', '');
safeSet('fRazonSocial', '');
safeSet('fRegimenFiscal', '');
safeSet('fUsoCfdi', 'G03');
safeSet('fCp', '');
safeSet('fPhone', '');
safeSet('fEmail', '');
safeSet('fAddress', '');
safeSet('fPriceTier', '1');
safeSet('fCreditLimit', '0');
safeSet('fMaxDiscountPct', '0');
modal.classList.add('active');
document.getElementById('fName').focus();
}
@@ -400,17 +409,19 @@ const Customers = (() => {
if (!modal) return;
document.getElementById('modalTitle').textContent = 'Editar Cliente';
document.getElementById('editId').value = c.id;
document.getElementById('fName').value = c.name || '';
document.getElementById('fRfc').value = c.rfc || '';
document.getElementById('fRazonSocial').value = c.razon_social || '';
document.getElementById('fRegimenFiscal').value = c.regimen_fiscal || '';
document.getElementById('fUsoCfdi').value = c.uso_cfdi || 'G03';
document.getElementById('fCp').value = c.cp || '';
document.getElementById('fPhone').value = c.phone || '';
document.getElementById('fEmail').value = c.email || '';
document.getElementById('fAddress').value = c.address || '';
document.getElementById('fPriceTier').value = c.price_tier || '1';
document.getElementById('fCreditLimit').value = c.credit_limit || 0;
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
safeSet('fName', c.name || '');
safeSet('fRfc', c.rfc || '');
safeSet('fRazonSocial', c.razon_social || '');
safeSet('fRegimenFiscal', c.regimen_fiscal || '');
safeSet('fUsoCfdi', c.uso_cfdi || 'G03');
safeSet('fCp', c.cp || '');
safeSet('fPhone', c.phone || '');
safeSet('fEmail', c.email || '');
safeSet('fAddress', c.address || '');
safeSet('fPriceTier', c.price_tier || '1');
safeSet('fCreditLimit', c.credit_limit || 0);
safeSet('fMaxDiscountPct', c.max_discount_pct || 0);
modal.classList.add('active');
}
@@ -438,6 +449,7 @@ const Customers = (() => {
address: val('fAddress') || null,
price_tier: parseInt(val('fPriceTier')) || 1,
credit_limit: parseFloat(val('fCreditLimit')) || 0,
max_discount_pct: parseFloat(val('fMaxDiscountPct')) || 0,
};
const editId = val('editId');
@@ -586,7 +598,9 @@ const Customers = (() => {
function injectModals() {
// Customer Create/Edit Modal
if (!document.getElementById('customerModal')) {
// Always remove and re-inject to ensure latest fields are present
const existingModal = document.getElementById('customerModal');
if (existingModal) existingModal.remove();
const div = document.createElement('div');
div.innerHTML = `
<div id="customerModal" class="modal-overlay" style="display:none;">
@@ -646,6 +660,7 @@ const Customers = (() => {
</select>
</div>
<div class="form-group"><label>Limite de Credito</label><input type="number" id="fCreditLimit" class="form-input" value="0" min="0" step="1000" /></div>
<div class="form-group"><label>Descuento Max (%)</label><input type="number" id="fMaxDiscountPct" class="form-input" value="0" min="0" max="100" step="0.5" /></div>
</div>
</div>
<div class="modal-footer">
@@ -655,9 +670,10 @@ const Customers = (() => {
</div>
</div>`;
document.body.appendChild(div);
}
// Statement Modal
const existingStatement = document.getElementById('statementModal');
if (existingStatement) existingStatement.remove();
if (!document.getElementById('statementModal')) {
const div = document.createElement('div');
div.innerHTML = `
@@ -772,11 +788,15 @@ const Customers = (() => {
// Run init
init();
return {
const publicApi = {
search, goToPage, loadCustomers,
showDetail, selectCustomer, closeDetail,
showCreateModal, editCurrent, closeModal, save,
showStatement, closeStatement,
showPaymentModal, closePayment, recordPayment,
};
// Expose globally for inline HTML onclick handlers
window.Customers = publicApi;
return publicApi;
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -47,6 +47,79 @@
return d.innerHTML;
}
// --- Dashboard summary badges ---
function loadSummary() {
apiFetch(API + '/summary').then(function(data) {
if (!data) return;
var totalSkusEl = document.getElementById('inv-total-skus');
var totalValueEl = document.getElementById('inv-total-value');
var lowStockEl = document.getElementById('inv-low-stock');
var noMovementEl = document.getElementById('inv-no-movement');
if (totalSkusEl) totalSkusEl.textContent = (data.total_skus || 0).toLocaleString('es-MX');
if (totalValueEl) totalValueEl.textContent = '$' + (data.total_value || 0).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
if (lowStockEl) lowStockEl.textContent = (data.low_stock || 0).toLocaleString('es-MX');
if (noMovementEl) noMovementEl.textContent = (data.no_movement || 0).toLocaleString('es-MX');
}).catch(function(err) {
console.error('Inventory summary load failed:', err);
});
}
loadSummary();
// --- Global tier discounts ---
var globalDiscounts = { 2: 15, 3: 25 };
function loadTierDiscounts() {
apiFetch(API + '/tier-discounts').then(function(data) {
if (data && data.data) {
data.data.forEach(function(d) {
globalDiscounts[d.tier_id] = d.discount_pct;
});
}
var discEl = document.getElementById('tierDiscountBadge');
if (discEl) {
discEl.textContent = 'Taller -' + globalDiscounts[2] + '% · Mayoreo -' + globalDiscounts[3] + '%';
}
});
}
loadTierDiscounts();
function showTierDiscountModal() {
document.getElementById('tierDisc2').value = globalDiscounts[2];
document.getElementById('tierDisc3').value = globalDiscounts[3];
document.getElementById('tierDiscountModal').classList.add('is-open');
}
function closeTierDiscountModal() {
document.getElementById('tierDiscountModal').classList.remove('is-open');
}
function saveTierDiscounts() {
var d2 = parseFloat(document.getElementById('tierDisc2').value) || 0;
var d3 = parseFloat(document.getElementById('tierDisc3').value) || 0;
fetch(API + '/tier-discounts', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ discount_pct_2: d2, discount_pct_3: d3 })
}).then(function(r) { return r.json(); })
.then(function(res) {
showToast(res.message || 'Guardado', 'ok');
globalDiscounts[2] = d2;
globalDiscounts[3] = d3;
var discEl = document.getElementById('tierDiscountBadge');
if (discEl) {
discEl.textContent = 'Taller -' + d2 + '% · Mayoreo -' + d3 + '%';
}
closeTierDiscountModal();
}).catch(function() {
showToast('Error al guardar descuentos', 'error');
});
}
// Handle hash-based tab switching (e.g. /pos/inventory#alertas)
(function handleHashTab() {
var hash = window.location.hash.replace('#', '');
if (hash && ['stock', 'entradas', 'salidas', 'traspasos', 'ajustes', 'conteos', 'alertas'].indexOf(hash) !== -1) {
setTimeout(function() { switchTab(hash); }, 100);
}
})();
// =====================================================================
// TAB SWITCHING — uses design-system switchTab() already in the HTML.
// We hook into it to trigger data loads when tabs are activated.
@@ -63,8 +136,12 @@
// STOCK / PRODUCTS (panel-stock)
// =====================================================================
var selectedItems = new Set();
function renderInventoryRow(it) {
var isChecked = selectedItems.has(it.id) ? 'checked' : '';
return '<tr style="cursor:pointer;" onclick="viewProductDetail(' + it.id + ')">' +
'<td onclick="event.stopPropagation();"><input type="checkbox" class="item-checkbox" data-id="' + it.id + '" ' + isChecked + ' onclick="event.stopPropagation();toggleItemSelection(' + it.id + ')"></td>' +
'<td class="td--mono" style="font-size:var(--text-caption);color:var(--color-text-muted);">' + it.id + '</td>' +
'<td class="td--mono">' + esc(it.barcode) + '</td>' +
'<td class="td--mono">' + esc(it.part_number) + '</td>' +
@@ -79,10 +156,50 @@
'<td>' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
'</td></tr>';
}
window.toggleItemSelection = function(id) {
if (selectedItems.has(id)) {
selectedItems.delete(id);
} else {
selectedItems.add(id);
}
updateSelectionUI();
};
window.toggleSelectAllItems = function() {
var cb = document.getElementById('selectAllItems');
var allChecked = cb.checked;
// We need to get all visible items from inventoryVS
if (inventoryVS && inventoryVS.data) {
inventoryVS.data.forEach(function(it) {
if (allChecked) selectedItems.add(it.id);
else selectedItems.delete(it.id);
});
inventoryVS.refresh();
}
updateSelectionUI();
};
function updateSelectionUI() {
var count = selectedItems.size;
var btn = document.getElementById('btnPublishML');
var badge = document.getElementById('meliSelectedCountBadge');
if (btn) btn.style.display = count > 0 ? 'inline-flex' : 'none';
if (badge) badge.textContent = count;
// Update select-all checkbox state
var selectAll = document.getElementById('selectAllItems');
if (selectAll && inventoryVS && inventoryVS.data) {
var visibleIds = inventoryVS.data.map(function(it) { return it.id; });
var allSelected = visibleIds.length > 0 && visibleIds.every(function(id) { return selectedItems.has(id); });
selectAll.checked = allSelected;
}
}
function loadItems(page, search) {
currentPage = page || 1;
currentSearch = search !== undefined ? search : currentSearch;
@@ -220,7 +337,7 @@
loadItems(currentPage);
// Close modal, clear form, refresh badges
closeCreateModal();
['newPartNumber','newName','newBrand','newBarcode','newCost','newPrice1','newPrice2','newPrice3','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
['newPartNumber','newName','newBrand','newBarcode','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
@@ -634,6 +751,195 @@
document.getElementById('historyModal').classList.remove('is-open');
}
// =====================================================================
// DELETE ITEM
// =====================================================================
function deleteItem(itemId) {
if (!confirm('¿Eliminar este artículo del inventario? Se mantendrán los registros históricos.')) return;
var token = localStorage.getItem('pos_token') || '';
fetch(API + '/items/' + itemId, {
method: 'DELETE',
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
}).then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { alert('Error: ' + data.error); return; }
showToast('Artículo eliminado');
loadItems(currentPage);
if (window.loadInventoryStats) window.loadInventoryStats();
}).catch(function() { alert('Error al eliminar artículo'); });
}
// =====================================================================
// MERCADOLIBRE PUBLISH
// =====================================================================
function publishToMeli(itemId) {
selectedItems.clear();
selectedItems.add(itemId);
updateSelectionUI();
openMeliPublishModal();
}
window.publishToMeli = publishToMeli;
// ─── MercadoLibre Bulk Publish Modal ───────────────────────────────────
window.openMeliPublishModal = function() {
if (selectedItems.size === 0) { showToast('Selecciona al menos un producto', 'warn'); return; }
document.getElementById('meliPublishModal').classList.add('is-open');
document.getElementById('meliPublishResult').innerHTML = '';
document.getElementById('meliCategoryId').value = '';
document.getElementById('meliCategorySearch').value = '';
document.getElementById('meliCategoryResults').innerHTML = '';
refreshMeliPublishPreview();
};
window.closeMeliPublishModal = function() {
document.getElementById('meliPublishModal').classList.remove('is-open');
};
function refreshMeliPublishPreview() {
var container = document.getElementById('meliPublishItemsPreview');
var countEl = document.getElementById('meliPublishSelectedCount');
countEl.textContent = selectedItems.size + ' producto(s) seleccionado(s)';
if (!inventoryVS || !inventoryVS.data) { container.innerHTML = '<p style="color:var(--color-text-muted);">Sin datos</p>'; return; }
var items = inventoryVS.data.filter(function(it) { return selectedItems.has(it.id); });
if (!items.length) { container.innerHTML = '<p style="color:var(--color-text-muted);">Ninguno</p>'; return; }
var html = '<table class="data-table" style="font-size:var(--text-caption);"><thead><tr><th>ID</th><th>No. Parte</th><th>Nombre</th><th>Stock</th><th style="text-align:right">Precio</th></tr></thead><tbody>';
items.forEach(function(it) {
html += '<tr><td>' + it.id + '</td><td class="td--mono">' + esc(it.part_number) + '</td><td>' + esc(it.name) + '</td><td>' + it.stock + '</td><td style="text-align:right">$' + fmt(it.price_1) + '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
}
var meliCategorySearchTimeout;
var meliCatItems = [];
var meliCatActiveIndex = -1;
window.searchMeliCategories = function() {
var q = document.getElementById('meliCategorySearch').value.trim();
var resultsDiv = document.getElementById('meliCategoryResults');
if (q.length < 2) { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; return; }
clearTimeout(meliCategorySearchTimeout);
resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-loading">Buscando...</div></div>';
meliCategorySearchTimeout = setTimeout(function() {
fetch('/pos/api/marketplace-ext/categories?q=' + encodeURIComponent(q), { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } })
.then(function(r) { return r.json(); })
.then(function(data) {
var cats = data.categories || [];
meliCatItems = cats.slice(0, 10);
meliCatActiveIndex = -1;
if (!meliCatItems.length) { resultsDiv.innerHTML = '<div class="meli-cat-dropdown"><div class="meli-cat-empty">Sin resultados</div></div>'; return; }
var html = '<div class="meli-cat-dropdown">';
meliCatItems.forEach(function(c, idx) {
html += '<div class="meli-cat-item" data-idx="' + idx + '" onmouseenter="highlightMeliCat(' + idx + ')" onmousedown="selectMeliCategoryIdx(' + idx + ')">' +
'<span>' + esc(c.category_name || c.category_id) + '</span>' +
'<span class="cat-id">' + esc(c.category_id) + '</span>' +
'</div>';
});
html += '</div>';
resultsDiv.innerHTML = html;
})
.catch(function() { resultsDiv.innerHTML = ''; meliCatItems = []; meliCatActiveIndex = -1; });
}, 300);
};
window.highlightMeliCat = function(idx) {
meliCatActiveIndex = idx;
var items = document.querySelectorAll('.meli-cat-item');
items.forEach(function(el, i) { el.classList.toggle('is-active', i === idx); });
};
window.selectMeliCategoryIdx = function(idx) {
var c = meliCatItems[idx];
if (!c) return;
selectMeliCategory(c.category_id, c.category_name || c.category_id);
};
window.selectMeliCategory = function(id, name) {
document.getElementById('meliCategoryId').value = id;
document.getElementById('meliCategorySearch').value = name;
document.getElementById('meliCategoryResults').innerHTML = '';
meliCatItems = [];
meliCatActiveIndex = -1;
};
window.handleMeliCatKeydown = function(e) {
if (!meliCatItems.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
meliCatActiveIndex = Math.min(meliCatActiveIndex + 1, meliCatItems.length - 1);
highlightMeliCat(meliCatActiveIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
meliCatActiveIndex = Math.max(meliCatActiveIndex - 1, -1);
highlightMeliCat(meliCatActiveIndex);
} else if (e.key === 'Enter') {
e.preventDefault();
if (meliCatActiveIndex >= 0) selectMeliCategoryIdx(meliCatActiveIndex);
} else if (e.key === 'Escape') {
document.getElementById('meliCategoryResults').innerHTML = '';
meliCatItems = [];
meliCatActiveIndex = -1;
}
};
/* Cerrar dropdown al hacer click fuera */
document.addEventListener('click', function(e) {
var field = document.getElementById('meliCategorySearch');
var results = document.getElementById('meliCategoryResults');
if (field && results && !field.contains(e.target) && !results.contains(e.target)) {
results.innerHTML = '';
meliCatItems = [];
meliCatActiveIndex = -1;
}
});
window.executeMeliPublish = function() {
var categoryId = document.getElementById('meliCategoryId').value.trim();
if (!categoryId) { document.getElementById('meliPublishResult').innerHTML = '<span style="color:var(--color-error);">Selecciona una categoría de MercadoLibre</span>'; return; }
var listingType = document.getElementById('meliListingType').value;
var shippingMode = document.getElementById('meliShippingMode').value;
var ids = Array.from(selectedItems);
var resultEl = document.getElementById('meliPublishResult');
var btn = document.getElementById('meliPublishBtn');
btn.disabled = true;
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Publicando ' + ids.length + ' producto(s)...</span>';
fetch('/pos/api/marketplace-ext/listings', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({
inventory_ids: ids,
category_id: categoryId,
listing_type: listingType,
shipping_mode: shippingMode
})
}).then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
if (data.error) { resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(data.error) + '</span>'; return; }
var success = (data.success || []).length;
var failedList = data.failed || [];
var failed = failedList.length;
var html = '<div style="margin-bottom:var(--space-2);"><span style="color:var(--color-success);">✅ ' + success + ' publicado(s)</span> · <span style="color:var(--color-error);">❌ ' + failed + ' fallo(s)</span></div>';
if (failedList.length) {
html += '<ul style="margin:0;padding-left:var(--space-4);font-size:var(--text-caption);color:var(--color-text-secondary);">';
failedList.forEach(function(f) {
html += '<li>Item #' + esc(f.inventory_id) + ': ' + esc(f.error) + '</li>';
});
html += '</ul>';
}
resultEl.innerHTML = html;
if (success > 0) {
selectedItems.clear();
updateSelectionUI();
if (inventoryVS) inventoryVS.refresh();
setTimeout(function() { closeMeliPublishModal(); }, 2500);
}
}).catch(function(e) { btn.disabled = false; resultEl.innerHTML = '<span style="color:var(--color-error);">Error: ' + esc(e.message) + '</span>'; });
};
// =====================================================================
// BARCODE LABEL PRINT
// =====================================================================
@@ -747,9 +1053,9 @@
// 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>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Precio 1</span><span class="td--amount">$' + fmt(data.price_1) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Precio 2</span><span class="td--amount">$' + fmt(data.price_2) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Precio 3</span><span class="td--amount">$' + fmt(data.price_3) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Mostrador</span><span class="td--amount">$' + fmt(data.price_1) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Taller</span><span class="td--amount">$' + fmt(data.price_2) + '</span><span style="font-size:var(--text-caption);color:var(--color-text-muted);"> (-' + globalDiscounts[2] + '%)</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Mayoreo</span><span class="td--amount">$' + fmt(data.price_3) + '</span><span style="font-size:var(--text-caption);color:var(--color-text-muted);"> (-' + globalDiscounts[3] + '%)</span></div>';
html += '</div>';
// Cross-references section
@@ -950,6 +1256,7 @@
window.cancelDraft = cancelDraft;
window.loadAlerts = loadAlerts;
window.printBarcode = printBarcode;
window.deleteItem = deleteItem;
window.autoMatchCompat = autoMatchCompat;
window.removeCompat = removeCompat;

View File

@@ -154,6 +154,9 @@
}
};
// Expose for other scripts
window.isKioskEnabled = isKioskEnabled;
// ─── Init ───
if (isKioskEnabled()) {
activate();

View File

@@ -0,0 +1,367 @@
/**
* marketplace_external.js — MercadoLibre integration UI
*/
(function() {
'use strict';
var API = '/pos/api/marketplace-ext';
var TOKEN = localStorage.getItem('pos_token') || '';
function headers() {
return {
'Authorization': 'Bearer ' + TOKEN,
'Content-Type': 'application/json',
'X-Device-Id': localStorage.getItem('pos_device_id') || 'web',
};
}
// ─── Tabs ──────────────────────────────────────────────────────────────
window.switchTab = function(tab) {
document.querySelectorAll('.tab-btn').forEach(function(b) {
b.classList.toggle('is-active', b.dataset.tab === tab);
b.setAttribute('aria-selected', b.dataset.tab === tab ? 'true' : 'false');
});
document.querySelectorAll('.tab-panel').forEach(function(p) {
p.classList.toggle('is-active', p.id === 'panel-' + tab);
});
if (tab === 'listings') loadListings();
if (tab === 'orders') loadOrders();
};
function closeModal(id) {
document.getElementById(id).classList.remove('is-open');
}
window.closeModal = closeModal;
// ─── Config / Connection ───────────────────────────────────────────────
async function loadConfig() {
try {
var res = await fetch(API + '/config', { headers: headers() });
if (!res.ok) throw new Error('Failed to load config');
var cfg = await res.json();
var statusDiv = document.getElementById('configStatus');
var formDiv = document.getElementById('configForm');
var connectedDiv = document.getElementById('configConnected');
if (cfg.connected) {
statusDiv.innerHTML = '<span class="meli-status meli-status--active">● Conectado</span>';
formDiv.style.display = 'none';
connectedDiv.style.display = 'block';
document.getElementById('connectedNickname').textContent = cfg.meli_user_id || 'Usuario ML';
document.getElementById('connectedSite').textContent = cfg.meli_site_id || 'MLM';
} else {
statusDiv.innerHTML = '<span class="meli-status meli-status--pending">● No conectado</span>';
formDiv.style.display = 'block';
connectedDiv.style.display = 'none';
}
} catch (e) {
console.error(e);
document.getElementById('configStatus').innerHTML = '<p style="color:var(--color-danger);">Error cargando configuración</p>';
}
}
window.startOAuth = function() {
var clientId = document.getElementById('cfgClientId').value.trim();
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
var category = document.getElementById('cfgCategory').value.trim();
var shipping = document.getElementById('cfgShipping').value;
if (!clientId || !clientSecret) {
alert('Client ID y Client Secret son requeridos');
return;
}
// Save config locally for the callback
localStorage.setItem('meli_client_id', clientId);
localStorage.setItem('meli_client_secret', clientSecret);
localStorage.setItem('meli_category', category);
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);
window.location.href = authUrl;
};
window.disconnectMeli = async function() {
if (!confirm('¿Desconectar MercadoLibre? Las publicaciones existentes no se eliminarán de ML.')) return;
try {
var res = await fetch(API + '/connect', { method: 'DELETE', headers: headers() });
if (res.ok) {
loadConfig();
} else {
alert('Error desconectando');
}
} catch (e) {
alert('Error: ' + e.message);
}
};
// ─── Listings ──────────────────────────────────────────────────────────
var listingsData = [];
window.loadListings = async function() {
var container = document.getElementById('listingsContainer');
container.innerHTML = '<p>Cargando...</p>';
try {
var res = await fetch(API + '/listings?page=1&per_page=50', { headers: headers() });
if (!res.ok) throw new Error('Failed to load listings');
var data = await res.json();
listingsData = data.items || [];
renderListings();
} catch (e) {
container.innerHTML = '<p style="color:var(--color-danger);">Error cargando publicaciones</p>';
}
};
function renderListings() {
var container = document.getElementById('listingsContainer');
var statusFilter = document.getElementById('listingStatusFilter').value;
var search = document.getElementById('listingSearch').value.toLowerCase();
var filtered = listingsData.filter(function(l) {
if (statusFilter && l.external_status !== statusFilter) return false;
if (search && !((l.title || '').toLowerCase().includes(search))) return false;
return true;
});
if (!filtered.length) {
container.innerHTML = '<p style="color:var(--color-text-muted);padding:var(--space-4);">No hay publicaciones. Ve a Inventario y publica un producto.</p>';
return;
}
container.innerHTML = filtered.map(function(l) {
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>'
+ '<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 || '—')
+ '</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>'
+ '</div>'
+ '</div>';
}).join('');
}
window.filterListings = renderListings;
window.syncListing = async function(id) {
try {
var res = await fetch(API + '/listings/' + id + '/sync', { method: 'POST', headers: headers() });
var data = await res.json();
if (res.ok) {
alert('Sincronizado: $' + data.price + ' · Stock: ' + data.stock);
loadListings();
} else {
alert('Error: ' + (data.error || 'Unknown'));
}
} catch (e) {
alert('Error: ' + e.message);
}
};
window.pauseListing = async function(id) {
try {
var res = await fetch(API + '/listings/' + id + '/pause', { method: 'POST', headers: headers() });
if (res.ok) { loadListings(); } else { alert('Error'); }
} catch (e) { alert('Error: ' + e.message); }
};
window.activateListing = async function(id) {
try {
var res = await fetch(API + '/listings/' + id + '/activate', { method: 'POST', headers: headers() });
if (res.ok) { loadListings(); } else { alert('Error'); }
} catch (e) { alert('Error: ' + e.message); }
};
window.deleteListing = async function(id) {
if (!confirm('¿Cerrar esta publicación en MercadoLibre?')) return;
try {
var res = await fetch(API + '/listings/' + id, { method: 'DELETE', headers: headers() });
if (res.ok) { loadListings(); } else { alert('Error'); }
} catch (e) { alert('Error: ' + e.message); }
};
// ─── Orders ────────────────────────────────────────────────────────────
var ordersData = [];
window.loadOrders = async function() {
var tbody = document.getElementById('ordersTableBody');
tbody.innerHTML = '<tr><td colspan="6">Cargando...</td></tr>';
try {
var res = await fetch(API + '/orders?page=1&per_page=50', { headers: headers() });
if (!res.ok) throw new Error('Failed to load orders');
var data = await res.json();
ordersData = data.items || [];
renderOrders();
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--color-danger)">Error cargando órdenes</td></tr>';
}
};
function renderOrders() {
var tbody = document.getElementById('ordersTableBody');
var statusFilter = document.getElementById('orderStatusFilter').value;
var search = document.getElementById('orderSearch').value.toLowerCase();
var filtered = ordersData.filter(function(o) {
if (statusFilter && o.status !== statusFilter) return false;
if (search && !((o.buyer_name || '').toLowerCase().includes(search) || (o.external_order_id || '').includes(search))) return false;
return true;
});
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--color-text-muted)">No hay órdenes.</td></tr>';
return;
}
tbody.innerHTML = filtered.map(function(o) {
var statusClass = 'meli-status--' + (o.status || 'pending');
return '<tr>'
+ '<td><a href="#" onclick="showOrderDetail(' + o.id + ');return false;">' + escapeHtml(o.external_order_id) + '</a></td>'
+ '<td>' + escapeHtml(o.buyer_name || o.buyer_nickname || '—') + '</td>'
+ '<td style="text-align:right">$' + (o.total_amount || 0).toFixed(2) + '</td>'
+ '<td><span class="meli-status ' + statusClass + '">' + (o.status || '—') + '</span></td>'
+ '<td>' + (o.created_at ? o.created_at.split('T')[0] : '—') + '</td>'
+ '<td>'
+ (o.status === 'pending' ? '<button class="btn btn--primary btn--xs" onclick="convertOrder(' + o.id + ')">Convertir a Venta</button> ' : '')
+ '<button class="btn btn--ghost btn--xs" onclick="showOrderDetail(' + o.id + ')">Ver</button>'
+ '</td>'
+ '</tr>';
}).join('');
}
window.filterOrders = renderOrders;
window.showOrderDetail = async function(id) {
var modal = document.getElementById('orderModal');
var body = document.getElementById('orderModalBody');
var footer = document.getElementById('orderModalFooter');
body.innerHTML = 'Cargando...';
footer.innerHTML = '';
modal.classList.add('is-open');
try {
var res = await fetch(API + '/orders/' + id, { headers: headers() });
var o = await res.json();
if (!res.ok) throw new Error(o.error || 'Error');
var itemsHtml = (o.items || []).map(function(it) {
return '<tr><td>' + escapeHtml(it.title || '—') + '</td><td>' + it.quantity + '</td><td style="text-align:right">$' + (it.unit_price || 0).toFixed(2) + '</td></tr>';
}).join('');
body.innerHTML = '<div style="margin-bottom:var(--space-4);">'
+ '<p><strong>Comprador:</strong> ' + escapeHtml(o.buyer_name || '—') + ' (' + escapeHtml(o.buyer_nickname || '—') + ')</p>'
+ '<p><strong>Email:</strong> ' + escapeHtml(o.buyer_email || '—') + '</p>'
+ '<p><strong>Teléfono:</strong> ' + escapeHtml(o.buyer_phone || '—') + '</p>'
+ '<p><strong>Total:</strong> $' + (o.total_amount || 0).toFixed(2) + '</p>'
+ '<p><strong>Estado ML:</strong> ' + escapeHtml(o.external_status || '—') + '</p>'
+ '<p><strong>Estado Nexus:</strong> ' + escapeHtml(o.status || '—') + '</p>'
+ '</div>'
+ '<h4 style="margin:var(--space-3) 0;">Items</h4>'
+ '<table class="data-table"><thead><tr><th>Producto</th><th>Cantidad</th><th style="text-align:right">Precio</th></tr></thead><tbody>' + itemsHtml + '</tbody></table>';
footer.innerHTML = '';
if (o.status === 'pending') {
footer.innerHTML += '<button class="btn btn--primary" onclick="convertOrder(' + o.id + ');closeModal(\'orderModal\')">Convertir a Venta</button> ';
}
if (o.status === 'confirmed') {
footer.innerHTML += '<button class="btn btn--primary" onclick="updateOrderStatus(' + o.id + ', \'packed\')">Marcar Empacada</button> ';
}
if (o.status === 'packed') {
footer.innerHTML += '<button class="btn btn--primary" onclick="updateOrderStatus(' + o.id + ', \'shipped\')">Marcar Enviada</button> ';
}
footer.innerHTML += '<button class="btn btn--ghost" onclick="closeModal(\'orderModal\')">Cerrar</button>';
} catch (e) {
body.innerHTML = '<p style="color:var(--color-danger)">Error: ' + escapeHtml(e.message) + '</p>';
}
};
window.convertOrder = async function(id) {
try {
var res = await fetch(API + '/orders/' + id + '/convert', {
method: 'POST',
headers: headers(),
body: JSON.stringify({})
});
var data = await res.json();
if (res.ok) {
alert('Orden convertida a venta #' + data.sale_id);
loadOrders();
} else {
alert('Error: ' + (data.error || 'Unknown'));
}
} catch (e) {
alert('Error: ' + e.message);
}
};
window.updateOrderStatus = async function(id, status) {
try {
var res = await fetch(API + '/orders/' + id + '/status', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ status: status })
});
if (res.ok) { loadOrders(); } else { alert('Error'); }
} catch (e) { alert('Error: ' + e.message); }
};
// ─── Utils ─────────────────────────────────────────────────────────────
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ─── Init ──────────────────────────────────────────────────────────────
// Handle OAuth callback
var urlParams = new URLSearchParams(window.location.search);
var authCode = urlParams.get('code');
if (authCode && window.location.pathname.includes('marketplace-external')) {
(async function() {
var clientId = localStorage.getItem('meli_client_id');
var clientSecret = localStorage.getItem('meli_client_secret');
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
try {
var res = await fetch(API + '/connect', {
method: 'POST',
headers: headers(),
body: JSON.stringify({
code: authCode,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
})
});
var data = await res.json();
if (res.ok) {
alert('¡Conectado exitosamente con MercadoLibre!');
window.history.replaceState({}, document.title, '/pos/marketplace-external');
loadConfig();
} else {
alert('Error conectando: ' + (data.error || 'Unknown'));
}
} catch (e) {
alert('Error: ' + e.message);
}
})();
}
document.addEventListener('DOMContentLoaded', function() {
loadConfig();
});
})();

View File

@@ -123,6 +123,8 @@ const POS = (() => {
currentRegister = null;
document.getElementById('registerInfo').innerHTML =
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">&#x26A0; Sin caja abierta — Clic para abrir</span>';
// Force open register modal on first load
showOpenRegisterModal();
}
} catch (e) {
console.warn('Register check failed:', e);
@@ -253,6 +255,9 @@ const POS = (() => {
discount_pct: parseFloat(item.discount_pct || 0),
tax_rate: parseFloat(item.tax_rate || 0.16),
stock: item.stock || 0,
price_1: parseFloat(item.price_1 || 0),
price_2: parseFloat(item.price_2 || 0),
price_3: parseFloat(item.price_3 || 0),
});
renderCart();
@@ -265,6 +270,67 @@ const POS = (() => {
renderCart();
}
function clearCart() {
cart.length = 0;
selectedRow = -1;
renderCart();
}
function openCancelModal() {
const overlay = document.getElementById('overlay-cancelar-venta');
const dialog = document.getElementById('modal-cancelar-venta');
if (overlay) overlay.classList.add('active');
if (dialog) dialog.classList.add('active');
}
function closeCancelModal() {
const overlay = document.getElementById('overlay-cancelar-venta');
const dialog = document.getElementById('modal-cancelar-venta');
if (overlay) overlay.classList.remove('active');
if (dialog) dialog.classList.remove('active');
}
function changeQuantity() {
if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn');
return;
}
const q = prompt('Nueva cantidad:', cart[selectedRow].quantity);
if (q !== null) {
const n = parseInt(q);
if (n > 0) {
cart[selectedRow].quantity = n;
renderCart();
}
}
}
function applyDiscount() {
if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn');
return;
}
const d = prompt('Descuento %:', cart[selectedRow].discount_pct);
if (d !== null) {
const n = parseFloat(d);
if (n >= 0 && n <= 100) {
cart[selectedRow].discount_pct = n;
renderCart();
}
}
}
// Wire confirm-cancel button
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('btnConfirmCancel');
if (btn) {
btn.addEventListener('click', function() {
clearCart();
closeCancelModal();
});
}
});
function renderCart() {
const tbody = document.getElementById('cartBody');
const table = document.getElementById('cartTable');
@@ -472,6 +538,9 @@ const POS = (() => {
cost: item.cost,
tax_rate: item.tax_rate,
stock: item.stock,
price_1: item.price_1,
price_2: item.price_2,
price_3: item.price_3,
});
hideSearchResults();
document.getElementById('itemSearch').value = '';
@@ -530,11 +599,22 @@ const POS = (() => {
}
}
function recalcCartPrices() {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
cart.forEach(item => {
if (item.price_1 > 0) {
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
});
}
async function selectCustomer(customer) {
currentCustomer = customer;
document.getElementById('customerAutocomplete').style.display = 'none';
document.getElementById('customerSearchWrap').querySelector('input').style.display = 'none';
recalcCartPrices();
const tiers = { 1: 'P1 Mostrador', 2: 'P2 Taller', 3: 'P3 Mayoreo' };
document.getElementById('customerName').textContent = customer.name;
document.getElementById('customerTier').textContent = tiers[customer.price_tier] || 'P1';
@@ -1255,7 +1335,7 @@ const POS = (() => {
init();
return {
addToCart, removeFromCart, selectRow,
addToCart, removeFromCart, clearCart, selectRow,
updateQty, updateDiscount,
addFromSearch, hideSearchResults,
selectCustomer, clearCustomer,
@@ -1268,5 +1348,6 @@ const POS = (() => {
connectThermal, thermalPrint,
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
openCancelModal, closeCancelModal, changeQuantity, applyDiscount,
};
})();

File diff suppressed because one or more lines are too long

View File

@@ -29,6 +29,7 @@
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
{ name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' },
{ name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
@@ -180,4 +181,7 @@
window.toggleSidebar = toggleSidebar;
window.closeSidebar = closeSidebar;
// Reveal sidebar smoothly after replacement to avoid flash/stun
document.body.classList.add('sidebar-ready');
})();

View File

@@ -16,6 +16,7 @@
var activePhone = null;
var pollTimer = null;
var statusPollTimer = null;
var qrPollTimer = null;
var connectionState = 'unknown'; // 'open', 'close', 'connecting', 'unknown'
// -- Helpers ---------------------------------------------------------------
@@ -88,6 +89,10 @@
api('GET', '/status').then(function (data) {
var state = (data.instance || data).state || data.state || 'close';
updateConnectionUI(state);
// If bridge already has a QR ready, show it immediately
if (state === 'qr' || state === 'connecting') {
fetchQR();
}
}).catch(function () {
updateConnectionUI('close');
});
@@ -106,7 +111,8 @@
// Load conversations + start polling on page load / reconnect
loadConversations();
startPolling();
} else if (state === 'connecting') {
stopQRPolling();
} else if (state === 'connecting' || state === 'qr') {
statusDot.className = 'status-dot status-dot--warn';
statusText.textContent = 'Escaneando QR...';
connectSection.style.display = 'flex';
@@ -125,6 +131,7 @@
refreshQrBtn.style.display = 'none';
qrImg.style.display = 'none';
qrPlaceholder.style.display = '';
stopQRPolling();
}
}
@@ -141,8 +148,15 @@
return;
}
// Instance created, now fetch QR
fetchQR();
// Switch UI to connecting state immediately
updateConnectionUI('connecting');
qrPlaceholder.textContent = 'Iniciando conexion con WhatsApp, generando QR...';
qrPlaceholder.style.display = '';
qrImg.style.display = 'none';
// Start polling for QR; the first fetchQR may not have QR ready yet
startStatusPolling();
startQRPolling();
}).catch(function () {
connectBtn.disabled = false;
connectBtn.textContent = 'Conectar WhatsApp';
@@ -151,7 +165,10 @@
}
function fetchQR() {
qrPlaceholder.textContent = 'Generando QR...';
// Only update placeholder text if we don't already have a QR image showing
if (qrImg.style.display !== 'block') {
qrPlaceholder.textContent = 'Generando codigo QR, espera unos segundos...';
}
api('GET', '/qr').then(function (data) {
var base64 = data.qr || data.base64 || data.qrcode || '';
@@ -164,15 +181,19 @@
// Start polling for connection state while QR is shown
startStatusPolling();
startQRPolling();
} else if ((data.instance && data.instance.state === 'open') || data.state === 'open') {
// Already connected
updateConnectionUI('open');
loadConversations();
} else {
qrPlaceholder.textContent = 'No se pudo generar el QR. Intenta de nuevo.';
// QR not ready yet — this is normal right after pressing Connect
if (qrImg.style.display !== 'block') {
qrPlaceholder.textContent = 'Generando codigo QR, por favor espera... (el codigo cambia cada pocos segundos, escanealo en cuanto aparezca)';
qrPlaceholder.style.display = '';
qrImg.style.display = 'none';
}
}
}).catch(function () {
qrPlaceholder.textContent = 'Error al obtener QR';
});
@@ -208,6 +229,24 @@
}
}
function startQRPolling() {
stopQRPolling();
qrPollTimer = setInterval(function () {
if (connectionState === 'connecting' || connectionState === 'qr') {
fetchQR();
} else {
stopQRPolling();
}
}, 5000);
}
function stopQRPolling() {
if (qrPollTimer) {
clearInterval(qrPollTimer);
qrPollTimer = null;
}
}
connectBtn.addEventListener('click', doConnect);
disconnectBtn.addEventListener('click', doDisconnect);
refreshQrBtn.addEventListener('click', fetchQR);

View File

@@ -1,8 +1,8 @@
// /home/Autopartes/pos/static/pwa/sw.js
// Nexus POS — Service Worker v6
// Nexus POS — Service Worker v9
// Self-contained vanilla JS. No external imports.
const CACHE_NAME = 'nexus-pos-v6';
const CACHE_NAME = 'nexus-pos-v11';
const APP_SHELL = [
'/pos/static/css/tokens.css',

View File

@@ -98,6 +98,133 @@ def generate_report_task(self, report_type, params, tenant_id):
}
# ─── MercadoLibre Tasks ───────────────────────────────────────────────────
@celery.task(bind=True, max_retries=3)
def sync_meli_stock_price_task(self, tenant_id: int):
"""Sync stock and price for all active ML listings."""
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
conn = get_tenant_conn(tenant_id)
try:
cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg)
if not svc:
return {'error': 'MercadoLibre not configured'}
cur = conn.cursor()
cur.execute(
"""
SELECT id, inventory_id, external_item_id, publish_price
FROM marketplace_listings
WHERE is_active = true AND channel = 'mercadolibre'
"""
)
listings = cur.fetchall()
cur.close()
from services.inventory_engine import get_stock_bulk
stock_map = get_stock_bulk(conn, branch_id=None)
updated = 0
failed = 0
for row in listings:
listing_id, inv_id, ext_id, last_price = row
cur = conn.cursor()
cur.execute("SELECT price_1 FROM inventory WHERE id = %s", (inv_id,))
price_row = cur.fetchone()
cur.close()
current_price = float(price_row[0]) if price_row and price_row[0] else 0
current_stock = stock_map.get(inv_id, 0)
try:
svc.update_item(
ext_id,
{"price": round(current_price, 2), "available_quantity": max(current_stock, 0)},
)
cur = conn.cursor()
cur.execute(
"""
UPDATE marketplace_listings
SET last_sync_at = NOW(), sync_errors = NULL, publish_price = %s
WHERE id = %s
""",
(current_price, listing_id),
)
conn.commit()
cur.close()
updated += 1
except Exception as e:
conn.rollback()
cur = conn.cursor()
cur.execute(
"UPDATE marketplace_listings SET sync_errors = %s WHERE id = %s",
(str(e)[:500], listing_id),
)
conn.commit()
cur.close()
failed += 1
return {'updated': updated, 'failed': failed, 'total': len(listings)}
finally:
conn.close()
@celery.task(bind=True, max_retries=3)
def sync_meli_orders_task(self, tenant_id: int):
"""Fetch new orders from MercadoLibre."""
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
conn = get_tenant_conn(tenant_id)
try:
# Determine last check date from most recent order
cur = conn.cursor()
cur.execute(
"SELECT MAX(created_at) FROM marketplace_orders WHERE channel = 'mercadolibre'"
)
row = cur.fetchone()
last_check = row[0]
cur.close()
date_from = None
if last_check:
date_from = last_check.strftime('%Y-%m-%dT%H:%M:%S.000-00:00')
result = meli_svc.fetch_and_save_orders(conn, date_from=date_from)
return result
except Exception as e:
return {'error': str(e)}
finally:
conn.close()
@celery.task(bind=True, max_retries=3)
def process_meli_webhook_task(self, tenant_id: int, topic: str, resource: str):
"""Process incoming MercadoLibre webhook asynchronously."""
from services import marketplace_external_service as meli_svc
from tenant_db import get_tenant_conn
conn = get_tenant_conn(tenant_id)
try:
if topic.startswith("orders"):
# Fetch full order and upsert
cfg = meli_svc.get_meli_config(conn)
svc = meli_svc._get_meli_service(cfg)
if svc and resource:
order_id = resource.split("/")[-1]
full = svc.get_order(order_id)
# 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)
return {'ok': True, 'topic': topic}
except Exception as e:
return {'error': str(e)}
finally:
conn.close()
@celery.task(bind=True, max_retries=2)
def sync_vehicle_compatibility_task(self, tenant_id, item_id, part_number, name, brand, compat_source):
"""Fetch AI/TecDoc vehicle compatibility in background after item creation."""

View File

@@ -188,10 +188,10 @@
<!-- Tabs -->
<div class="tabs-row" role="tablist">
<button class="tab-btn is-active" role="tab" aria-selected="true" onclick="switchTab('cxc')">
Ctas. por Cobrar <span class="tab-btn__badge">23</span>
Ctas. por Cobrar <span class="tab-btn__badge" id="badge-cxc">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" onclick="switchTab('cxp')">
Ctas. por Pagar <span class="tab-btn__badge--alert tab-btn__badge">3</span>
Ctas. por Pagar <span class="tab-btn__badge--alert tab-btn__badge" id="badge-cxp">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" onclick="switchTab('balance')">
Balance General
@@ -498,5 +498,31 @@
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
<script>
// Load accounting stats for tab badges
async function loadAccountingStats() {
const token = localStorage.getItem('pos_token') || '';
try {
const res = await fetch('/pos/api/accounting/stats', {
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
});
if (!res.ok) return;
const data = await res.json();
const map = {
'badge-cxc': data.cuentas_cobrar,
'badge-cxp': data.cuentas_pagar
};
Object.entries(map).forEach(function([id, val]) {
const el = document.getElementById(id);
if (el) el.textContent = val || 0;
});
} catch (e) {
console.error('Failed to load accounting stats:', e);
}
}
window.loadAccountingStats = loadAccountingStats;
loadAccountingStats();
</script>
</body>
</html>

View File

@@ -420,6 +420,10 @@
<div class="credit-metric__label">Utilizado</div>
<div class="credit-metric__value used" id="detailCreditUsed">$31,500</div>
</div>
<div class="credit-metric">
<div class="credit-metric__label">Descuento Max</div>
<div class="credit-metric__value" id="detailMaxDiscount">0%</div>
</div>
</div>
<div class="credit-progress">
<div class="credit-progress__labels">
@@ -611,7 +615,7 @@
</div>
<div class="panel-footer">
<button class="btn-edit" onclick="if(typeof Customers!=='undefined') Customers.editCurrent();">Editar Cliente</button>
<button class="btn-sale" onclick="window.location.href='/pos/';">Nueva Venta</button>
<button class="btn-sale" onclick="window.location.href='/pos/sale';">Nueva Venta</button>
</div>
</div>

View File

@@ -13,7 +13,7 @@
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<link rel="stylesheet" href="/pos/static/css/inventory.css">
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=4">
</head>
<body>
@@ -306,10 +306,18 @@
]);
}
</script>
<button class="btn btn--ghost btn--sm" onclick="showTierDiscountModal()">
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
</button>
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto
</button>
<button class="btn btn--sm btn--meli" id="btnPublishML" style="display:none;" onclick="openMeliPublishModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
Publicar en ML <span id="meliSelectedCountBadge" style="background:#2D3277;color:#FFE600;border-radius:10px;padding:0 6px;font-size:11px;margin-left:4px;">0</span>
</button>
</div>
<div class="table-wrapper">
@@ -317,6 +325,7 @@
<table class="data-table" id="stockTable">
<thead>
<tr>
<th style="width:32px;"><input type="checkbox" id="selectAllItems" onclick="toggleSelectAllItems()" title="Seleccionar todos" /></th>
<th style="font-size:var(--text-caption);color:var(--color-text-muted);">ID</th>
<th>Barcode</th>
<th>No. Parte</th>
@@ -324,9 +333,9 @@
<th>Marca</th>
<th style="text-align:right">Stock</th>
<th style="text-align:right">Costo</th>
<th style="text-align:right">Precio 1</th>
<th style="text-align:right">Precio 2</th>
<th style="text-align:right">Precio 3</th>
<th style="text-align:right">Mostrador</th>
<th style="text-align:right">Taller</th>
<th style="text-align:right">Mayoreo</th>
<th>Ubicación</th>
<th>Acciones</th>
</tr>
@@ -689,9 +698,7 @@
<div class="inv-field"><label>Marca</label><input type="text" id="newBrand" placeholder="Marca" /></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>Costo</label><input type="number" id="newCost" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio 1</label><input type="number" id="newPrice1" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio 2</label><input type="number" id="newPrice2" step="0.01" placeholder="0.00" /></div>
<div class="inv-field"><label>Precio 3</label><input type="number" id="newPrice3" 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>
<div class="inv-field"><label>Stock Inicial</label><input type="number" id="newInitialStock" placeholder="0" /></div>
<div class="inv-field"><label>Ubicación</label><input type="text" id="newLocation" placeholder="Ej: A-12-3" /></div>
@@ -809,6 +816,73 @@
</div>
</div>
<!-- Tier Discounts Modal -->
<div class="inv-modal-overlay" id="tierDiscountModal">
<div class="inv-modal">
<div class="inv-modal__header">
<h3>Descuentos por Tipo de Cliente</h3>
<button class="inv-modal__close" onclick="closeTierDiscountModal()">&times;</button>
</div>
<div class="inv-modal__body">
<p style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-4);">Estos descuentos se aplican automáticamente a todos los productos al calcular precios de Taller y Mayoreo.</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div class="inv-field"><label>Descuento Taller (%)</label><input type="number" id="tierDisc2" step="0.1" min="0" max="100" placeholder="15" /></div>
<div class="inv-field"><label>Descuento Mayoreo (%)</label><input type="number" id="tierDisc3" step="0.1" min="0" max="100" placeholder="25" /></div>
</div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeTierDiscountModal()">Cancelar</button>
<button class="btn btn--primary" onclick="saveTierDiscounts()">Guardar</button>
</div>
</div>
</div>
<!-- ══════════ Publicar en MercadoLibre Modal ══════════ -->
<div class="inv-modal-overlay" id="meliPublishModal">
<div class="inv-modal inv-modal--wide">
<div class="inv-modal__header">
<h3>Publicar en MercadoLibre</h3>
<button class="inv-modal__close" onclick="closeMeliPublishModal()">&times;</button>
</div>
<div class="inv-modal__body">
<div id="meliPublishSelectedCount" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-3);">0 productos seleccionados</div>
<div id="meliPublishItemsPreview" style="max-height:200px;overflow-y:auto;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);margin-bottom:var(--space-4);">
<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Selecciona productos del inventario para ver el preview.</p>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;margin-bottom:var(--space-4);">
<div class="inv-field">
<label>Categoría ML *</label>
<div style="position:relative;">
<input type="text" id="meliCategorySearch" placeholder="Buscar categoría..." oninput="searchMeliCategories()" onkeydown="handleMeliCatKeydown(event)" autocomplete="off" />
<div id="meliCategoryResults"></div>
</div>
<input type="hidden" id="meliCategoryId" />
</div>
<div class="inv-field">
<label>Tipo de Publicación</label>
<select id="meliListingType">
<option value="gold_special">Gold Special</option>
<option value="gold_pro">Gold Pro</option>
<option value="bronze">Bronce (gratis)</option>
</select>
</div>
<div class="inv-field">
<label>Modo de Envío</label>
<select id="meliShippingMode">
<option value="me2" selected>MercadoEnvíos (me2)</option>
</select>
<small style="color:var(--color-text-muted);font-size:var(--text-caption);">Tu cuenta requiere ME2 obligatoriamente.</small>
</div>
</div>
<div id="meliPublishResult" style="min-height:1.5em;"></div>
</div>
<div class="inv-modal__footer">
<button class="btn btn--ghost" onclick="closeMeliPublishModal()">Cancelar</button>
<button class="btn btn--meli" id="meliPublishBtn" onclick="executeMeliPublish()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> Publicar</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>
@@ -821,7 +895,7 @@
<script src="/pos/static/js/pos-utils.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/virtual-scroll.js" defer></script>
<script src="/pos/static/js/inventory.js?v=5" defer></script>
<script src="/pos/static/js/inventory.js?v=10" defer></script>
<script src="/pos/static/js/offline-banner.js" 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>

View File

@@ -301,16 +301,16 @@
<!-- Tabs Row -->
<div class="tabs-row" role="tablist" aria-label="Módulos de Facturación">
<button class="tab-btn is-active" role="tab" aria-selected="true" aria-controls="panel-facturas" id="tab-facturas" onclick="switchTab('facturas')">
Facturas <span class="tab-btn__badge">247</span>
Facturas <span class="tab-btn__badge" id="badge-facturas">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-notas" id="tab-notas" onclick="switchTab('notas')">
Notas de Crédito <span class="tab-btn__badge">8</span>
Notas de Crédito <span class="tab-btn__badge" id="badge-notas-credito">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-complementos" id="tab-complementos" onclick="switchTab('complementos')">
Complementos de Pago <span class="tab-btn__badge">12</span>
Complementos de Pago <span class="tab-btn__badge" id="badge-complementos">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-cancelaciones" id="tab-cancelaciones" onclick="switchTab('cancelaciones')">
Cancelaciones <span class="tab-btn__badge tab-btn__badge--warn">6</span>
Cancelaciones <span class="tab-btn__badge tab-btn__badge--warn" id="badge-cancelaciones">0</span>
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-config" id="tab-config" onclick="switchTab('config')">
Configuración CFDI
@@ -1060,5 +1060,33 @@
<script src="/pos/static/js/pwa-install.js" defer></script>
<script src="/pos/static/js/chat.js" defer></script>
<script>
// Load invoicing stats for tab badges
async function loadInvoicingStats() {
const token = localStorage.getItem('pos_token') || '';
try {
const res = await fetch('/pos/api/invoicing/stats', {
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
});
if (!res.ok) return;
const data = await res.json();
const map = {
'badge-facturas': data.facturas,
'badge-notas-credito': data.notas_credito,
'badge-complementos': data.complementos,
'badge-cancelaciones': data.cancelaciones
};
Object.entries(map).forEach(function([id, val]) {
const el = document.getElementById(id);
if (el) el.textContent = val || 0;
});
} catch (e) {
console.error('Failed to load invoicing stats:', e);
}
}
window.loadInvoicingStats = loadInvoicingStats;
loadInvoicingStats();
</script>
</body>
</html>

View File

@@ -291,6 +291,13 @@
btnLogin.disabled = !canLogin;
}
/* ------------------------------------------------------------------
LOGIN BUTTON CLICK
------------------------------------------------------------------ */
btnLogin.addEventListener('click', function() {
triggerLogin();
});
/* ------------------------------------------------------------------
TRIGGER LOGIN (demo)
------------------------------------------------------------------ */

View File

@@ -57,10 +57,11 @@
<h2 style="margin-bottom:var(--space-4);font-family:var(--font-heading);">Mi Inventario</h2>
<div class="upload-box">
<label>Cargar inventario via CSV</label>
<textarea id="csvText" placeholder="part_number,stock,price&#10;AB-123,5,150.50&#10;CD-456,12,89.00"></textarea>
<textarea id="csvText" placeholder="part_number,stock,price,name&#10;AB-123,5,150.50,Filtro de aceite&#10;CD-456,12,89.00,Balata delantera"></textarea>
<div class="hint">
Columnas requeridas: <code>part_number, stock, price</code>.
Opcionales: <code>min_order, warehouse_location, currency</code>.
Opcionales: <code>name, min_order, warehouse_location, currency</code>.
<br>Si la parte no existe en el catálogo, se crea automáticamente como <em>seller listing</em>.
</div>
<button style="margin-top:var(--space-3);padding:var(--space-3) var(--space-6);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-md);font-weight:bold;cursor:pointer;" onclick="uploadCSV()">Subir CSV</button>
<div id="uploadResult" style="margin-top:var(--space-3);font-size:var(--text-body-sm);"></div>
@@ -205,8 +206,18 @@
var priceStr = p.min_price === p.max_price
? '$' + fmt(p.min_price)
: '$' + fmt(p.min_price) + ' $' + fmt(p.max_price);
return '<div class="part-card" onclick="openPartDetail(' + p.id_part + ')">' +
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
var isOem = p.listing_type === 'oem';
var badge = isOem
? '<span style="background:#3FB95020;color:#3FB950;padding:2px 6px;border-radius:4px;font-size:var(--text-caption);">Catálogo</span>'
: '<span style="background:#F5A62320;color:#F5A623;padding:2px 6px;border-radius:4px;font-size:var(--text-caption);">Listing</span>';
var onclick = isOem
? 'openPartDetail(' + p.id + ')'
: 'openListingDetail(' + p.id + ')';
return '<div class="part-card" onclick="' + onclick + '">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-1);">' +
'<div class="part-card__oem">' + esc(p.part_number) + '</div>' +
badge +
'</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'<div class="part-card__meta">' +
'<span class="price-range">' + priceStr + '</span>' +
@@ -248,7 +259,7 @@
'</div>' +
'<div style="text-align:right;">' +
'<div class="price-range">$' + fmt(b.price) + '</div>' +
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(' + partId + ', ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(' + partId + ', null, ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
'</div>' +
'</div>';
});
@@ -257,7 +268,38 @@
});
};
window.createOrderFor = function (partId, bodegaId, bodegaName, price) {
window.openListingDetail = function (wiId) {
var modal = document.getElementById('partModal');
modal.classList.add('open');
document.getElementById('partModalBody').innerHTML = 'Cargando bodegas...';
apiFetch('/inventory/listing/' + wiId).then(function (resp) {
if (!resp || !resp.data) return;
var bodegas = resp.data;
if (bodegas.length === 0) {
document.getElementById('partModalBody').innerHTML = '<div class="empty-state">Ninguna bodega tiene esta parte en stock.</div>';
return;
}
var html = '<p style="color:var(--color-text-muted);margin-bottom:var(--space-3);">Elige una bodega para ordenar:</p>';
html += '<div style="display:flex;flex-direction:column;gap:var(--space-2);">';
bodegas.forEach(function (b) {
html += '<div style="padding:var(--space-3);border:1px solid var(--glass-border);border-radius:var(--radius-md);display:flex;justify-content:space-between;align-items:center;">' +
'<div>' +
'<strong>' + esc(b.name) + '</strong>' +
'<div style="color:var(--color-text-muted);font-size:var(--text-caption);">' + esc(b.city || '') + ' · ' + esc(b.stock_hint) + '</div>' +
'</div>' +
'<div style="text-align:right;">' +
'<div class="price-range">$' + fmt(b.price) + '</div>' +
'<button style="margin-top:var(--space-1);padding:var(--space-1) var(--space-3);background:var(--gradient-accent);color:var(--btn-primary-text);border:none;border-radius:var(--radius-sm);font-size:var(--text-caption);cursor:pointer;" onclick="createOrderFor(null, ' + wiId + ', ' + b.id_bodega + ', \'' + esc(b.name).replace(/\'/g, '\\\'') + '\', ' + (b.price || 0) + ')">Ordenar</button>' +
'</div>' +
'</div>';
});
html += '</div>';
document.getElementById('partModalBody').innerHTML = html;
});
};
window.createOrderFor = function (partId, wiId, bodegaId, bodegaName, price) {
var qty = prompt('Cantidad a ordenar para "' + bodegaName + '":', '1');
if (!qty) return;
qty = parseInt(qty);
@@ -265,12 +307,22 @@
var notes = prompt('Notas para la bodega (opcional):', '') || '';
var item = { quantity: qty, unit_price: price };
if (partId) {
item.part_id = partId;
} else if (wiId) {
item.wi_id = wiId;
} else {
alert('Error: se requiere part_id o wi_id');
return;
}
// Create PO draft
apiFetch('/orders', {
method: 'POST',
body: JSON.stringify({
bodega_id: bodegaId,
items: [{ part_id: partId, quantity: qty, unit_price: price }],
items: [item],
delivery_method: 'pickup',
buyer_notes: notes,
}),
@@ -482,6 +534,10 @@
r.updated + ' actualizados';
if (r.skipped > 0) msg += ', ' + r.skipped + ' omitidos';
msg += '</span>';
if (r.oem_count !== undefined || r.seller_count !== undefined) {
msg += '<div style="margin-top:var(--space-2);font-size:var(--text-caption);color:var(--color-text-muted);">' +
(r.oem_count || 0) + ' catálogo OEM · ' + (r.seller_count || 0) + ' seller listings</div>';
}
if (r.errors && r.errors.length) {
msg += '<div style="margin-top:var(--space-2);color:var(--color-text-muted);font-size:var(--text-caption);">';
r.errors.slice(0, 5).forEach(function (e) {

View File

@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html lang="es" data-theme="industrial">
<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>MercadoLibre — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<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/sidebar.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/inventory.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<style>
.meli-status { display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:20px;font-size:12px;font-weight:600; }
.meli-status--active { background:#d4edda;color:#155724; }
.meli-status--paused { background:#fff3cd;color:#856404; }
.meli-status--closed { background:#f8d7da;color:#721c24; }
.meli-status--pending { background:#e2e3e5;color:#383d41; }
.meli-card { background:var(--color-surface-1);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-4); }
.meli-grid { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:var(--space-4); }
.meli-connect-btn { display:inline-flex;align-items:center;gap:8px;padding:10px 20px;background:#FFE600;color:#2D3277;border:none;border-radius:var(--radius-md);font-weight:700;cursor:pointer; }
.meli-connect-btn:hover { filter:brightness(0.95); }
.meli-config-row { display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4); }
.meli-config-row label { display:block;font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:4px; }
.meli-config-row input, .meli-config-row select { padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface-0);color:var(--color-text-primary); }
</style>
</head>
<body>
<!-- =========================================================================
THEME SWITCHER BAR
========================================================================= -->
<header class="theme-bar" role="banner">
<div class="theme-bar__left">
<div class="theme-bar__store">
<span class="theme-bar__dot"></span>
Nexus Autoparts
</div>
<div class="theme-bar__sep"></div>
<span class="theme-bar__label">MercadoLibre — Integración</span>
</div>
<div class="theme-bar__right">
<span class="theme-bar__label">Tema:</span>
<button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')">
<span class="theme-btn__swatch"></span>
Industrial
</button>
<button class="theme-btn theme-btn--modern" data-theme-target="modern" onclick="setTheme('modern')">
<span class="theme-btn__swatch"></span>
Moderno
</button>
</div>
</header>
<!-- =========================================================================
APP SHELL
========================================================================= -->
<div class="app-shell">
<!-- -----------------------------------------------------------------------
SIDEBAR NAVIGATION
----------------------------------------------------------------------- -->
<aside class="sidebar" role="navigation" aria-label="Navegación principal">
<div class="sidebar__brand">
<div class="brand-logo">NA</div>
<div class="brand-name">
<span class="brand-name__primary">Nexus</span>
<span class="brand-name__sub">Autoparts POS</span>
</div>
</div>
<nav class="sidebar__nav">
<div class="nav-section-label">Principal</div>
<a class="nav-item" href="/pos/dashboard">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
</svg>
<span>Dashboard</span>
</a>
<a class="nav-item" href="/pos/sale">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
<span>POS</span>
</a>
<a class="nav-item" href="/pos/catalog">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
<span>Catálogo</span>
</a>
<a class="nav-item" href="/pos/inventory">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<span>Inventario</span>
</a>
<div class="nav-section-label">Gestión</div>
<a class="nav-item" href="/pos/customers">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span>Clientes</span>
</a>
<a class="nav-item" href="/pos/marketplace">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>
</svg>
<span>Marketplace B2B</span>
</a>
<a class="nav-item is-active" href="/pos/marketplace-external" aria-current="page">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
<span>MercadoLibre</span>
</a>
<a class="nav-item" href="/pos/invoicing">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<span>Facturación</span>
</a>
<a class="nav-item" href="/pos/config">
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
</svg>
<span>Configuración</span>
</a>
</nav>
<div class="sidebar__footer">
<div class="sidebar__user-avatar" id="sidebarAvatar">U</div>
<div class="sidebar__user-info">
<div class="sidebar__user-name" id="sidebarName">Usuario</div>
<div class="sidebar__user-role" id="sidebarRole"></div>
</div>
</div>
</aside>
<!-- -----------------------------------------------------------------------
MAIN CONTENT
----------------------------------------------------------------------- -->
<main class="main" role="main">
<!-- Page Header -->
<div class="page-header">
<div class="page-header__title-group">
<span class="page-header__eyebrow">Marketplace</span>
<h1 class="page-header__title">MercadoLibre</h1>
</div>
<div class="page-header__actions">
<a class="btn btn--ghost" href="/pos/dashboard">
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Dashboard
</a>
</div>
</div>
<!-- Tab Bar -->
<div class="tab-bar">
<button class="tab-btn is-active" role="tab" aria-selected="true" aria-controls="panel-config" onclick="switchTab('config')">
Configuración
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-listings" onclick="switchTab('listings')">
Publicaciones
</button>
<button class="tab-btn" role="tab" aria-selected="false" aria-controls="panel-orders" onclick="switchTab('orders')">
Órdenes
</button>
</div>
<!-- Tab Panels -->
<div class="tab-panels" id="tab-panels">
<!-- ══════════ TAB: Configuración ══════════ -->
<div class="tab-panel is-active" id="panel-config" role="tabpanel">
<div class="meli-card" style="max-width:600px;">
<h3 style="margin:0 0 var(--space-4);font-family:var(--font-heading);">Conexión con MercadoLibre</h3>
<div id="configStatus">
<p>Cargando estado...</p>
</div>
<div id="configForm" style="display:none;margin-top:var(--space-4);">
<p style="margin-bottom:var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-secondary);">
Para conectar, necesitas una <strong>aplicación de MercadoLibre</strong>.
Ve a <a href="https://developers.mercadolibre.com.mx" target="_blank">developers.mercadolibre.com.mx</a>
y crea una app. Luego pega los datos aquí:
</p>
<div class="meli-config-row">
<div>
<label>Client ID</label>
<input type="text" id="cfgClientId" placeholder="Tu App ID" style="width:200px;" />
</div>
<div>
<label>Client Secret</label>
<input type="password" id="cfgClientSecret" placeholder="Tu Secret Key" style="width:200px;" />
</div>
</div>
<div class="meli-config-row">
<div>
<label>Categoría Default</label>
<input type="text" id="cfgCategory" placeholder="MLM1747" style="width:150px;" />
</div>
<div>
<label>Modo de Envío</label>
<select id="cfgShipping">
<option value="me2">MercadoEnvíos (me2)</option>
<option value="custom">Propio (custom)</option>
</select>
</div>
</div>
<button class="meli-connect-btn" onclick="startOAuth()">🔗 Conectar con MercadoLibre</button>
</div>
<div id="configConnected" style="display:none;margin-top:var(--space-4);">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:var(--space-3);">
<div style="width:48px;height:48px;border-radius:50%;background:#FFE600;display:flex;align-items:center;justify-content:center;font-weight:800;color:#2D3277;">ML</div>
<div>
<div style="font-weight:700;" id="connectedNickname">Usuario ML</div>
<div style="font-size:var(--text-caption);color:var(--color-text-muted);" id="connectedSite">MLM</div>
</div>
</div>
<button class="btn btn--danger btn--sm" onclick="disconnectMeli()">Desconectar</button>
</div>
</div>
</div>
<!-- ══════════ TAB: Publicaciones ══════════ -->
<div class="tab-panel" id="panel-listings" 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="listingSearch" placeholder="Buscar publicación..." oninput="filterListings()" />
</div>
<select class="select-filter" id="listingStatusFilter" onchange="filterListings()">
<option value="">Todos los estados</option>
<option value="active">Activas</option>
<option value="paused">Pausadas</option>
<option value="closed">Cerradas</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--primary" onclick="loadListings()">🔄 Actualizar</button>
</div>
<div id="listingsContainer" class="meli-grid"></div>
<div id="listingsPagination" class="table-footer" style="margin-top:var(--space-4);"></div>
</div>
<!-- ══════════ TAB: Órdenes ══════════ -->
<div class="tab-panel" id="panel-orders" 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="orderSearch" placeholder="Buscar orden..." oninput="filterOrders()" />
</div>
<select class="select-filter" id="orderStatusFilter" onchange="filterOrders()">
<option value="">Todos los estados</option>
<option value="pending">Pendientes</option>
<option value="confirmed">Confirmadas</option>
<option value="packed">Empacadas</option>
<option value="shipped">Enviadas</option>
<option value="delivered">Entregadas</option>
<option value="cancelled">Canceladas</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--primary" onclick="loadOrders()">🔄 Actualizar</button>
</div>
<div class="table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Orden ML</th>
<th>Comprador</th>
<th style="text-align:right">Total</th>
<th>Estado Nexus</th>
<th>Fecha</th>
<th>Acciones</th>
</tr>
</thead>
<tbody id="ordersTableBody"></tbody>
</table>
<div id="ordersPagination" class="table-footer"></div>
</div>
</div>
</div><!-- /tab-panels -->
</main>
</div><!-- /app-shell -->
<!-- ══════════ Order Detail Modal ══════════ -->
<div class="inv-modal-overlay" id="orderModal">
<div class="inv-modal inv-modal--wide">
<div class="inv-modal__header">
<h3>Detalle de Orden ML</h3>
<button class="inv-modal__close" onclick="closeModal('orderModal')">&times;</button>
</div>
<div class="inv-modal__body" id="orderModalBody">cargando...</div>
<div class="inv-modal__footer" id="orderModalFooter"></div>
</div>
</div>
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>
<script src="/pos/static/js/marketplace_external.js?v=3" defer></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
</body>
</html>

View File

@@ -32,6 +32,10 @@
<span id="statusClock"></span>
</div>
<div class="status-bar__right">
<a href="/pos/dashboard" class="btn btn--ghost btn--sm" id="backToSystemBtn" style="margin-right:8px;display:none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Sistema
</a>
<div class="status-bar__user" aria-label="Usuario activo">
<div class="status-bar__user-avatar" aria-hidden="true">--</div>
<span id="employeeName">Empleado</span>
@@ -203,7 +207,7 @@
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
<button class="btn-secondary-action" onclick="POS.showCutZModal()" title="Corte Z - Cerrar caja">Corte Z</button>
<button class="btn-secondary-action danger" id="btnCancelSale" title="Cancelar (Esc)">Cancelar</button>
<button class="btn-secondary-action danger" id="btnCancelSale" onclick="POS.openCancelModal()" title="Cancelar (Esc)">Cancelar</button>
</div>
</div>
</aside>
@@ -233,14 +237,14 @@
<span class="fkey-key">F6</span><span class="fkey-label">Cajon</span>
</div>
<div class="fkey-sep"></div>
<div class="fkey" title="Cantidad +/-">
<div class="fkey" onclick="POS.changeQuantity()" title="Cantidad +/-">
<span class="fkey-key">+/-</span><span class="fkey-label">Cantidad</span>
</div>
<div class="fkey" title="Descuento">
<div class="fkey" onclick="POS.applyDiscount()" title="Descuento">
<span class="fkey-key">*</span><span class="fkey-label">Descuento</span>
</div>
<div class="fkey-sep"></div>
<div class="fkey" id="fkeyEsc" title="Cancelar">
<div class="fkey" id="fkeyEsc" onclick="POS.openCancelModal()" title="Cancelar">
<span class="fkey-key">Esc</span><span class="fkey-label">Cancelar</span>
</div>
</div>
@@ -563,7 +567,7 @@
<script src="/pos/static/js/app-init.js" defer></script>
<script src="/pos/static/js/push.js" defer></script>
<script src="/pos/static/js/printer.js" defer></script>
<script src="/pos/static/js/pos.js?v=4" defer></script>
<script src="/pos/static/js/pos.js?v=5" defer></script>
<script>
// Cancel sale button wiring
@@ -577,11 +581,7 @@
closeCancelModal();
// Clear cart via POS module
if (typeof POS !== 'undefined') {
// Clear each item
const tbody = document.getElementById('cartBody');
while (tbody && tbody.firstChild) {
POS.removeFromCart(0);
}
POS.clearCart();
POS.clearCustomer();
}
});
@@ -614,6 +614,20 @@
}
updateClock();
setInterval(updateClock, 30000);
// Show "Back to System" button when NOT in kiosk mode
(function checkKiosk() {
var btn = document.getElementById('backToSystemBtn');
if (!btn) return;
function showIfNotKiosk() {
try {
var isKiosk = window.isKioskEnabled && window.isKioskEnabled();
btn.style.display = isKiosk ? 'none' : 'inline-flex';
} catch (e) { btn.style.display = 'inline-flex'; }
}
showIfNotKiosk();
setInterval(showIfNotKiosk, 2000);
})();
</script>
<script src="/pos/static/js/chat.js" defer></script>

View File

@@ -65,7 +65,9 @@
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;font-family:var(--font-mono);font-weight:700;">$' + fmt(q.total) + '</td>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + statusBadge + '</td>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;color:var(--color-text-muted);">' + dateStr + '</td>';
html += '<td><button onclick="deleteQuote(' + q.id + ', event)" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:16px;padding:4px 8px;border-radius:4px;" onmouseover="this.style.color=\'#F85149\';this.style.background=\'rgba(248,81,73,0.1)\'" onmouseout="this.style.color=\'var(--color-text-muted)\';this.style.background=\'none\'">🗑️</button></td>';
html += '<td><button onclick="deleteQuote(' + q.id + ', event)" style="background:var(--color-bg-overlay);border:1.5px solid var(--color-border);color:var(--color-text-muted);cursor:pointer;font-size:13px;padding:6px 10px;border-radius:var(--radius-md);display:inline-flex;align-items:center;gap:4px;transition:var(--transition-fast);" onmouseover="this.style.color=\'var(--color-error)\';this.style.borderColor=\'var(--color-error)\';this.style.background=\'rgba(248,81,73,0.08)\'" onmouseout="this.style.color=\'var(--color-text-muted)\';this.style.borderColor=\'var(--color-border)\';this.style.background=\'var(--color-bg-overlay)\'">';
html += '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
html += 'Eliminar</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
@@ -114,15 +116,33 @@
html += '<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-text-accent);">Total: $' + fmt(q.total) + '</div>';
html += '</div>';
html += '<div style="margin-top:var(--space-5);display:flex;gap:var(--space-3);justify-content:flex-end;flex-wrap:wrap;">';
html += '<div style="margin-top:var(--space-5);">';
// Primary actions
if (q.status === 'active') {
html += '<button class="btn btn--ghost" onclick="editQuote(' + q.id + ')" style="color:#4f46e5;">Editar</button>';
html += '<button class="btn btn--ghost" onclick="convertQuote(' + q.id + ')" style="color:#059669;">Convertir a venta</button>';
html += '<button class="btn btn--ghost" onclick="shareQuote(' + q.id + ')">Compartir link</button>';
html += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-3);">';
html += '<button class="btn btn--primary" onclick="convertQuote(' + q.id + ')" style="padding:10px 20px;font-size:var(--text-body);display:inline-flex;align-items:center;gap:8px;">';
html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>';
html += 'Convertir a Venta</button>';
html += '<button class="btn btn--secondary" onclick="editQuote(' + q.id + ')" style="padding:10px 20px;font-size:var(--text-body);display:inline-flex;align-items:center;gap:8px;">';
html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
html += 'Editar</button>';
html += '<button class="btn btn--ghost" onclick="shareQuote(' + q.id + ')" style="padding:10px 20px;font-size:var(--text-body);display:inline-flex;align-items:center;gap:8px;border:1.5px solid var(--color-border);">';
html += '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>';
html += 'Compartir</button>';
html += '</div>';
}
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="color:#F85149;">Eliminar</button>';
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')">Exportar CSV</button>';
html += '<button class="btn btn--ghost" onclick="window.print()">Imprimir</button>';
// Secondary actions
html += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;justify-content:flex-end;">';
html += '<button class="btn btn--ghost" onclick="window.print()" style="padding:8px 16px;font-size:var(--text-body-sm);display:inline-flex;align-items:center;gap:6px;">';
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>';
html += 'Imprimir</button>';
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')" style="padding:8px 16px;font-size:var(--text-body-sm);display:inline-flex;align-items:center;gap:6px;">';
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>';
html += 'Exportar CSV</button>';
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="padding:8px 16px;font-size:var(--text-body-sm);display:inline-flex;align-items:center;gap:6px;color:var(--color-error);">';
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
html += 'Eliminar</button>';
html += '</div>';
html += '</div>';
document.getElementById('quoteDetail').innerHTML = html;
@@ -142,10 +162,12 @@
} else {
alert('Error: ' + (d.error || 'desconocido'));
}
});
})
.catch(function(err) { alert('Error de red al eliminar: ' + err.message); });
};
window.editQuote = function(id) {
try {
fetch(API + '/quotations/' + id, { headers: headers() })
.then(function(r) { return r.json(); })
.then(function(q) {
@@ -165,11 +187,14 @@
localStorage.setItem('pos_edit_quote_customer_id', q.customer_id || '');
localStorage.setItem('pos_edit_quote_notes', q.notes || '');
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
window.location.href = '/pos';
});
window.location.href = '/pos/sale';
})
.catch(function(err) { alert('Error al cargar para editar: ' + err.message); });
} catch (e) { alert('Excepcion en editQuote: ' + e.message); }
};
window.convertQuote = function(id) {
try {
fetch(API + '/quotations/' + id, { headers: headers() })
.then(function(r) { return r.json(); })
.then(function(q) {
@@ -187,24 +212,33 @@
});
localStorage.setItem('pos_convert_quote_id', id);
localStorage.setItem('pos_cart', JSON.stringify(cartItems));
window.location.href = '/pos';
});
window.location.href = '/pos/sale';
})
.catch(function(err) { alert('Error al cargar para convertir: ' + err.message); });
} catch (e) { alert('Excepcion en convertQuote: ' + e.message); }
};
window.shareQuote = function(id) {
try {
fetch(API + '/quotations/' + id + '/share', { method: 'POST', headers: headers() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.url) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(d.url).then(function() {
alert('Link copiado al portapapeles:\n' + d.url);
}).catch(function() {
prompt('Copia este link:', d.url);
});
} else {
alert('Error: ' + (d.error || 'desconocido'));
prompt('Copia este link:', d.url);
}
});
} else {
alert('Error del servidor: ' + (d.error || 'desconocido'));
}
})
.catch(function(err) { alert('Error de red al compartir: ' + err.message); });
} catch (e) { alert('Excepcion en shareQuote: ' + e.message); }
};
// Close modal on outside click

View File

@@ -131,7 +131,7 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
<!-- Sidebar -->
<script src="/pos/static/js/i18n.js" defer></script>
<script src="/pos/static/js/whatsapp2.js" defer></script>
<script src="/pos/static/js/whatsapp2.js?v=5" defer></script>
<script src="/pos/static/js/pos-utils.js" defer></script>
<script src="/pos/static/js/sidebar.js" defer></script>

View File

@@ -1,7 +1,9 @@
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion } = require('@whiskeysockets/baileys');
const { default: makeWASocket, useMultiFileAuthState, DisconnectReason, fetchLatestBaileysVersion, downloadMediaMessage } = require('@whiskeysockets/baileys');
const express = require('express');
const QRCode = require('qrcode');
const pino = require('pino');
const fs = require('fs');
const path = require('path');
const app = express();
app.use(express.json());
@@ -17,15 +19,117 @@ const AUTH_DIR = process.env.AUTH_DIR || '/app/auth';
let sock = null;
let qrCode = null;
let connectionState = 'disconnected';
let retry440Count = 0;
let lastConnectAttempt = 0;
const logger = pino({ level: process.env.LOG_LEVEL || 'warn' });
// Queue for outgoing messages when disconnected
const sendQueue = [];
let queueTimer = null;
let connectWatchdog = null;
let staleWatchdog = null;
const WATCHDOG_MS = 90000;
const STALE_MS = 90000;
let lastActivity = Date.now();
function updateActivity() {
lastActivity = Date.now();
}
function clearAuthState() {
try {
if (fs.existsSync(AUTH_DIR)) {
fs.readdirSync(AUTH_DIR).forEach(f => {
try { fs.unlinkSync(path.join(AUTH_DIR, f)); } catch (e) {}
});
}
console.log(`[Tenant ${TENANT_ID}] Auth state cleared`);
} catch (e) {
console.error(`[Tenant ${TENANT_ID}] Failed to clear auth:`, e.message);
}
}
function flushSendQueue() {
if (!sock || connectionState !== 'open') return;
while (sendQueue.length > 0) {
const item = sendQueue.shift();
try {
sock.sendMessage(item.jid, { text: item.message });
console.log(`[Tenant ${TENANT_ID}] Flushed queued message to ${item.jid}`);
} catch (e) {
console.error(`[Tenant ${TENANT_ID}] Failed to flush queued message:`, e.message);
sendQueue.unshift(item);
break;
}
}
}
function clearWatchdog() {
if (connectWatchdog) { clearTimeout(connectWatchdog); connectWatchdog = null; }
if (staleWatchdog) { clearInterval(staleWatchdog); staleWatchdog = null; }
}
function scheduleStaleWatchdog() {
if (staleWatchdog) clearInterval(staleWatchdog);
staleWatchdog = setInterval(() => {
if (connectionState === 'open' && (Date.now() - lastActivity > STALE_MS)) {
console.log(`[Tenant ${TENANT_ID}] Stale watchdog: no activity for ${STALE_MS/1000}s while open, forcing reconnect`);
try { sock?.ws?.close(); } catch (e) {}
sock = null;
connectionState = 'disconnected';
setTimeout(connectWhatsApp, 30000);
}
}, 30000);
}
function scheduleWatchdog() {
clearWatchdog();
connectWatchdog = setTimeout(() => {
if (connectionState !== 'open') {
console.log(`[Tenant ${TENANT_ID}] Watchdog: connection not stable after ${WATCHDOG_MS/1000}s, forcing reconnect`);
try { sock?.ws?.close(); } catch (e) {}
sock = null;
connectionState = 'disconnected';
setTimeout(connectWhatsApp, 30000);
}
}, WATCHDOG_MS);
}
async function connectWhatsApp() {
// Rate limit: max 1 attempt per 30 seconds to avoid WhatsApp throttling
const now = Date.now();
if (now - lastConnectAttempt < 30000) {
const wait = 30000 - (now - lastConnectAttempt);
console.log(`[Tenant ${TENANT_ID}] Rate limiting: waiting ${wait/1000}s before next attempt`);
setTimeout(connectWhatsApp, wait);
return;
}
lastConnectAttempt = now;
clearWatchdog();
const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR);
const { version } = await fetchLatestBaileysVersion();
console.log(`[Tenant ${TENANT_ID}] Connecting with Baileys v` + version.join('.'));
connectionState = 'connecting';
scheduleWatchdog();
sock = makeWASocket({ version, auth: state, logger, printQRInTerminal: true, browser: ['Nexus POS', 'Chrome', '120.0'] });
sock = makeWASocket({
version,
auth: state,
logger,
// Use a generic Ubuntu + Chrome fingerprint — less suspicious than raw Linux
browser: ['Ubuntu', 'Chrome', '120.0.0.0'],
defaultQueryTimeoutMs: 60000,
keepAliveIntervalMs: 15000,
markOnlineOnConnect: false,
syncFullHistory: false,
shouldSyncHistoryMessage: () => false,
shouldIgnoreJid: (jid) => false,
retryRequestDelayMs: 250,
maxMsgRetryCount: 2,
connectTimeoutMs: 60000,
emitOwnEvents: false,
});
sock.ev.on('creds.update', saveCreds);
sock.ev.on('connection.update', async (update) => {
@@ -36,43 +140,248 @@ async function connectWhatsApp() {
console.log(`[Tenant ${TENANT_ID}] QR code generated!`);
}
if (connection === 'close') {
clearWatchdog();
connectionState = 'disconnected';
qrCode = null;
const reason = lastDisconnect?.error?.output?.statusCode;
if (reason !== DisconnectReason.loggedOut) { setTimeout(connectWhatsApp, 5000); }
const statusCode = lastDisconnect?.error?.output?.statusCode;
// Baileys wraps some errors differently; try both paths
const reason = statusCode || lastDisconnect?.error?.statusCode;
console.log(`[Tenant ${TENANT_ID}] Disconnected, reason:`, reason);
if (reason === DisconnectReason.loggedOut) {
console.log(`[Tenant ${TENANT_ID}] Logged out — clearing auth`);
clearAuthState();
sock = null;
retry440Count = 0;
return;
}
if (reason === 440) {
// 440 = conflict/replaced. The session data is permanently invalid.
// Clean auth immediately and wait 5 min so WhatsApp forgets the old session.
console.log(`[Tenant ${TENANT_ID}] 440 Session replaced — clearing auth, waiting 5 min`);
clearAuthState();
sock = null;
retry440Count++;
const delay = retry440Count >= 3 ? 600000 : 300000; // 10 min after 3 failures, else 5 min
setTimeout(connectWhatsApp, delay);
return;
}
if (reason === 515) {
// 515 = stream error, often precedes 440. Treat same as 440.
console.log(`[Tenant ${TENANT_ID}] 515 Stream error — clearing auth, waiting 5 min`);
clearAuthState();
sock = null;
setTimeout(connectWhatsApp, 300000);
return;
}
if (reason === 428) {
console.log(`[Tenant ${TENANT_ID}] 428 Server terminated — waiting 60s`);
sock = null;
setTimeout(connectWhatsApp, 60000);
return;
}
if (reason === 408) {
// 408 during init queries usually means the server is overloaded
// or our auth is partially invalid. Clear auth if this happens repeatedly.
console.log(`[Tenant ${TENANT_ID}] 408 Timeout — waiting 60s`);
sock = null;
retry440Count++;
if (retry440Count >= 5) {
console.log(`[Tenant ${TENANT_ID}] Too many timeouts — clearing auth for fresh QR`);
clearAuthState();
retry440Count = 0;
setTimeout(connectWhatsApp, 300000);
return;
}
setTimeout(connectWhatsApp, 60000);
return;
}
// Any other error — wait 30s and retry
console.log(`[Tenant ${TENANT_ID}] Reconnecting in 30s... (reason: ${reason})`);
sock = null;
setTimeout(connectWhatsApp, 30000);
}
if (connection === 'open') {
clearWatchdog();
connectionState = 'open';
qrCode = null;
retry440Count = 0;
updateActivity();
scheduleStaleWatchdog();
console.log(`[Tenant ${TENANT_ID}] Connected!`);
flushSendQueue();
}
if (connection === 'open') { connectionState = 'open'; qrCode = null; console.log(`[Tenant ${TENANT_ID}] Connected!`); }
});
sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
if (msg.key.fromMe) continue;
const phone = msg.key.remoteJid.replace('@s.whatsapp.net', '');
const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || '';
console.log(`[Tenant ${TENANT_ID}] From ${phone}: ${text}`);
const message = msg.message || {};
let media_kind = 'text';
let media_base64 = null;
let media_mimetype = null;
let media_caption = null;
let media_ptt = false;
let latitude = null;
let longitude = null;
let text = '';
if (message.conversation) {
text = message.conversation;
} else if (message.extendedTextMessage) {
text = message.extendedTextMessage.text || '';
} else if (message.imageMessage) {
media_kind = 'image';
media_mimetype = message.imageMessage.mimetype;
media_caption = message.imageMessage.caption || '';
text = media_caption;
} else if (message.videoMessage) {
media_kind = 'video';
media_mimetype = message.videoMessage.mimetype;
media_caption = message.videoMessage.caption || '';
text = media_caption;
} else if (message.audioMessage) {
media_kind = 'audio';
media_mimetype = message.audioMessage.mimetype;
media_ptt = message.audioMessage.ptt || false;
} else if (message.locationMessage) {
media_kind = 'location';
latitude = message.locationMessage.degreesLatitude;
longitude = message.locationMessage.degreesLongitude;
text = `Ubicación: ${latitude}, ${longitude}`;
}
if (['image', 'video', 'audio'].includes(media_kind)) {
try {
await fetch(WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event: 'messages.upsert', data: { key: msg.key, message: msg.message, messageTimestamp: msg.messageTimestamp } }) });
} catch (e) { console.log(`[Tenant ${TENANT_ID}] Webhook failed:`, e.message); }
const buffer = await downloadMediaMessage(msg, 'buffer', {}, { logger: pino({ level: 'silent' }) });
if (buffer) media_base64 = buffer.toString('base64');
} catch (e) {
console.log(`[Tenant ${TENANT_ID}] Media download failed:`, e.message);
}
}
updateActivity();
console.log(`[Tenant ${TENANT_ID}] From ${phone}: [${media_kind}] ${text.substring(0, 80)}`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
const resp = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'messages.upsert',
data: {
key: msg.key,
message: msg.message,
messageTimestamp: msg.messageTimestamp,
media_kind,
media_base64,
media_mimetype,
media_caption,
media_ptt,
latitude,
longitude,
push_name: msg.pushName || ''
}
}),
signal: controller.signal
});
clearTimeout(timeoutId);
if (resp.ok) {
console.log(`[Tenant ${TENANT_ID}] Webhook delivered OK (${resp.status})`);
} else {
console.log(`[Tenant ${TENANT_ID}] Webhook returned ${resp.status}`);
}
} catch (e) {
console.log(`[Tenant ${TENANT_ID}] Webhook failed:`, e.message);
}
}
});
}
app.get('/', (req, res) => res.json({ status: 'ok', service: 'Nexus WhatsApp Bridge', tenant: TENANT_ID, state: connectionState }));
app.get('/', (req, res) => res.json({ status: 'ok', service: 'Nexus WhatsApp Bridge', tenant: TENANT_ID, state: connectionState, queued: sendQueue.length }));
app.get('/status', (req, res) => res.json({ state: connectionState, hasQr: !!qrCode }));
app.get('/qr', (req, res) => {
if (connectionState === 'open') return res.json({ state: 'open', message: 'Already connected' });
if (!qrCode) return res.json({ state: connectionState, qr: null, message: 'QR not ready' });
res.json({ state: 'qr', qr: qrCode });
});
app.post('/connect', async (req, res) => { if (!sock) connectWhatsApp(); res.json({ state: connectionState }); });
app.post('/send', async (req, res) => {
if (connectionState !== 'open') return res.status(400).json({ error: 'Not connected' });
const { phone, message } = req.body;
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
try { const r = await sock.sendMessage(jid, { text: message }); res.json({ success: true, id: r.key.id }); }
catch (e) { res.status(500).json({ error: e.message }); }
app.post('/connect', async (req, res) => {
if (sock) {
try { await sock.logout(); } catch (e) {}
sock = null;
}
retry440Count = 0;
clearAuthState();
qrCode = null;
connectionState = 'connecting';
connectWhatsApp();
res.json({ state: connectionState });
});
app.post('/logout', async (req, res) => { if (sock) { await sock.logout(); sock = null; } qrCode = null; connectionState = 'disconnected'; res.json({ state: 'disconnected' }); });
app.post('/send', async (req, res) => {
const { phone, message } = req.body;
if (!phone || !message) {
return res.status(400).json({ error: 'phone and message required' });
}
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
app.listen(PORT, () => { console.log(`[Tenant ${TENANT_ID}] WhatsApp Bridge on port ${PORT}`); connectWhatsApp(); });
if (connectionState !== 'open' || !sock) {
sendQueue.push({ jid, message });
console.log(`[Tenant ${TENANT_ID}] Message queued for ${jid} (state: ${connectionState}, queue size: ${sendQueue.length})`);
return res.status(202).json({ queued: true, state: connectionState });
}
try {
const r = await sock.sendMessage(jid, { text: message });
res.json({ success: true, id: r.key.id });
} catch (e) {
sendQueue.push({ jid, message });
console.log(`[Tenant ${TENANT_ID}] Send failed, queued for retry:`, e.message);
res.status(202).json({ queued: true, error: e.message });
}
});
app.post('/send-image', async (req, res) => {
const { phone, caption, base64 } = req.body;
if (!phone || !base64) {
return res.status(400).json({ error: 'phone and base64 required' });
}
const jid = phone.includes('@') ? phone : phone + '@s.whatsapp.net';
if (connectionState !== 'open' || !sock) {
return res.status(400).json({ error: 'Not connected' });
}
try {
const buffer = Buffer.from(base64, 'base64');
const r = await sock.sendMessage(jid, {
image: buffer,
caption: caption || ''
});
res.json({ success: true, id: r.key.id });
} catch (e) {
console.log(`[Tenant ${TENANT_ID}] Send image failed:`, e.message);
res.status(500).json({ error: e.message });
}
});
app.post('/logout', async (req, res) => {
if (sock) { await sock.logout(); sock = null; }
clearAuthState();
qrCode = null;
connectionState = 'disconnected';
retry440Count = 0;
res.json({ state: 'disconnected' });
});
app.listen(PORT, () => {
console.log(`[Tenant ${TENANT_ID}] WhatsApp Bridge on port ${PORT}`);
connectWhatsApp();
});

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""
Assign categories to Strada inventory based on pcode from TODO.st01.
Creates categories if they don't exist and updates inventory.category_id.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
from tenant_db import get_tenant_conn
# pcode → category name mapping (inferred from common auto parts codes)
PCODE_MAP = {
'SU': 'Suspensión',
'SO': 'Soportería',
'MA': 'Mangueras',
'EM': 'Empaques',
'HQ': 'Herramientas y Equipo',
'AM': 'Amortiguadores',
'IG': 'Ignición y Eléctrico',
'BD': 'Bandas y Correas',
'BL': 'Baleros',
'FI': 'Filtros',
'HE': 'Herramientas',
'PI': 'Pistones y Anillos',
'AW': 'Accesorios',
'BE': 'Baterías y Eléctrico',
'K0': 'Kit de Servicio',
'DF': 'Frenos (Discos)',
'RE': 'Refrigeración',
'PE': 'Perfiles y Sellos',
'BG': 'Bujías',
'TA': 'Toma de Agua',
'CV': 'Conversiones y Kits',
'CT': 'Clutch y Transmisión',
'CC': 'Cilindros y Componentes',
'OT': 'Otros',
'SF': 'Soportes y Bases',
'BT': 'Bujes y Terminales',
'HF': 'Herramientas y Ferretería',
'CR': 'Cremalleras y Dirección',
'CS': 'Cilindros de Freno',
'CF': 'Cables y Frenos',
'FG': 'Faros y Iluminación',
'TO': 'Tornillería',
'AO': 'Aceites y Lubricantes',
'BJ': 'Bujes',
'FR': 'Frenos',
'DR': 'Dirección y Rótulas',
'KT': 'Kits de Reparación',
'TT': 'Transmisión',
'BU': 'Bujes y Soportes',
'PC': 'Poleas y Componentes',
'CL': 'Clutch',
'PG': 'Pegamentos y Adhesivos',
'QU': 'Químicos',
'TA': 'Toma de Agua',
'VC': 'Válvulas y Controles',
'MV': 'Motoventiladores',
'AS': 'Aspas y Ventiladores',
'PO': 'Poleas',
'CM': 'Compresores y A/C',
'HE': 'Herramientas',
'EA': 'Enfriadores y Radiadores',
'CO': 'Conectores',
'CX': 'Conectores y Cables',
'TR': 'Tren Delantero',
'AB': 'Abrazaderas',
'FU': 'Fusibles',
'MR': 'Mangueras de Freno',
'TO': 'Tornillería',
'RA': 'Radiadores',
'MC': 'Mangueras y Conectores',
}
def assign_categories(tenant_id):
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
# Get or create categories
cur.execute("SELECT id, name FROM part_categories")
existing = {name: id for id, name in cur.fetchall()}
created = 0
category_map = {} # pcode -> category_id
for pcode, name in PCODE_MAP.items():
if name in existing:
category_map[pcode] = existing[name]
else:
cur.execute("INSERT INTO part_categories (name) VALUES (%s) RETURNING id", (name,))
cat_id = cur.fetchone()[0]
existing[name] = cat_id
category_map[pcode] = cat_id
created += 1
print(f"Categories: {len(existing)} total, {created} created")
# Update inventory items based on location (pcode) field
updated = 0
for pcode, cat_id in category_map.items():
cur.execute("""
UPDATE inventory
SET category_id = %s
WHERE location = %s AND category_id IS NULL
""", (cat_id, pcode))
updated += cur.rowcount
conn.commit()
cur.close()
conn.close()
print(f"Updated {updated:,} inventory items with categories")
# Show uncategorized items
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT location, COUNT(*) FROM inventory
WHERE category_id IS NULL AND location IS NOT NULL
GROUP BY location ORDER BY COUNT(*) DESC
""")
uncategorized = cur.fetchall()
if uncategorized:
print(f"\nUncategorized items by pcode:")
for pcode, cnt in uncategorized[:20]:
print(f" {pcode}: {cnt:,}")
cur.close()
conn.close()
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--tenant', type=int, required=True)
args = parser.parse_args()
assign_categories(args.tenant)

View File

@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
Nexus Autoparts — Import Strada TODO.st01 Inventory
Parses fixed-width report and imports into tenant DB using PostgreSQL COPY.
Usage:
python3 import_estrada_st01.py --tenant=28 --file=/tmp/todo_st01.txt --branch=1
"""
import argparse
import csv
import io
import os
import sys
import tempfile
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
import psycopg2
from tenant_db import get_tenant_conn
# Margins applied to Cost A
MARGIN_PRICE_1 = 1.35 # mostrador / retail
MARGIN_PRICE_2 = 1.25 # taller / workshop
MARGIN_PRICE_3 = 1.15 # mayoreo / wholesale
def parse_number(val, default=0):
"""Parse a string that might be '$1,234.56' or '.000' or empty."""
if not val:
return default
cleaned = str(val).replace('$', '').replace(',', '').replace(' ', '').strip()
if cleaned == '.':
return default
try:
return float(cleaned)
except ValueError:
return default
def parse_st01_line(line):
"""Parse a single line from TODO.st01 fixed-width format."""
line = line.rstrip('\n')
# Skip short lines
if len(line) < 80:
return None
# Skip header/footer lines
if 'Page:' in line or 'General Inventory' in line or 'STRADA AUTO' in line:
return None
if 'Tienda:' in line and 'Page:' in line:
return None
if line.strip().startswith('Line Part Number'):
return None
if line.strip().startswith('----'):
return None
# Fixed-width columns based on header:
# Line Part Number Description Addl description Pcode Instk Min 1 Max 1 Cost A Cost B codes[1]
# ---- ---------------- ---------------- ---------------- ----- ------- ------ ------ ---------- ---------- --------
marca = line[0:5].strip()
part_number_raw = line[5:21].strip()
description = line[22:39].strip()
addl_desc = line[40:56].strip()
pcode = line[56:61].strip()
instk = line[62:70].strip()
min1 = line[71:77].strip()
max1 = line[78:84].strip()
cost_a = line[85:95].strip()
cost_b = line[96:106].strip()
codes = line[107:].strip() if len(line) > 107 else ''
if not part_number_raw:
return None
# Build composite part number: MARCA-NUMERO
# Some marcas are empty (fallback to pcode or generic)
marca_safe = marca if marca else 'GEN'
part_number = f"{marca_safe}-{part_number_raw}"
# Build name from description + additional description
name = description
if addl_desc and addl_desc != '?':
name = f"{description} {addl_desc}".strip()
if not name:
return None
cost = parse_number(cost_a, 0)
stock = int(parse_number(instk, 0))
min_stock = int(parse_number(min1, 0))
max_stock = int(parse_number(max1, 0))
# Calculate prices with margins
price_1 = round(cost * MARGIN_PRICE_1, 2) if cost > 0 else 0
price_2 = round(cost * MARGIN_PRICE_2, 2) if cost > 0 else 0
price_3 = round(cost * MARGIN_PRICE_3, 2) if cost > 0 else 0
return {
'part_number': part_number,
'name': name[:300], # truncate to schema limit
'brand': marca_safe,
'cost': cost,
'price_1': price_1,
'price_2': price_2,
'price_3': price_3,
'stock': stock,
'min_stock': min_stock,
'max_stock': max_stock,
'location': pcode,
'unit': 'PZA',
'tax_rate': 0.16,
}
def import_st01(tenant_id, file_path, branch_id=1):
print(f"=== Importing Strada inventory ===")
print(f"Tenant: {tenant_id}")
print(f"File: {file_path}")
print(f"Branch: {branch_id}")
print()
# Parse file
print("Parsing fixed-width file...")
parsed = []
skipped = 0
start_time = time.time()
with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
row = parse_st01_line(line)
if row is None:
skipped += 1
continue
parsed.append(row)
parse_time = time.time() - start_time
print(f" Parsed: {len(parsed):,} rows")
print(f" Skipped: {skipped:,} lines (headers/empty)")
print(f" Time: {parse_time:.1f}s")
print()
if not parsed:
print("ERROR: No data rows found.")
return
# Show sample
print("Sample rows:")
for row in parsed[:5]:
print(f" {row['part_number']:20} | {row['name'][:40]:40} | stk={row['stock']:>4} | cost={row['cost']:>10.2f} | p1={row['price_1']:>10.2f}")
print()
# Connect to tenant DB
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
# Ensure branch exists
cur.execute("SELECT id FROM branches WHERE id = %s", (branch_id,))
if not cur.fetchone():
print(f"ERROR: Branch {branch_id} does not exist. Creating default branch...")
cur.execute("INSERT INTO branches (name) VALUES ('Principal') RETURNING id")
branch_id = cur.fetchone()[0]
conn.commit()
print(f" Created branch id={branch_id}")
# Step 1: Create temp table and COPY data
print("Creating temp table...")
cur.execute("""
DROP TABLE IF EXISTS tmp_st01_import;
CREATE TEMP TABLE tmp_st01_import (
part_number VARCHAR(100),
name VARCHAR(300),
brand VARCHAR(100),
cost NUMERIC(12,2),
price_1 NUMERIC(12,2),
price_2 NUMERIC(12,2),
price_3 NUMERIC(12,2),
stock INTEGER,
min_stock INTEGER,
max_stock INTEGER,
location VARCHAR(50),
unit VARCHAR(20),
tax_rate NUMERIC(5,4)
);
""")
conn.commit()
print("COPY to temp table...")
csv_buffer = io.StringIO()
writer = csv.writer(csv_buffer, lineterminator='\n')
for row in parsed:
writer.writerow([
row['part_number'],
row['name'],
row['brand'],
row['cost'],
row['price_1'],
row['price_2'],
row['price_3'],
row['stock'],
row['min_stock'],
row['max_stock'],
row['location'],
row['unit'],
row['tax_rate'],
])
csv_buffer.seek(0)
cur.copy_expert(
"""COPY tmp_st01_import (
part_number, name, brand, cost, price_1, price_2, price_3,
stock, min_stock, max_stock, location, unit, tax_rate
) FROM STDIN WITH (FORMAT CSV)""",
csv_buffer
)
conn.commit()
cur.execute("SELECT COUNT(*) FROM tmp_st01_import")
copied = cur.fetchone()[0]
print(f" Copied: {copied:,} rows")
print()
# Step 2: Insert into inventory
print("Inserting into inventory...")
start_time = time.time()
cur.execute("""
INSERT INTO inventory (
branch_id, part_number, name, brand,
cost, price_1, price_2, price_3,
min_stock, max_stock, location, unit, tax_rate, is_active
)
SELECT
%s, part_number, name, brand,
cost, price_1, price_2, price_3,
min_stock, max_stock, location, unit, tax_rate, TRUE
FROM tmp_st01_import
ON CONFLICT (branch_id, part_number) DO UPDATE SET
name = EXCLUDED.name,
brand = EXCLUDED.brand,
cost = EXCLUDED.cost,
price_1 = EXCLUDED.price_1,
price_2 = EXCLUDED.price_2,
price_3 = EXCLUDED.price_3,
min_stock = EXCLUDED.min_stock,
max_stock = EXCLUDED.max_stock,
location = EXCLUDED.location,
is_active = TRUE
""", (branch_id,))
conn.commit()
insert_time = time.time() - start_time
cur.execute("SELECT COUNT(*) FROM inventory WHERE branch_id = %s", (branch_id,))
total_inventory = cur.fetchone()[0]
print(f" Inventory rows: {total_inventory:,}")
print(f" Time: {insert_time:.1f}s")
print()
# Step 3: Create stock operations for items with stock > 0
print("Creating initial stock operations...")
start_time = time.time()
cur.execute("""
INSERT INTO inventory_operations (
inventory_id, branch_id, operation_type, quantity,
cost_at_time, reference_type, notes
)
SELECT
i.id,
i.branch_id,
'INITIAL',
t.stock,
i.cost,
'IMPORT',
'Importado desde TODO.st01'
FROM tmp_st01_import t
JOIN inventory i ON i.part_number = t.part_number AND i.branch_id = %s
WHERE t.stock > 0
""", (branch_id,))
conn.commit()
ops_time = time.time() - start_time
cur.execute("SELECT COUNT(*) FROM inventory_operations WHERE operation_type = 'INITIAL'")
total_ops = cur.fetchone()[0]
print(f" Stock operations created: {total_ops:,}")
print(f" Time: {ops_time:.1f}s")
print()
# Cleanup
cur.execute("DROP TABLE IF EXISTS tmp_st01_import")
conn.commit()
cur.close()
conn.close()
print("=== Import complete ===")
print(f"Total inventory items: {total_inventory:,}")
print(f"Items with stock > 0: {total_ops:,}")
def main():
parser = argparse.ArgumentParser(description='Import Strada TODO.st01 into Nexus tenant')
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
parser.add_argument('--file', required=True, help='Path to TODO.st01 file')
parser.add_argument('--branch', type=int, default=1, help='Branch ID (default: 1)')
args = parser.parse_args()
import_st01(args.tenant, args.file, args.branch)
if __name__ == '__main__':
main()

439
scripts/import_pdf_catalog.py Executable file
View File

@@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""
Import aftermarket parts catalog from PDF into Nexus Autoparts DB.
Usage:
# Extract and preview (generates CSV for review)
python3 scripts/import_pdf_catalog.py extract catalogo_bosch.pdf "BOSCH" --output bosch_preview.csv
# Import after reviewing CSV
python3 scripts/import_pdf_catalog.py import bosch_preview.csv "BOSCH"
The CSV should have columns:
part_number, name, price_usd, applications
Applications column (optional): comma-separated vehicle descriptions like:
"TOYOTA COROLLA 2015-2020, NISSAN SENTRA 2016-2019"
If applications is empty, the part will be created but not linked to vehicles.
"""
import os
import sys
import re
import csv
import json
import argparse
import subprocess
import psycopg2
from pathlib import Path
# Add parent to path for config imports
sys.path.insert(0, str(Path(__file__).parent.parent / "pos"))
MASTER_DB_URL = os.environ.get("MASTER_DB_URL", "postgresql://postgres@localhost/nexus_autoparts")
def get_db_conn():
return psycopg2.connect(MASTER_DB_URL)
def pdf_to_text(pdf_path):
"""Extract text from PDF using pdftotext (preserves layout)."""
result = subprocess.run(
["pdftotext", "-layout", pdf_path, "-"],
capture_output=True, text=True
)
if result.returncode != 0:
raise RuntimeError(f"pdftotext failed: {result.stderr}")
return result.stdout
def extract_lines_fuzzy(text, min_cols=2):
"""
Heuristic table extractor.
Looks for lines that have:
- A part number pattern (alphanumeric with dashes/slashes, 3+ chars)
- Some description text
Returns list of dicts with raw columns.
"""
rows = []
lines = text.splitlines()
# Part number patterns: BOSCH 0 986 AF1 041, MOOG K80001, NGK BKR6E, etc.
part_number_patterns = [
re.compile(r'\b[0-9A-Z]{3,}(?:[-\s/][0-9A-Z]+){1,}\b'), # codes with separators
re.compile(r'\b[A-Z]{1,3}\d{3,}[A-Z0-9]*\b'), # MOOG K80001, NGK BKR6E
re.compile(r'\b\d{3,}[A-Z]{1,3}\d+\b'), # 123ABC45
]
for line in lines:
line = line.strip()
if len(line) < 10:
continue
# Try to find a part number
part_number = None
for pat in part_number_patterns:
m = pat.search(line)
if m:
part_number = m.group(0).strip()
break
if not part_number:
continue
# Split line by 2+ spaces to get columns
cols = [c.strip() for c in re.split(r'\s{2,}', line) if c.strip()]
if len(cols) < min_cols:
continue
# Heuristic: part number is usually first or second column
# The rest is description, possibly with price at the end
name_parts = []
price = None
for col in cols:
if col == part_number:
continue
# Price detection
price_m = re.match(r'^\$?([0-9]{1,6}(?:\.[0-9]{1,2})?)$', col.replace(',', ''))
if price_m and not price:
price = float(price_m.group(1))
continue
name_parts.append(col)
name = ' '.join(name_parts) if name_parts else part_number
# Clean up name
name = re.sub(r'\s+', ' ', name).strip()
if len(name) < 3:
name = part_number
rows.append({
'part_number': part_number,
'name': name,
'price_usd': price,
'applications': '',
'raw': line,
})
return rows
def preview_rows(rows, limit=20):
print(f"\nExtracted {len(rows)} candidate rows. First {limit}:")
print("-" * 100)
for i, r in enumerate(rows[:limit]):
print(f"{i+1}. PN: {r['part_number'][:30]:30s} | Name: {r['name'][:50]:50s} | Price: {r['price_usd']}")
print("-" * 100)
def save_csv(rows, path):
with open(path, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=['part_number', 'name', 'price_usd', 'applications'])
writer.writeheader()
for r in rows:
writer.writerow({
'part_number': r['part_number'],
'name': r['name'],
'price_usd': r['price_usd'] or '',
'applications': r['applications'],
})
print(f"Saved preview to {path}")
def load_csv(path):
rows = []
with open(path, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
price = row.get('price_usd', '')
try:
price = float(price) if price else None
except ValueError:
price = None
rows.append({
'part_number': row.get('part_number', '').strip(),
'name': row.get('name', '').strip(),
'price_usd': price,
'applications': row.get('applications', '').strip(),
})
return rows
def resolve_manufacturer(cur, name):
"""Get or create manufacturer. Returns id_manufacture."""
cur.execute(
"SELECT id_manufacture FROM manufacturers WHERE UPPER(name_manufacture) = UPPER(%s)",
(name,)
)
row = cur.fetchone()
if row:
return row[0]
# Insert new manufacturer
cur.execute(
"INSERT INTO manufacturers (name_manufacture) VALUES (%s) RETURNING id_manufacture",
(name.upper() if len(name) <= 6 else name,)
)
return cur.fetchone()[0]
def resolve_or_create_part(cur, oem_part_number, name):
"""
parts.oem_part_number has UNIQUE index.
If it exists, return id_part. If not, insert.
"""
cur.execute(
"SELECT id_part, name_part FROM parts WHERE oem_part_number = %s",
(oem_part_number,)
)
row = cur.fetchone()
if row:
return row[0]
# Need a group_id. Use 'General' group as default.
cur.execute("SELECT id_part_group FROM part_groups WHERE name_part_group = 'General' LIMIT 1")
grow = cur.fetchone()
group_id = grow[0] if grow else None
cur.execute(
"""
INSERT INTO parts (oem_part_number, name_part, group_id)
VALUES (%s, %s, %s)
RETURNING id_part
""",
(oem_part_number, name, group_id)
)
return cur.fetchone()[0]
def parse_applications(app_text):
"""
Parse text like 'TOYOTA COROLLA 2015-2020, NISSAN SENTRA 2016-2019'
into list of (brand, model, year_from, year_to).
"""
if not app_text:
return []
results = []
# Split by commas or slashes
entries = re.split(r'[,;/]', app_text)
for entry in entries:
entry = entry.strip()
if not entry:
continue
# Pattern: BRAND MODEL YEAR-YEAR or BRAND MODEL YEAR
m = re.match(
r'^([A-Z][A-Z\s]{1,20}?)\s+([A-Z0-9][A-Z0-9\s\-_]{1,30}?)\s+(\d{4})(?:\s*-\s*(\d{4}))?$',
entry.upper().strip()
)
if m:
brand = m.group(1).strip()
model = m.group(2).strip()
year_from = int(m.group(3))
year_to = int(m.group(4)) if m.group(4) else year_from
results.append((brand, model, year_from, year_to))
else:
# Try looser pattern: just BRAND MODEL
m2 = re.match(r'^([A-Z][A-Z\s]{1,20}?)\s+([A-Z0-9][A-Z0-9\s\-_]{1,30})$', entry.upper().strip())
if m2:
results.append((m2.group(1).strip(), m2.group(2).strip(), None, None))
return results
def resolve_mye_ids(cur, brand_name, model_name, year_from, year_to):
"""Find MYE ids matching brand/model/year range."""
myes = []
# Find brand
cur.execute("SELECT id_brand FROM brands WHERE UPPER(name_brand) = UPPER(%s)", (brand_name,))
brow = cur.fetchone()
if not brow:
return myes
brand_id = brow[0]
# Find model (fuzzy)
cur.execute(
"""
SELECT id_model, name_model FROM models
WHERE brand_id = %s AND UPPER(name_model) LIKE UPPER(%s)
ORDER BY name_model
LIMIT 5
""",
(brand_id, f"%{model_name}%")
)
models = cur.fetchall()
if not models:
return myes
# Use first match
model_id = models[0][0]
# Find MYEs for year range
if year_from and year_to:
cur.execute(
"""
SELECT mye.id_mye FROM model_year_engine mye
JOIN years y ON y.id_year = mye.year_id
WHERE mye.model_id = %s AND y.year_car BETWEEN %s AND %s
""",
(model_id, year_from, year_to)
)
elif year_from:
cur.execute(
"""
SELECT mye.id_mye FROM model_year_engine mye
JOIN years y ON y.id_year = mye.year_id
WHERE mye.model_id = %s AND y.year_car = %s
""",
(model_id, year_from)
)
else:
cur.execute(
"SELECT id_mye FROM model_year_engine WHERE model_id = %s",
(model_id,)
)
myes = [r[0] for r in cur.fetchall()]
return myes
def import_rows(rows, manufacturer_name, dry_run=False):
conn = get_db_conn()
cur = conn.cursor()
try:
manufacturer_id = resolve_manufacturer(cur, manufacturer_name)
print(f"Manufacturer '{manufacturer_name}' → id={manufacturer_id}")
inserted_parts = 0
inserted_am = 0
linked_vehicles = 0
skipped = 0
for i, row in enumerate(rows):
pn = row['part_number']
name = row['name'] or pn
price = row['price_usd']
if not pn:
skipped += 1
continue
if dry_run:
print(f" [DRY] {pn} | {name[:40]} | ${price}")
continue
# 1. Ensure part exists in parts table
part_id = resolve_or_create_part(cur, pn, name)
# 2. Insert/upsert aftermarket_parts
cur.execute(
"""
SELECT id_aftermarket_parts FROM aftermarket_parts
WHERE part_number = %s AND manufacturer_id = %s
""",
(pn, manufacturer_id)
)
existing = cur.fetchone()
if existing:
# Update
cur.execute(
"""
UPDATE aftermarket_parts
SET name_aftermarket_parts = %s,
price_usd = COALESCE(%s, price_usd),
oem_part_id = %s
WHERE id_aftermarket_parts = %s
""",
(name, price, part_id, existing[0])
)
else:
cur.execute(
"""
INSERT INTO aftermarket_parts
(oem_part_id, manufacturer_id, part_number, name_aftermarket_parts, price_usd)
VALUES (%s, %s, %s, %s, %s)
""",
(part_id, manufacturer_id, pn, name, price)
)
inserted_am += 1
inserted_parts += 1
# 3. Link vehicles if applications provided
apps = row.get('applications', '')
if apps:
parsed = parse_applications(apps)
for brand, model, yf, yt in parsed:
myes = resolve_mye_ids(cur, brand, model, yf, yt)
for mye_id in myes:
cur.execute(
"""
INSERT INTO vehicle_parts (part_id, model_year_engine_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(part_id, mye_id)
)
linked_vehicles += 1
if (i + 1) % 100 == 0:
print(f" ... processed {i+1}/{len(rows)}")
conn.commit()
print(f"\nDone!")
print(f" Parts processed: {inserted_parts}")
print(f" Aftermarket parts inserted/updated: {inserted_am}")
print(f" Vehicle links created: {linked_vehicles}")
print(f" Skipped (no PN): {skipped}")
except Exception as e:
conn.rollback()
raise
finally:
cur.close()
conn.close()
def main():
parser = argparse.ArgumentParser(description='Import aftermarket catalog from PDF')
subparsers = parser.add_subparsers(dest='command')
# Extract command
ext = subparsers.add_parser('extract', help='Extract PDF to preview CSV')
ext.add_argument('pdf', help='Path to PDF file')
ext.add_argument('manufacturer', help='Manufacturer name')
ext.add_argument('--output', '-o', default='catalog_preview.csv', help='Output CSV path')
# Import command
imp = subparsers.add_parser('import', help='Import reviewed CSV to DB')
imp.add_argument('csv', help='Path to reviewed CSV')
imp.add_argument('manufacturer', help='Manufacturer name')
imp.add_argument('--dry-run', action='store_true', help='Preview without writing to DB')
args = parser.parse_args()
if args.command == 'extract':
print(f"Extracting {args.pdf}...")
text = pdf_to_text(args.pdf)
rows = extract_lines_fuzzy(text)
preview_rows(rows)
save_csv(rows, args.output)
print(f"\nNext step: Review {args.output}, add 'applications' column if needed,")
print(f"then run: python3 scripts/import_pdf_catalog.py import {args.output} '{args.manufacturer}'")
elif args.command == 'import':
rows = load_csv(args.csv)
print(f"Loaded {len(rows)} rows from {args.csv}")
import_rows(rows, args.manufacturer, dry_run=args.dry_run)
else:
parser.print_help()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
Mass QWEN vehicle compatibility processor for Strada.
Processes inventory items in batches of 5 with parallel workers.
Usage:
python3 qwen_batch_compat.py --tenant=28 --batch-size=5 --workers=10 --checkpoint=qwen_progress.json
"""
import argparse
import json
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
import psycopg2
from tenant_db import get_tenant_conn, get_master_conn
from services.qwen_fitment import get_vehicle_fitment
DEFAULT_CHECKPOINT_FILE = '/tmp/qwen_progress.json'
PROGRESS_LOCK = Lock()
def load_checkpoint(checkpoint_file):
if os.path.exists(checkpoint_file):
with open(checkpoint_file, 'r') as f:
return set(json.load(f))
return set()
def save_checkpoint(checkpoint_file, processed_ids):
with PROGRESS_LOCK:
with open(checkpoint_file, 'w') as f:
json.dump(list(processed_ids), f)
def get_pending_items(tenant_id, processed_ids, limit=None):
"""Get inventory items that haven't been processed yet."""
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
# Get items without vehicle compatibility records
if processed_ids:
placeholders = ','.join(['%s'] * len(processed_ids))
query = f"""
SELECT id, part_number, name, brand
FROM inventory
WHERE id NOT IN ({placeholders})
AND is_active = true
ORDER BY id
"""
cur.execute(query, tuple(processed_ids))
else:
cur.execute("""
SELECT id, part_number, name, brand
FROM inventory
WHERE is_active = true
ORDER BY id
""")
rows = cur.fetchall()
cur.close()
conn.close()
if limit:
rows = rows[:limit]
return rows
def process_single_item(item_id, part_number, name, brand):
"""Process one item with QWEN."""
try:
result = get_vehicle_fitment(part_number, name, brand)
return {
'item_id': item_id,
'part_number': part_number,
'success': True,
'vehicles': result.get('vehicles', []),
'confidence': result.get('confidence', 0),
'notes': result.get('notes', ''),
}
except Exception as exc:
return {
'item_id': item_id,
'part_number': part_number,
'success': False,
'error': str(exc),
}
def save_results(tenant_id, results):
"""Save QWEN results to inventory_vehicle_compat."""
if not results:
return 0
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
saved = 0
for result in results:
if not result.get('success'):
continue
item_id = result['item_id']
vehicles = result.get('vehicles', [])[:200] # Limit to 200 vehicles per item
confidence = result.get('confidence', 0)
for v in vehicles:
mye_id = v.get('mye_id')
if mye_id:
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, make, model, year, created_at)
VALUES (%s, %s, 'qwen_ai', %s, %s, %s, %s, NOW())
ON CONFLICT (inventory_id, model_year_engine_id, make, model, year) DO NOTHING
""", (item_id, mye_id, confidence, v.get('make'), v.get('model'), v.get('year')))
else:
cur.execute("""
INSERT INTO inventory_vehicle_compat
(inventory_id, model_year_engine_id, source, confidence, make, model, year, engine, engine_code, created_at)
VALUES (%s, NULL, 'qwen_ai', %s, %s, %s, %s, %s, %s, NOW())
""", (item_id, confidence, v.get('make'), v.get('model'), v.get('year'), v.get('engine'), v.get('engine_code')))
saved += 1
conn.commit()
cur.close()
conn.close()
return saved
def main():
parser = argparse.ArgumentParser(description='Batch QWEN vehicle compatibility processor')
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
parser.add_argument('--workers', type=int, default=10, help='Number of parallel workers')
parser.add_argument('--limit', type=int, default=None, help='Max items to process (None = all)')
parser.add_argument('--checkpoint-file', default=DEFAULT_CHECKPOINT_FILE, help='Progress checkpoint file')
args = parser.parse_args()
checkpoint_file = args.checkpoint_file
print(f"=== QWEN Batch Compatibility Processor ===", flush=True)
print(f"Tenant: {args.tenant}", flush=True)
print(f"Workers: {args.workers}", flush=True)
print(f"Checkpoint: {args.checkpoint_file}", flush=True)
print(flush=True)
# Load checkpoint
processed_ids = load_checkpoint(checkpoint_file)
print(f"Previously processed: {len(processed_ids):,} items", flush=True)
# Get pending items
pending = get_pending_items(args.tenant, processed_ids, args.limit)
print(f"Pending items: {len(pending):,}", flush=True)
if not pending:
print("Nothing to process!", flush=True)
return
# Process with thread pool
total = len(pending)
completed = 0
success_count = 0
fail_count = 0
total_vehicles = 0
start_time = time.time()
with ThreadPoolExecutor(max_workers=args.workers) as executor:
future_to_item = {
executor.submit(process_single_item, item[0], item[1], item[2], item[3]): item
for item in pending
}
batch_results = []
batch_size = 50 # Save to DB every N items
for future in as_completed(future_to_item):
item = future_to_item[future]
try:
result = future.result(timeout=180)
batch_results.append(result)
if result['success']:
success_count += 1
total_vehicles += len(result.get('vehicles', []))
else:
fail_count += 1
processed_ids.add(result['item_id'])
except Exception as exc:
fail_count += 1
processed_ids.add(item[0])
print(f"Worker exception for {item[1]}: {exc}")
completed += 1
# Save checkpoint and DB batch periodically
if len(batch_results) >= batch_size:
save_checkpoint(checkpoint_file, list(processed_ids))
saved = save_results(args.tenant, batch_results)
batch_results = []
elapsed = time.time() - start_time
rate = completed / elapsed if elapsed > 0 else 0
eta = (total - completed) / rate if rate > 0 else 0
print(f" Progress: {completed:,}/{total:,} ({100*completed/total:.1f}%) | "
f"Success: {success_count} | Fail: {fail_count} | "
f"Vehicles: {total_vehicles} | "
f"Rate: {rate:.1f} items/min | ETA: {eta/60:.0f} min", flush=True)
# Final save
if batch_results:
save_results(args.tenant, batch_results)
save_checkpoint(checkpoint_file, list(processed_ids))
elapsed = time.time() - start_time
print(f"\n=== Complete ===", flush=True)
print(f"Processed: {completed:,}", flush=True)
print(f"Success: {success_count}", flush=True)
print(f"Failed: {fail_count}", flush=True)
print(f"Total vehicles found: {total_vehicles}", flush=True)
print(f"Elapsed: {elapsed/3600:.1f} hours", flush=True)
print(f"Avg rate: {completed/(elapsed/60):.1f} items/min", flush=True)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,321 @@
#!/usr/bin/env python3
"""
Nexus Autoparts — Sync ALL Strada inventory to marketplace (backfill 133k items).
Extends warehouse_inventory with seller listings for parts that don't match
the OEM catalog. Items that DO match keep their part_id; unmatched items
get part_id=NULL with seller_part_number / seller_part_name populated.
Usage:
export MASTER_DB_URL="postgresql://user:pass@localhost/nexus_autoparts"
export TENANT_DB_URL_TEMPLATE="postgresql://user:pass@localhost/{db_name}"
python3 sync_estrada_marketplace_full.py --tenant=28 --bodega=7 --branch=1
Safe to re-run: uses UPSERT semantics.
"""
import argparse
import csv
import io
import os
import sys
import tempfile
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
import psycopg2
from tenant_db import get_tenant_conn
BATCH_SIZE = 5000
def get_master_conn():
from config import MASTER_DB_URL
return psycopg2.connect(MASTER_DB_URL)
def load_catalog_maps(master_conn):
"""Pre-load OEM part_number → part_id and cross-reference maps."""
cur = master_conn.cursor()
print("[1/5] Loading OEM catalog...")
cur.execute("SELECT id_part, oem_part_number FROM parts")
oem_map = {}
for row in cur:
pn = row[1].strip() if row[1] else ''
if pn:
oem_map[pn] = row[0]
print("[2/5] Loading cross-references...")
cur.execute("SELECT cross_reference_number, part_id FROM part_cross_references")
cross_map = {}
for row in cur:
pn = row[0].strip() if row[0] else ''
if pn:
cross_map[pn] = row[1]
cur.close()
print(f" OEM parts: {len(oem_map):,} | Cross-references: {len(cross_map):,}")
return oem_map, cross_map
def read_tenant_inventory(tenant_conn, branch_id):
"""Read all active inventory items from tenant DB."""
cur = tenant_conn.cursor()
print("[3/5] Reading tenant inventory...")
# Join with categories to get category name
cur.execute("""
SELECT
i.id,
i.part_number,
i.name,
i.price_1,
COALESCE(iss.stock, 0) AS stock,
c.name AS category_name
FROM inventory i
LEFT JOIN inventory_stock_summary iss ON iss.inventory_id = i.id
LEFT JOIN part_categories c ON c.id = i.category_id
WHERE i.is_active = true
AND (i.branch_id = %s OR %s IS NULL)
ORDER BY i.id
""", (branch_id, branch_id))
items = []
for row in cur:
items.append({
'id': row[0],
'part_number': (row[1] or '').strip(),
'name': (row[2] or '').strip(),
'price': float(row[3] or 0),
'stock': int(row[4] or 0),
'category': (row[5] or '').strip(),
})
cur.close()
print(f" Tenant items: {len(items):,}")
return items
def classify_items(items, oem_map, cross_map):
"""Split items into OEM-matched and seller listings."""
matched = []
seller = []
for it in items:
pn = it['part_number']
if not pn:
continue
# Try exact match on OEM or cross-reference
part_id = oem_map.get(pn)
if not part_id:
# Try without brand prefix (e.g. "4S-86050" → "86050")
raw = pn.split('-', 1)[1] if '-' in pn else pn
part_id = oem_map.get(raw) or oem_map.get(pn) or cross_map.get(raw) or cross_map.get(pn)
if part_id:
matched.append({**it, 'part_id': part_id})
else:
seller.append(it)
print(f" Matched (OEM): {len(matched):,} | Seller listings: {len(seller):,}")
return matched, seller
def sync_to_warehouse(master_conn, bodega_id, matched, seller):
"""Bulk upsert into warehouse_inventory using COPY."""
cur = master_conn.cursor()
# Create temp table matching warehouse_inventory structure
cur.execute("""
CREATE TEMP TABLE tmp_wi (
user_id INT,
part_id INT,
seller_part_number VARCHAR(100),
seller_part_name VARCHAR(300),
seller_category VARCHAR(100),
tenant_inventory_id INT,
price NUMERIC(12,2),
stock_quantity INT,
min_order_quantity INT DEFAULT 1,
warehouse_location VARCHAR(100) DEFAULT 'Principal',
bodega_id INT,
currency VARCHAR(3) DEFAULT 'MXN'
) ON COMMIT DROP
""")
print("[4/5] Preparing batches...")
buffer = io.StringIO()
writer = csv.writer(buffer, lineterminator='\n',
quoting=csv.QUOTE_MINIMAL)
total = 0
for it in matched + seller:
part_id = it.get('part_id')
seller_pn = None if part_id else it['part_number']
seller_name = None if part_id else (it['name'] or it['part_number'])
seller_cat = None if part_id else it['category']
writer.writerow([
1, # user_id (legacy FK, must match existing rows for bodega 7)
part_id, # part_id (NULL for seller listings)
seller_pn, # seller_part_number
seller_name, # seller_part_name
seller_cat, # seller_category
it['id'], # tenant_inventory_id
it['price'], # price
max(0, it['stock']), # stock_quantity
1, # min_order_quantity
'Principal', # warehouse_location
bodega_id, # bodega_id
'MXN', # currency
])
total += 1
if total % BATCH_SIZE == 0:
buffer.seek(0)
cur.copy_expert("""
COPY tmp_wi (user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency)
FROM STDIN WITH (FORMAT CSV, NULL '')
""", buffer)
buffer = io.StringIO()
writer = csv.writer(buffer, lineterminator='\n',
quoting=csv.QUOTE_MINIMAL)
print(f" Buffered {total:,} rows...")
# Final batch
if buffer.tell() > 0:
buffer.seek(0)
cur.copy_expert("""
COPY tmp_wi (user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency)
FROM STDIN WITH (FORMAT CSV, NULL '')
""", buffer)
print(f"[5/5] Upserting {total:,} rows into warehouse_inventory...")
# --- Update existing OEM-matched rows ---
cur.execute("""
UPDATE warehouse_inventory wi
SET
price = tmp.price,
stock_quantity = tmp.stock_quantity,
user_id = tmp.user_id,
currency = tmp.currency,
updated_at = NOW()
FROM tmp_wi tmp
WHERE wi.bodega_id = tmp.bodega_id
AND wi.part_id = tmp.part_id
AND wi.warehouse_location = tmp.warehouse_location
AND tmp.part_id IS NOT NULL
""")
matched_updated = cur.rowcount
# --- Insert new OEM-matched rows ---
cur.execute("""
INSERT INTO warehouse_inventory (
user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency, updated_at
)
SELECT
user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency, NOW()
FROM tmp_wi tmp
WHERE part_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM warehouse_inventory wi
WHERE wi.bodega_id = tmp.bodega_id
AND wi.part_id = tmp.part_id
AND wi.warehouse_location = tmp.warehouse_location
)
""")
matched_inserted = cur.rowcount
# --- Update existing seller listings ---
cur.execute("""
UPDATE warehouse_inventory wi
SET
price = tmp.price,
stock_quantity = tmp.stock_quantity,
seller_part_name = tmp.seller_part_name,
seller_category = tmp.seller_category,
user_id = tmp.user_id,
currency = tmp.currency,
updated_at = NOW()
FROM tmp_wi tmp
WHERE wi.bodega_id = tmp.bodega_id
AND wi.seller_part_number = tmp.seller_part_number
AND wi.warehouse_location = tmp.warehouse_location
AND tmp.part_id IS NULL
""")
seller_updated = cur.rowcount
# --- Insert new seller listings ---
cur.execute("""
INSERT INTO warehouse_inventory (
user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency, updated_at
)
SELECT
user_id, part_id, seller_part_number, seller_part_name,
seller_category, tenant_inventory_id, price, stock_quantity,
min_order_quantity, warehouse_location, bodega_id, currency, NOW()
FROM tmp_wi tmp
WHERE part_id IS NULL
AND NOT EXISTS (
SELECT 1 FROM warehouse_inventory wi
WHERE wi.bodega_id = tmp.bodega_id
AND wi.seller_part_number = tmp.seller_part_number
AND wi.warehouse_location = tmp.warehouse_location
)
""")
seller_inserted = cur.rowcount
matched_upserted = matched_updated + matched_inserted
seller_upserted = seller_updated + seller_inserted
master_conn.commit()
cur.close()
print(f"\n✓ Done!")
print(f" OEM matched upserted: {matched_upserted:,}")
print(f" Seller listings upserted: {seller_upserted:,}")
print(f" Total: {matched_upserted + seller_upserted:,}")
def main():
parser = argparse.ArgumentParser(description='Sync tenant inventory to marketplace')
parser.add_argument('--tenant', type=int, required=True, help='Tenant ID')
parser.add_argument('--bodega', type=int, required=True, help='Bodega ID in master DB')
parser.add_argument('--branch', type=int, default=1, help='Branch ID filter (default all)')
args = parser.parse_args()
start = time.time()
print(f"=== Sync tenant {args.tenant} → bodega {args.bodega} ===\n")
master_conn = get_master_conn()
tenant_conn = get_tenant_conn(args.tenant)
try:
oem_map, cross_map = load_catalog_maps(master_conn)
items = read_tenant_inventory(tenant_conn, args.branch)
matched, seller = classify_items(items, oem_map, cross_map)
sync_to_warehouse(master_conn, args.bodega, matched, seller)
finally:
tenant_conn.close()
master_conn.close()
elapsed = time.time() - start
print(f"\nElapsed: {elapsed:.1f}s")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Sync Strada inventory to marketplace warehouse_inventory.
Matches by OEM part_number and cross-references.
"""
import os, sys, io
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'pos'))
from tenant_db import get_tenant_conn, get_master_conn
BODEGA_ID = 7
USER_ID = 1 # admin user in master DB
CURRENCY = 'MXN'
def main():
tenant_conn = get_tenant_conn(28)
tenant_cur = tenant_conn.cursor()
# Get active inventory with stock
tenant_cur.execute("""
SELECT i.id, i.part_number, i.price_1, i.cost, COALESCE(iss.stock, 0) as stock
FROM inventory i
LEFT JOIN inventory_stock_summary iss ON iss.inventory_id = i.id
WHERE i.is_active = true
""")
items = {r[0]: {'pn': r[1], 'price': r[2], 'cost': r[3], 'stock': r[4]} for r in tenant_cur.fetchall()}
print(f"Active inventory: {len(items)}")
tenant_cur.close()
tenant_conn.close()
# Get catalog mappings from master
master = get_master_conn()
master_cur = master.cursor()
master_cur.execute("SELECT id_part, oem_part_number FROM parts WHERE oem_part_number IS NOT NULL")
oem_map = {r[1]: r[0] for r in master_cur.fetchall()}
master_cur.execute("SELECT part_id, cross_reference_number FROM part_cross_references")
cross_map = {r[1]: r[0] for r in master_cur.fetchall()}
print(f"OEM parts: {len(oem_map)}, Cross-references: {len(cross_map)}")
# Build match list
matched = []
seen_parts = set()
for item_id, data in items.items():
pn = data['pn']
raw = pn.split('-', 1)[1] if '-' in pn else pn
part_id = oem_map.get(raw) or oem_map.get(pn) or cross_map.get(raw) or cross_map.get(pn)
if part_id and part_id not in seen_parts:
seen_parts.add(part_id)
price = data['price'] or data['cost'] or 0
stock = data['stock'] or 0
matched.append((USER_ID, part_id, price, stock, 1, 'Principal', BODEGA_ID, CURRENCY))
print(f"Matched items: {len(matched)}")
if not matched:
print("Nothing to sync")
return
# Bulk insert via COPY
csv_buffer = io.StringIO()
for row in matched:
csv_buffer.write(','.join(str(c) for c in row) + '\n')
csv_buffer.seek(0)
master_cur.copy_expert(
"""COPY warehouse_inventory (user_id, part_id, price, stock_quantity, min_order_quantity, warehouse_location, bodega_id, currency)
FROM STDIN WITH (FORMAT CSV)""",
csv_buffer
)
master.commit()
master_cur.close()
master.close()
print(f"Synced {len(matched)} items to warehouse_inventory")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,262 @@
# Guion de Video — Introducción a Nexus POS
## Módulos: Inventario + Punto de Venta
**Duración estimada:** 45 minutos
**Formato:** Screen recording con voz en off
**Resolución:** 1920×1080 (16:9)
**Público:** Dueños de refaccionarias, mostradores, administradores
---
## ESCENA 0 — INTRO / HOOK (0:000:25)
**Visual:**
- Fade in desde negro.
- Logo de Nexus Autoparts centrado.
- Transición rápida: montaje de 34 tomas del sistema en acción (búsqueda de pieza, ticket imprimiéndose, gráfica de ventas).
**Narración (voz en off, tono energético):**
> "¿Todavía controlas tu inventario con hojas de Excel o cuadernos?
> Pierdes piezas, no sabes qué te queda en bodega y en la caja nunca cuadra.
> Te presento Nexus POS: el sistema diseñado específicamente para refaccionarias.
> En los próximos minutos vas a ver cómo administrar tu inventario y vender desde cualquier dispositivo."
**Nota técnica:**
- Música de fondo: upbeat corporativo, volumen bajo durante la narración.
- Lower third: "Nexus POS — Inventario & Punto de Venta".
---
## ESCENA 1 — INVENTARIO: PANEL GENERAL (0:250:55)
**Visual:**
- Login rápido (no mostrar credenciales reales, usar cuenta demo).
- Entrar al módulo **Inventario**.
- Mostrar el dashboard de inventario: badges superiores (Total de piezas, Stock bajo, Sin stock, Valor total).
**Narración:**
> "Empecemos con el corazón de tu negocio: el inventario.
> Desde el panel principal tienes el pulso completo de tu refaccionaria.
> Cuántas piezas tienes, cuáles están por acabarse y el valor total de tu stock… todo al instante."
**Nota técnica:**
- Usar transición suave (slide lateral) al entrar al módulo.
- Zoom sutil sobre los badges superiores para enfatizar los números.
---
## ESCENA 2 — INVENTARIO: ALTA RÁPIDA DE PRODUCTO (0:551:35)
**Visual:**
- Clic en "Nuevo Artículo".
- Llenar formulario: Número de parte, nombre, marca, stock inicial, precios.
- Destacar el campo **Código de barras** (se genera automáticamente).
- Guardar. Aparece toast de éxito: "Artículo creado".
**Narración:**
> "Dar de alta una pieza es cosa de segundos.
> Capturas el número de parte, la descripción, la marca… y el sistema genera automáticamente un código de barras.
> Tú decides el stock inicial y hasta tres niveles de precio.
> Guardas… y listo, la pieza ya está en el sistema."
**Nota técnica:**
- Escribir rápido pero legible (o usar autofill demo).
- Resaltar con cursor el código de barras generado.
- Toast de éxito debe ser visible al menos 1.5 segundos.
---
## ESCENA 3 — INVENTARIO: COMPATIBILIDAD CON VEHÍCULOS (1:352:05)
**Visual:**
- Abrir la ficha del artículo recién creado.
- Clic en pestaña **"Compatibilidad"**.
- Clic en "Auto-Match con IA (QWEN)".
- Mostrar spinner de carga breve (34 segundos), luego la lista de vehículos aparece (marca, modelo, año, motor).
- Señalar la etiqueta "IA" en color naranja/azul.
**Narración:**
> "Aquí viene lo potente.
> No sabes exactamente a qué carros le queda esta pieza… pero la inteligencia artificial sí.
> Con un solo clic, el sistema busca en segundos todos los vehículos compatibles: marca, modelo, año y motor.
> Todo se guarda automáticamente para que tu mostrador lo consulte al instante."
**Nota técnica:**
- Usar una pieza con resultados claros (ejemplo: filtro de aceite PH8A o bujía BKR5EYA).
- Si el auto-match tarda mucho en producción, usar un clip grabado previamente o acelerar 2×.
- Enfatizar la etiqueta "IA" con un círculo o flecha.
---
## ESCENA 4 — INVENTARIO: ENTRADAS, TRASPASOS Y AJUSTES (2:052:40)
**Visual:**
- Volver al listado de inventario.
- Clic en "Entrada" sobre una fila de stock (simular compra a proveedor: cantidad 10, costo $150).
- Cambiar a pestaña **"Traspasos"**, mostrar lista de movimientos entre sucursales.
- Cambiar a pestaña **"Ajustes"**, mostrar ajuste de inventario.
**Narración:**
> "El inventario se mueve constantemente.
> Recibiste mercancía del proveedor — registras la entrada en un clic.
> Necesitas enviar 5 piezas a la otra sucursal — traspaso directo.
> Y si encuentras una diferencia física contra el sistema, haces un ajuste con comentario.
> Todo queda auditado: quién, cuándo y por qué."
**Nota técnica:**
- Usar transición rápida entre pestañas (corte duro o fade).
- Mostrar al menos 23 filas en cada tabla para que se vea real.
---
## ESCENA 5 — POS: APERTURA DE CAJA (2:403:05)
**Visual:**
- Transición al módulo **Punto de Venta**.
- Mostrar barra superior: "Sin caja abierta".
- Clic en "Abrir Caja", llenar número de caja y monto inicial ($500).
- Guardar. La barra cambia a "Caja #1 abierta".
**Narración:**
> "Pasemos a la venta.
> Antes de vender, abres tu caja: le pones número y efectivo inicial.
> El sistema no te deja facturar sin caja abierta, así que siempre hay control.
> Una vez abierta, la barra te recuerda en todo momento en qué caja estás."
**Nota técnica:**
- Transición limpia: slide desde abajo o fade.
- Close-up en la barra de estado antes y después de abrir la caja.
---
## ESCENA 6 — POS: VENTA RÁPIDA (3:053:45)
**Visual:**
- Buscar producto por número de parte (escanear o teclear).
- El producto aparece en el carrito con imagen, descripción y precio.
- Agregar una segunda pieza al carrito.
- Clic en **"Cobrar"**.
- Modal de pago: mostrar total, cliente, descuento.
- Dividir pago: $200 efectivo, resto transferencia.
- Clic "Finalizar Venta". Ticket/recibo aparece en pantalla.
**Narración:**
> "Vender es tan simple como buscar la pieza… puede ser por número de parte o escaneando el código de barras.
> El carrito muestra todo claro: qué vendes, cuánto cuesta y cuánto llevas.
> Al cobrar, aceptas efectivo, transferencia, tarjeta… o una combinación de todo.
> Finalizas… y el ticket se genera al instante para el cliente."
**Nota técnica:**
- Usar búsqueda rápida (autocomplete visible).
- Animar el producto "volando" al carrito (si el sistema lo tiene) o simple highlight.
- Mostrar claramente el ticket final con logo y datos de la empresa.
---
## ESCENA 7 — POS: CORTE Z Y CIERRE DE CAJA (3:454:10)
**Visual:**
- Clic en "Corte Z" en la barra de estado.
- Modal aparece con resumen: ventas por método de pago, entradas, salidas, efectivo esperado.
- Ingresar efectivo real en caja ($1,247.50).
- El sistema calcula diferencia automáticamente.
- Clic "Cerrar Caja". Confirmación.
**Narración:**
> "Al final del turno, haces tu corte.
> El sistema te dice exactamente cuánto deberías tener en caja: ventas, entradas, salidas… todo desglosado.
> Tú cuentas el efectivo real, lo capturas y si hay diferencia, la ves inmediatamente.
> Cierras caja y listo: turno terminado sin sorpresas."
**Nota técnica:**
- Hacer scroll dentro del modal para mostrar todos los totales.
- Enfatizar el número de "diferencia" (positivo o negativo) con color.
---
## ESCENA 8 — CIERRE / CALL TO ACTION (4:104:45)
**Visual:**
- Volver al dashboard principal.
- Montaje rápido de pantallas vistas: inventario, auto-match, carrito, corte Z.
- Fade a pantalla final: datos de contacto / sitio web / QR.
**Narración (tono inspirador):**
> "Desde la primera pieza que entra hasta el último ticket del día, Nexus POS te acompaña.
- Inventario inteligente con IA, punto de venta rápido y cortes de caja que sí cuadran.
- Todo en un solo sistema, accesible desde tu computadora, tablet o celular.
- Agenda tu demo hoy y lleva tu refaccionaria al siguiente nivel."
**Texto en pantalla (overlay):**
- "¿Listo para modernizar tu refaccionaria?"
- Botón/CTA: "Agenda tu demo gratis"
- Web: nexusautoparts.com.mx
- WhatsApp: [número]
**Nota técnica:**
- Música sube de volumen en la pantalla final.
- Logo + CTA deben permanecer 57 segundos.
---
## CHECKLIST TÉCNICO PARA GRABACIÓN
| Ítem | Especificación |
|------|----------------|
| **Resolución** | 1920×1080 mínimo |
| **FPS** | 30 fps (60 fps opcional para animaciones suaves) |
| **Audio** | Voz en off clara, sin eco. Música royalty-free bajo |
| **Cursor** | Usar cursor grande y resaltado (Halo o similar) |
| **Transiciones** | Slide lateral 0.3s o fade 0.2s. Nada exagerado |
| **Datos demo** | Usar piezas reales: BKR5EYA, PH8A, BP6ES. Cliente: "Juan Pérez" |
| **Cuenta** | Usar usuario demo; NUNCA mostrar contraseñas reales |
| **Limpieza** | Cerrar notificaciones del OS antes de grabar |
---
## DATOS DE PRUEBA RECOMENDADOS PARA LA GRABACIÓN
### Producto 1 (alta en vivo)
- Número de parte: `PH8A`
- Nombre: `Filtro de aceite Motorcraft`
- Marca: `Motorcraft`
- Stock inicial: `12`
- Precio 1: `$185.00`
### Producto 2 (venta rápida)
- Número de parte: `BKR5EYA`
- Nombre: `Bujía NGK`
- Precio: `$145.00`
### Cliente demo
- Nombre: `Juan Pérez`
- Teléfono: `555-123-4567`
### Caja
- Caja #1
- Apertura: `$500.00`
- Cierre: `$1,247.50`
---
## NOTAS POST-PRODUCCIÓN
1. **Subtítulos:** Agregar subtítulos en español (accesibilidad + redes sociales sin audio).
2. **Capítulos:** YouTube chapters recomendados:
- 0:00 Intro
- 0:25 Inventario
- 0:55 Alta de producto
- 1:35 Compatibilidad con IA
- 2:05 Movimientos de inventario
- 2:40 Punto de Venta
- 3:05 Venta rápida
- 3:45 Corte de caja
- 4:10 Demo y contacto
3. **Thumbnail:** Split screen — mitad inventario, mitad POS. Texto: "Control total de tu refaccionaria".
4. **Formatos de exportación:**
- MP4 H.264 (YouTube / web)
- Vertical 9:16 recorte para Reels/TikTok (60 seg version)
---
*Guion generado para Nexus Autoparts — Módulos Inventario & POS*
*Versión 1.0 — 2026-05-18*