Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- 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
This commit is contained in:
258
pos/blueprints/b2b_bp.py
Normal file
258
pos/blueprints/b2b_bp.py
Normal file
@@ -0,0 +1,258 @@
|
||||
# /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()
|
||||
Reference in New Issue
Block a user