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:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
404
pos/blueprints/marketplace_external_bp.py
Normal file
404
pos/blueprints/marketplace_external_bp.py
Normal 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})
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user