- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
259 lines
9.4 KiB
Python
259 lines
9.4 KiB
Python
# /home/Autopartes/pos/blueprints/b2b_bp.py
|
|
"""Nexus B2B yonke marketplace blueprint.
|
|
|
|
Wholesale trading of used parts between yonkes (deshuesaderos): one tenant
|
|
publishes its used pieces, another buys them. Listings and orders live in the
|
|
MASTER DB; the pieces stay in each tenant DB.
|
|
|
|
All endpoints live under /pos/api/b2b.
|
|
Permissions: yonke.view for reads, yonke.manage for writes.
|
|
|
|
Seller:
|
|
GET /pos/api/b2b/my-listings my published listings
|
|
POST /pos/api/b2b/listings publish/refresh a listing
|
|
PATCH /pos/api/b2b/listings/<id> edit price/status/notes
|
|
DELETE /pos/api/b2b/listings/<id> soft-delete (status='removed')
|
|
GET /pos/api/b2b/seller/status {is_seller, listings_count, ...}
|
|
Buyer:
|
|
GET /pos/api/b2b/catalog available listings from other tenants
|
|
GET /pos/api/b2b/listings/<id> listing detail (+ seller_name)
|
|
Orders (both):
|
|
POST /pos/api/b2b/orders create an order from listings
|
|
GET /pos/api/b2b/orders my orders as buyer
|
|
GET /pos/api/b2b/orders/incoming my orders as seller
|
|
GET /pos/api/b2b/orders/<id> order detail (participants only)
|
|
PUT /pos/api/b2b/orders/<id>/status transition the order status
|
|
"""
|
|
|
|
from flask import Blueprint, g, jsonify, request
|
|
from middleware import require_auth
|
|
from services import b2b_service
|
|
from tenant_db import get_master_conn
|
|
|
|
b2b_bp = Blueprint('b2b', __name__, url_prefix='/pos/api/b2b')
|
|
|
|
|
|
def _int_arg(name, default, minimum, maximum):
|
|
try:
|
|
val = int(request.args.get(name, default))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return min(max(val, minimum), maximum)
|
|
|
|
|
|
def _error_response(e):
|
|
"""Map a service exception to (json, status); never leaks a 500."""
|
|
if isinstance(e, b2b_service.B2BForbidden):
|
|
return jsonify({'error': str(e)}), 403
|
|
if isinstance(e, b2b_service.B2BNotFound):
|
|
return jsonify({'error': str(e)}), 404
|
|
if isinstance(e, b2b_service.B2BValidationError):
|
|
return jsonify({'error': str(e)}), 400
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# SELLER
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
@b2b_bp.route('/my-listings', methods=['GET'])
|
|
@require_auth('yonke.view')
|
|
def my_listings():
|
|
"""Listings published by the current tenant."""
|
|
master = get_master_conn()
|
|
try:
|
|
data = b2b_service.list_my_listings(
|
|
master, g.tenant_id, status=request.args.get('status'))
|
|
return jsonify({'data': data})
|
|
except Exception as e:
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@b2b_bp.route('/listings', methods=['POST'])
|
|
@require_auth('yonke.manage')
|
|
def publish_listing():
|
|
"""Publish (or refresh) one of my used/rebuild pieces to the B2B catalog."""
|
|
data = request.get_json(silent=True) or {}
|
|
master = get_master_conn()
|
|
try:
|
|
listing = b2b_service.publish_listing(master, g.tenant_id, data)
|
|
master.commit()
|
|
return jsonify(listing), 201
|
|
except Exception as e:
|
|
master.rollback()
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@b2b_bp.route('/listings/<int:listing_id>', methods=['PATCH'])
|
|
@require_auth('yonke.manage')
|
|
def update_listing(listing_id):
|
|
"""Edit price_wholesale / status / notes of one of my listings."""
|
|
data = request.get_json(silent=True) or {}
|
|
master = get_master_conn()
|
|
try:
|
|
listing = b2b_service.update_listing(master, g.tenant_id, listing_id, data)
|
|
master.commit()
|
|
return jsonify(listing)
|
|
except Exception as e:
|
|
master.rollback()
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@b2b_bp.route('/listings/<int:listing_id>', methods=['DELETE'])
|
|
@require_auth('yonke.manage')
|
|
def remove_listing(listing_id):
|
|
"""Soft-delete a listing (status='removed')."""
|
|
master = get_master_conn()
|
|
try:
|
|
b2b_service.remove_listing(master, g.tenant_id, listing_id)
|
|
master.commit()
|
|
return jsonify({'ok': True, 'id': listing_id, 'status': 'removed'})
|
|
except Exception as e:
|
|
master.rollback()
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@b2b_bp.route('/seller/status', methods=['GET'])
|
|
@require_auth('yonke.view')
|
|
def seller_status():
|
|
"""{is_seller, listings_count, incoming_orders} for the current tenant."""
|
|
master = get_master_conn()
|
|
try:
|
|
return jsonify(b2b_service.get_seller_status(master, g.tenant_id))
|
|
except Exception as e:
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# BUYER — catalog
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
@b2b_bp.route('/catalog', methods=['GET'])
|
|
@require_auth('yonke.view')
|
|
def catalog():
|
|
"""Available listings from OTHER tenants, with seller_name and pagination."""
|
|
master = get_master_conn()
|
|
try:
|
|
items, pagination = b2b_service.list_catalog(
|
|
master, g.tenant_id,
|
|
filters={
|
|
'q': request.args.get('q'),
|
|
'make': request.args.get('make'),
|
|
'model': request.args.get('model'),
|
|
'year': request.args.get('year'),
|
|
'grade': request.args.get('grade'),
|
|
},
|
|
page=_int_arg('page', 1, 1, 10**9),
|
|
per_page=_int_arg('per_page', 12, 1, 100),
|
|
)
|
|
return jsonify({'data': items, 'pagination': pagination})
|
|
except Exception as e:
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@b2b_bp.route('/listings/<int:listing_id>', methods=['GET'])
|
|
@require_auth('yonke.view')
|
|
def get_listing(listing_id):
|
|
"""Listing detail with seller_name."""
|
|
master = get_master_conn()
|
|
try:
|
|
listing = b2b_service.get_listing(master, listing_id)
|
|
if not listing:
|
|
return jsonify({'error': 'Listing no encontrado'}), 404
|
|
return jsonify(listing)
|
|
except Exception as e:
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# ORDERS
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
@b2b_bp.route('/orders', methods=['POST'])
|
|
@require_auth('yonke.manage')
|
|
def create_order():
|
|
"""Create a pending order from one or more listings (single seller)."""
|
|
data = request.get_json(silent=True) or {}
|
|
master = get_master_conn()
|
|
try:
|
|
order = b2b_service.create_order(
|
|
master, g.tenant_id, data.get('items'), notes=data.get('notes'))
|
|
master.commit()
|
|
return jsonify(order), 201
|
|
except Exception as e:
|
|
master.rollback()
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@b2b_bp.route('/orders', methods=['GET'])
|
|
@require_auth('yonke.view')
|
|
def list_orders():
|
|
"""My orders as buyer (role='buyer')."""
|
|
master = get_master_conn()
|
|
try:
|
|
return jsonify({'data': b2b_service.list_orders(master, g.tenant_id, 'buyer')})
|
|
except Exception as e:
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@b2b_bp.route('/orders/incoming', methods=['GET'])
|
|
@require_auth('yonke.view')
|
|
def list_incoming_orders():
|
|
"""Orders I must fulfil as seller (role='seller')."""
|
|
master = get_master_conn()
|
|
try:
|
|
return jsonify({'data': b2b_service.list_orders(master, g.tenant_id, 'seller')})
|
|
except Exception as e:
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@b2b_bp.route('/orders/<int:order_id>', methods=['GET'])
|
|
@require_auth('yonke.view')
|
|
def get_order(order_id):
|
|
"""Order detail (buyer or seller of that order only)."""
|
|
master = get_master_conn()
|
|
try:
|
|
return jsonify(b2b_service.get_order(master, g.tenant_id, order_id))
|
|
except Exception as e:
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|
|
|
|
|
|
@b2b_bp.route('/orders/<int:order_id>/status', methods=['PUT'])
|
|
@require_auth('yonke.manage')
|
|
def update_order_status(order_id):
|
|
"""Transition an order status (role-checked). 'received' also stocks the buyer."""
|
|
data = request.get_json(silent=True) or {}
|
|
master = get_master_conn()
|
|
try:
|
|
result = b2b_service.update_order_status(
|
|
master, g.tenant_id, order_id, data.get('status'),
|
|
branch_id=g.branch_id)
|
|
return jsonify(result)
|
|
except Exception as e:
|
|
master.rollback()
|
|
return _error_response(e)
|
|
finally:
|
|
master.close()
|