""" Nexus Marketplace B2B — REST endpoints (Phase 1). Routes: Bodegas GET /pos/api/marketplace/bodegas list verified bodegas GET /pos/api/marketplace/bodegas/ 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/ 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/ full detail POST /pos/api/marketplace/orders//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/', 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/', 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) @marketplace_bp.route('/inventory/listing/', 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 # ═══════════════════════════════════════════════════════════════════════════ @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/', 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//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)