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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user