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

@@ -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,35 +673,59 @@ def webhook():
'Puedes escribirme el mensaje?')
elif msg.get('text'):
# 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)
reply = ai_resp.get('message', '') or ''
# Enrich: if the AI returned a search_query, look up real parts
# from the catalog and append them to the WhatsApp reply.
search_q = ai_resp.get('search_query')
vehicle = ai_resp.get('vehicle')
# Persist detected vehicle so we don't lose context between messages
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
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 set_vehicle
set_vehicle(clean_phone, vehicle)
except Exception as veh_err:
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
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 search_q and reply:
try:
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn, master_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
# 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)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")
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)
reply = ai_resp.get('message', '') or ''
# Enrich: if the AI returned a search_query, look up real parts
# from the catalog and append them to the WhatsApp reply.
search_q = ai_resp.get('search_query')
vehicle = ai_resp.get('vehicle')
# Persist detected vehicle so we don't lose context between messages
if vehicle and isinstance(vehicle, dict) and vehicle.get('brand'):
try:
from services.wa_quotation import set_vehicle
set_vehicle(tenant_conn, clean_phone, vehicle)
except Exception as veh_err:
print(f"[WA-AI] Failed to save vehicle: {veh_err}")
if search_q and reply:
try:
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(tenant_conn, clean_phone, found_part)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")
# Send reply if we produced one
if reply: