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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user