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>
This commit is contained in:
2026-04-18 05:35:53 +00:00
parent 6b097614a0
commit e95f7cf684
54 changed files with 11226 additions and 1422 deletions

View File

@@ -1,360 +1,336 @@
# /home/Autopartes/pos/blueprints/marketplace_bp.py
"""Marketplace B2B: bodegas publish inventory, talleres/refaccionarias browse and order."""
"""
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_master_conn, get_tenant_conn
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')
@marketplace_bp.route('/sellers', methods=['GET'])
@require_auth()
def list_sellers():
"""List active sellers/bodegas."""
# ─── 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()
cur = conn.cursor()
cur.execute("""
SELECT id, name, subdomain, rfc
FROM tenants
WHERE is_active = true AND is_seller = true
ORDER BY name
""")
sellers = []
for r in cur.fetchall():
sellers.append({'id': r[0], 'name': r[1], 'subdomain': r[2], 'rfc': r[3]})
cur.close()
conn.close()
return jsonify({'data': sellers})
try:
return f(conn)
finally:
conn.close()
@marketplace_bp.route('/search', methods=['GET'])
# ═══════════════════════════════════════════════════════════════════════════
# 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():
"""Search across ALL seller tenant inventories.
Query params:
q: search term (required, min 2 chars)
seller_id: optional filter by specific seller
page: page number (default 1)
per_page: results per page (default 50, max 200)
"""
q = request.args.get('q', '').strip()
if len(q) < 2:
return jsonify({'error': 'Search query must be at least 2 characters'}), 400
seller_id = request.args.get('seller_id')
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
offset = (page - 1) * per_page
# Get all seller tenants
master = get_master_conn()
mcur = master.cursor()
if seller_id:
mcur.execute("""
SELECT id, name, db_name FROM tenants
WHERE is_active = true AND is_seller = true AND id = %s
""", (seller_id,))
else:
mcur.execute("""
SELECT id, name, db_name FROM tenants
WHERE is_active = true AND is_seller = true
ORDER BY name
""")
sellers = mcur.fetchall()
mcur.close()
master.close()
results = []
search_pattern = f'%{q}%'
for s_id, s_name, db_name in sellers:
try:
conn = get_tenant_conn(s_id)
cur = conn.cursor()
cur.execute("""
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate, i.unit,
COALESCE(s.stock, 0) AS stock
FROM inventory i
LEFT JOIN (
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
FROM inventory_operations GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE i.is_active = true
AND COALESCE(s.stock, 0) > 0
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s)
ORDER BY i.name
LIMIT %s
""", (search_pattern, search_pattern, search_pattern, per_page))
for r in cur.fetchall():
results.append({
'seller_id': s_id,
'seller_name': s_name,
'part_number': r[0],
'name': r[1],
'brand': r[2],
'price': float(r[3]) if r[3] else 0,
'tax_rate': float(r[4]) if r[4] else 0.16,
'unit': r[5] or 'PZA',
'stock': r[6],
})
cur.close()
conn.close()
except Exception:
# Skip tenants with connection issues
continue
# Sort all results by name, then paginate
results.sort(key=lambda x: x['name'])
total = len(results)
paged = results[offset:offset + per_page]
return jsonify({
'data': paged,
'pagination': {
'page': page,
'per_page': per_page,
'total': total,
'pages': (total + per_page - 1) // per_page if per_page else 1,
}
})
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('/order', methods=['POST'])
@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 marketplace order from buyer to seller.
"""Create a new PO in draft status.
Body:
seller_id: int (required)
items: [{ part_number, part_name, quantity, unit_price }] (required)
notes: str (optional)
{
"bodega_id": 1,
"items": [{"part_id": 123, "quantity": 2, "unit_price": 150}, ...],
"delivery_method": "pickup",
"delivery_address": "...",
"buyer_notes": "..."
}
"""
data = request.get_json() or {}
seller_id = data.get('seller_id')
items = data.get('items', [])
body = request.get_json() or {}
bodega_id = int(body.get('bodega_id') or 0)
items = body.get('items') or []
if not seller_id:
return jsonify({'error': 'seller_id required'}), 400
if not bodega_id:
return jsonify({'error': 'bodega_id required'}), 400
if not items:
return jsonify({'error': 'items required (non-empty array)'}), 400
return jsonify({'error': 'At least one item required'}), 400
buyer_id = g.tenant_id
# Get buyer and seller names
master = get_master_conn()
mcur = master.cursor()
mcur.execute("SELECT name FROM tenants WHERE id = %s", (buyer_id,))
buyer_row = mcur.fetchone()
mcur.execute("SELECT name FROM tenants WHERE id = %s AND is_seller = true AND is_active = true", (seller_id,))
seller_row = mcur.fetchone()
mcur.close()
if not buyer_row:
master.close()
return jsonify({'error': 'Buyer tenant not found'}), 404
if not seller_row:
master.close()
return jsonify({'error': 'Seller not found or not active'}), 404
buyer_name = buyer_row[0]
seller_name = seller_row[0]
# Calculate total
total = 0
for item in items:
qty = item.get('quantity', 0)
price = item.get('unit_price', 0)
item['subtotal'] = round(qty * price, 2)
total += item['subtotal']
mcur2 = master.cursor()
mcur2.execute("""
INSERT INTO marketplace_orders (buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, total, notes)
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id
""", (buyer_id, seller_id, buyer_name, seller_name, round(total, 2), data.get('notes')))
order_id = mcur2.fetchone()[0]
for item in items:
mcur2.execute("""
INSERT INTO marketplace_order_items (order_id, part_number, part_name, quantity, unit_price, subtotal)
VALUES (%s, %s, %s, %s, %s, %s)
""", (order_id, item.get('part_number'), item.get('part_name'),
item.get('quantity', 0), item.get('unit_price', 0), item.get('subtotal', 0)))
master.commit()
mcur2.close()
master.close()
return jsonify({'id': order_id, 'total': round(total, 2), 'message': 'Order created'}), 201
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', methods=['GET'])
@marketplace_bp.route('/orders/mine', methods=['GET'])
@require_auth()
def list_orders():
"""List marketplace orders (as buyer or seller).
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)
Query params:
role: 'buyer' or 'seller' (default: both)
status: filter by status
page: page number
per_page: results per page
"""
tenant_id = g.tenant_id
role = request.args.get('role', '')
status = request.args.get('status', '')
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
offset = (page - 1) * per_page
master = get_master_conn()
mcur = master.cursor()
where_clauses = []
params = []
if role == 'buyer':
where_clauses.append("buyer_tenant_id = %s")
params.append(tenant_id)
elif role == 'seller':
where_clauses.append("seller_tenant_id = %s")
params.append(tenant_id)
@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:
where_clauses.append("(buyer_tenant_id = %s OR seller_tenant_id = %s)")
params.extend([tenant_id, tenant_id])
if status:
where_clauses.append("status = %s")
params.append(status)
where = " AND ".join(where_clauses)
mcur.execute(f"SELECT count(*) FROM marketplace_orders WHERE {where}", params)
total = mcur.fetchone()[0]
mcur.execute(f"""
SELECT id, buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
total, status, notes, created_at, updated_at
FROM marketplace_orders
WHERE {where}
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, offset])
orders = []
for r in mcur.fetchall():
orders.append({
'id': r[0], 'buyer_tenant_id': r[1], 'seller_tenant_id': r[2],
'buyer_name': r[3], 'seller_name': r[4],
'total': float(r[5]) if r[5] else 0,
'status': r[6], 'notes': r[7],
'created_at': str(r[8]), 'updated_at': str(r[9]),
})
mcur.close()
master.close()
return jsonify({
'data': orders,
'pagination': {
'page': page, 'per_page': per_page,
'total': total, 'pages': (total + per_page - 1) // per_page if per_page else 1,
}
})
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:order_id>/status', methods=['PUT'])
@marketplace_bp.route('/orders/<int:po_id>', methods=['GET'])
@require_auth()
def update_order_status(order_id):
"""Update order status. Seller can confirm/ship/deliver/cancel. Buyer can cancel if pending.
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
Body:
status: 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
# 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"}
"""
data = request.get_json() or {}
new_status = data.get('status')
valid_statuses = ['confirmed', 'shipped', 'delivered', 'cancelled']
if new_status not in valid_statuses:
return jsonify({'error': f'status must be one of: {", ".join(valid_statuses)}'}), 400
_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
tenant_id = g.tenant_id
# 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
master = get_master_conn()
mcur = master.cursor()
mcur.execute("""
SELECT buyer_tenant_id, seller_tenant_id, status
FROM marketplace_orders WHERE id = %s
""", (order_id,))
row = mcur.fetchone()
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
if not row:
mcur.close()
master.close()
return jsonify({'error': 'Order not found'}), 404
buyer_id, seller_id, current_status = row
# Permission check
if tenant_id == buyer_id:
# Buyer can only cancel pending orders
if new_status != 'cancelled' or current_status != 'pending':
mcur.close()
master.close()
return jsonify({'error': 'Buyer can only cancel pending orders'}), 403
elif tenant_id == seller_id:
# Seller can do any transition
pass
else:
mcur.close()
master.close()
return jsonify({'error': 'Not authorized for this order'}), 403
mcur.execute("""
UPDATE marketplace_orders SET status = %s, updated_at = NOW()
WHERE id = %s
""", (new_status, order_id))
master.commit()
mcur.close()
master.close()
return jsonify({'id': order_id, 'status': new_status, 'message': 'Order updated'})
@marketplace_bp.route('/orders/<int:order_id>/items', methods=['GET'])
@require_auth()
def get_order_items(order_id):
"""Get items for a specific order."""
tenant_id = g.tenant_id
master = get_master_conn()
mcur = master.cursor()
# Verify tenant is buyer or seller
mcur.execute("""
SELECT buyer_tenant_id, seller_tenant_id FROM marketplace_orders WHERE id = %s
""", (order_id,))
row = mcur.fetchone()
if not row or (row[0] != tenant_id and row[1] != tenant_id):
mcur.close()
master.close()
return jsonify({'error': 'Not authorized'}), 403
mcur.execute("""
SELECT id, part_number, part_name, quantity, unit_price, subtotal
FROM marketplace_order_items WHERE order_id = %s ORDER BY id
""", (order_id,))
items = []
for r in mcur.fetchall():
items.append({
'id': r[0], 'part_number': r[1], 'part_name': r[2],
'quantity': r[3], 'unit_price': float(r[4]) if r[4] else 0,
'subtotal': float(r[5]) if r[5] else 0,
})
mcur.close()
master.close()
return jsonify({'data': items})
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)