Files
Autoparts-DB/pos/blueprints/marketplace_bp.py
consultoria-as e95f7cf684 feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts
Major features:
- Pixel-Perfect glassmorphism design (landing + POS + public catalog)
- OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types)
- Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications)
- Peer-to-peer inventory (multi-instance, LAN discovery)
- WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations
- Smart unified search (VIN/plate/part_number/keyword auto-detect)
- Shop Supplies tab (vehicle-independent parts)
- Chatbot AI fallback chain (5 models) + response cache
- CSV inventory import tool + setup_instance.sh installer
- Tablet-responsive CSS + sidebar toggle
- Filters, export CSV, employee edit, business data save
- Quotation system (WA→POS) with auto-print on confirmation
- Live stats on landing page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:35:53 +00:00

337 lines
12 KiB
Python

"""
Nexus Marketplace B2B — REST endpoints (Phase 1).
Routes:
Bodegas
GET /pos/api/marketplace/bodegas list verified bodegas
GET /pos/api/marketplace/bodegas/<id> bodega detail
Inventory
POST /pos/api/marketplace/inventory/upload bulk CSV upload (seller)
GET /pos/api/marketplace/inventory/search browse (text/brand/city filters)
GET /pos/api/marketplace/inventory/part/<id> bodegas stocking this part
Purchase Orders
POST /pos/api/marketplace/orders create draft
GET /pos/api/marketplace/orders/mine buyer's PO list
GET /pos/api/marketplace/orders/inbox seller's incoming PO list
GET /pos/api/marketplace/orders/<id> full detail
POST /pos/api/marketplace/orders/<id>/transition state change
NOTE: this replaces an earlier stub that referenced now-unused tables
(marketplace_orders, marketplace_order_items, tenants.is_seller flag).
The Phase 1 schema uses bodegas + purchase_orders + purchase_order_items.
"""
from functools import wraps
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 marketplace_service as mkt
marketplace_bp = Blueprint('marketplace', __name__, url_prefix='/pos/api/marketplace')
# ─── Role loader + checker ────────────────────────────────────────────────
def _load_marketplace_profile():
"""Fetch the caller's marketplace_role + bodega_id from the tenant DB
and attach to flask.g. Call AFTER @require_auth. Idempotent."""
if hasattr(g, 'marketplace_loaded'):
return
g.marketplace_role = 'buyer'
g.marketplace_bodega_id = None
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute(
"SELECT marketplace_role, bodega_id FROM employees WHERE id = %s",
(g.employee_id,),
)
row = cur.fetchone()
if row:
g.marketplace_role = row[0] or 'buyer'
g.marketplace_bodega_id = row[1]
cur.close()
conn.close()
except Exception as e:
print(f'[marketplace] failed to load role: {e}')
g.marketplace_loaded = True
def require_marketplace_role(*allowed_roles):
"""Decorator: only allow users whose marketplace_role is in the allowed list.
Must be applied AFTER @require_auth()."""
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
_load_marketplace_profile()
if g.marketplace_role not in allowed_roles:
return jsonify({
'error': f'Marketplace role {g.marketplace_role} cannot access this endpoint',
'required': list(allowed_roles),
}), 403
return f(*args, **kwargs)
return wrapped
return decorator
def _with_master(f):
"""Open a master connection, run f(master_conn), always close."""
conn = get_master_conn()
try:
return f(conn)
finally:
conn.close()
# ═══════════════════════════════════════════════════════════════════════════
# BODEGAS
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_bp.route('/whoami', methods=['GET'])
@require_auth()
def whoami():
"""Return the current user's marketplace profile (role, bodega_id, etc.)."""
_load_marketplace_profile()
return jsonify({
'employee_id': g.employee_id,
'employee_name': g.employee_name,
'tenant_id': g.tenant_id,
'marketplace_role': g.marketplace_role,
'bodega_id': g.marketplace_bodega_id,
})
@marketplace_bp.route('/bodegas', methods=['GET'])
@require_auth()
def list_bodegas():
verified_only = request.args.get('verified_only', 'true').lower() != 'false'
city = request.args.get('city')
def _do(master):
return jsonify({'data': mkt.list_bodegas(master, verified_only=verified_only, city=city)})
return _with_master(_do)
@marketplace_bp.route('/bodegas/<int:bodega_id>', methods=['GET'])
@require_auth()
def get_bodega(bodega_id):
def _do(master):
b = mkt.get_bodega(master, bodega_id)
if not b:
return jsonify({'error': 'Bodega not found'}), 404
return jsonify(b)
return _with_master(_do)
# ═══════════════════════════════════════════════════════════════════════════
# INVENTORY
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_bp.route('/inventory/upload', methods=['POST'])
@require_auth()
@require_marketplace_role('seller', 'admin')
def upload_inventory():
"""CSV bulk upload for a bodega's warehouse inventory.
Body options:
multipart/form-data with file field 'file'
OR
application/json with {bodega_id, csv} (admin override)
"""
# Sellers upload to THEIR bodega; admin can upload to any.
if g.marketplace_role == 'seller':
bodega_id = g.marketplace_bodega_id
if not bodega_id:
return jsonify({'error': 'Seller has no bodega_id assigned'}), 400
else:
body = request.get_json(silent=True) or {}
bodega_id = int(body.get('bodega_id') or 0)
if not bodega_id:
return jsonify({'error': 'bodega_id required for admin upload'}), 400
# Read CSV from either multipart file or JSON body
csv_text = None
if 'file' in request.files:
csv_text = request.files['file'].read().decode('utf-8', errors='ignore')
else:
body = request.get_json(silent=True) or {}
csv_text = body.get('csv')
if not csv_text:
return jsonify({'error': 'CSV payload required (file upload or JSON csv field)'}), 400
def _do(master):
result = mkt.upload_inventory_csv(master, bodega_id, csv_text)
return jsonify(result)
return _with_master(_do)
@marketplace_bp.route('/inventory/search', methods=['GET'])
@require_auth()
def search_inventory():
q = request.args.get('q')
brand = request.args.get('brand')
city = request.args.get('city')
limit = min(request.args.get('limit', 50, type=int), 200)
def _do(master):
data = mkt.search_inventory(master, query=q, brand=brand, city=city, limit=limit)
return jsonify({'data': data, 'count': len(data)})
return _with_master(_do)
@marketplace_bp.route('/inventory/part/<int:part_id>', methods=['GET'])
@require_auth()
def bodegas_with_part(part_id):
def _do(master):
data = mkt.get_bodegas_with_part(master, part_id)
return jsonify({'data': data, 'count': len(data)})
return _with_master(_do)
# ═══════════════════════════════════════════════════════════════════════════
# PURCHASE ORDERS
# ═══════════════════════════════════════════════════════════════════════════
@marketplace_bp.route('/orders', methods=['POST'])
@require_auth()
@require_marketplace_role('buyer', 'admin')
def create_order():
"""Create a new PO in draft status.
Body:
{
"bodega_id": 1,
"items": [{"part_id": 123, "quantity": 2, "unit_price": 150}, ...],
"delivery_method": "pickup",
"delivery_address": "...",
"buyer_notes": "..."
}
"""
body = request.get_json() or {}
bodega_id = int(body.get('bodega_id') or 0)
items = body.get('items') or []
if not bodega_id:
return jsonify({'error': 'bodega_id required'}), 400
if not items:
return jsonify({'error': 'At least one item required'}), 400
def _do(master):
try:
po_id = mkt.create_po_draft(
master,
buyer_tenant_id=g.tenant_id,
buyer_user_id=g.employee_id,
buyer_display_name=g.employee_name,
buyer_phone=body.get('buyer_phone'),
buyer_email=body.get('buyer_email'),
bodega_id=bodega_id,
items=items,
delivery_method=body.get('delivery_method', 'pickup'),
delivery_address=body.get('delivery_address'),
buyer_notes=body.get('buyer_notes'),
)
return jsonify({'ok': True, 'po_id': po_id}), 201
except ValueError as e:
return jsonify({'error': str(e)}), 400
return _with_master(_do)
@marketplace_bp.route('/orders/mine', methods=['GET'])
@require_auth()
def my_orders():
"""Buyer view: POs this tenant (or user) created."""
only_mine = request.args.get('only_mine', 'true').lower() != 'false'
def _do(master):
data = mkt.list_pos_for_buyer(
master,
buyer_tenant_id=g.tenant_id,
buyer_user_id=g.employee_id if only_mine else None,
)
return jsonify({'data': data, 'count': len(data)})
return _with_master(_do)
@marketplace_bp.route('/orders/inbox', methods=['GET'])
@require_auth()
@require_marketplace_role('seller', 'admin')
def seller_inbox():
"""Seller view: incoming POs for this bodega."""
if g.marketplace_role == 'seller':
bodega_id = g.marketplace_bodega_id
else:
bodega_id = int(request.args.get('bodega_id') or 0)
if not bodega_id:
return jsonify({'error': 'bodega_id required'}), 400
def _do(master):
data = mkt.list_pos_for_seller(master, bodega_id)
return jsonify({'data': data, 'count': len(data)})
return _with_master(_do)
@marketplace_bp.route('/orders/<int:po_id>', methods=['GET'])
@require_auth()
def get_order(po_id):
"""PO detail — buyer sees their tenant's POs, seller sees their bodega's."""
_load_marketplace_profile()
def _do(master):
po = mkt.get_po_detail(master, po_id)
if not po:
return jsonify({'error': 'PO not found'}), 404
# Authorization
if g.marketplace_role == 'seller':
if po['bodega_id'] != g.marketplace_bodega_id:
return jsonify({'error': 'Not authorized'}), 403
elif g.marketplace_role == 'buyer':
if po['buyer_tenant_id'] != g.tenant_id:
return jsonify({'error': 'Not authorized'}), 403
# admin sees all
return jsonify(po)
return _with_master(_do)
@marketplace_bp.route('/orders/<int:po_id>/transition', methods=['POST'])
@require_auth()
def transition_order(po_id):
"""Change a PO's status. Role determines which transitions are allowed.
Body: {"new_status": "confirmed", "note": "optional note"}
"""
_load_marketplace_profile()
body = request.get_json() or {}
new_status = body.get('new_status')
note = body.get('note')
if not new_status:
return jsonify({'error': 'new_status required'}), 400
# Map marketplace_role to actor_kind for the state machine.
actor_kind = g.marketplace_role
if actor_kind == 'admin':
actor_kind = 'seller' # admin defaults to seller path in Phase 1
def _do(master):
po = mkt.get_po_detail(master, po_id)
if not po:
return jsonify({'error': 'PO not found'}), 404
if g.marketplace_role == 'seller' and po['bodega_id'] != g.marketplace_bodega_id:
return jsonify({'error': 'Not authorized'}), 403
if g.marketplace_role == 'buyer' and po['buyer_tenant_id'] != g.tenant_id:
return jsonify({'error': 'Not authorized'}), 403
result = mkt.transition_po(
master,
po_id=po_id,
new_status=new_status,
actor_user_id=g.employee_id,
actor_kind=actor_kind,
note=note,
)
if not result.get('ok'):
return jsonify(result), 400
return jsonify(result)
return _with_master(_do)