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
This commit is contained in:
2026-05-26 04:24:07 +00:00
parent 50c0dbe7d4
commit a236187f3a
66 changed files with 7335 additions and 498 deletions

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