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>
337 lines
12 KiB
Python
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)
|