- Gunicorn production server with auto-scaled workers, run.sh, updated systemd service - Marketplace B2B: cross-tenant inventory search, ordering, seller management with full UI - Subscription billing: plan limits enforced on products/employees/branches, billing API + upgrade flow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -105,11 +105,20 @@ print('Migraciones aplicadas correctamente')
|
|||||||
|
|
||||||
### 8. Iniciar el POS
|
### 8. Iniciar el POS
|
||||||
|
|
||||||
|
**Desarrollo:**
|
||||||
```bash
|
```bash
|
||||||
cd /home/Autopartes/pos
|
cd /home/Autopartes/pos
|
||||||
python3 app.py
|
python3 app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Produccion (Gunicorn):**
|
||||||
|
```bash
|
||||||
|
pip install gunicorn --break-system-packages
|
||||||
|
sudo mkdir -p /var/log/nexus-pos
|
||||||
|
cd /home/Autopartes/pos
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
Acceder desde el navegador:
|
Acceder desde el navegador:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -162,9 +171,10 @@ After=postgresql.service
|
|||||||
Wants=postgresql.service
|
Wants=postgresql.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=notify
|
||||||
WorkingDirectory=/home/Autopartes/pos
|
WorkingDirectory=/home/Autopartes/pos
|
||||||
ExecStart=/usr/bin/python3 app.py
|
ExecStartPre=/bin/mkdir -p /var/log/nexus-pos
|
||||||
|
ExecStart=/usr/local/bin/gunicorn -c gunicorn.conf.py "app:create_app()"
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
User=root
|
User=root
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ def create_app():
|
|||||||
from blueprints.whatsapp_bp import whatsapp_bp
|
from blueprints.whatsapp_bp import whatsapp_bp
|
||||||
app.register_blueprint(whatsapp_bp)
|
app.register_blueprint(whatsapp_bp)
|
||||||
|
|
||||||
|
from blueprints.marketplace_bp import marketplace_bp
|
||||||
|
app.register_blueprint(marketplace_bp)
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.route('/pos/health')
|
@app.route('/pos/health')
|
||||||
def health():
|
def health():
|
||||||
@@ -113,6 +116,10 @@ def create_app():
|
|||||||
def pos_whatsapp():
|
def pos_whatsapp():
|
||||||
return render_template('whatsapp.html')
|
return render_template('whatsapp.html')
|
||||||
|
|
||||||
|
@app.route('/pos/marketplace')
|
||||||
|
def pos_marketplace():
|
||||||
|
return render_template('marketplace.html')
|
||||||
|
|
||||||
@app.route('/pos/static/<path:filename>')
|
@app.route('/pos/static/<path:filename>')
|
||||||
def pos_static(filename):
|
def pos_static(filename):
|
||||||
return send_from_directory('static', filename)
|
return send_from_directory('static', filename)
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ def create_branch():
|
|||||||
if not data.get('name'):
|
if not data.get('name'):
|
||||||
return jsonify({'error': 'name required'}), 400
|
return jsonify({'error': 'name required'}), 400
|
||||||
|
|
||||||
|
# Plan limit check
|
||||||
|
from services.billing import check_limit, next_plan, PLANS, get_plan
|
||||||
|
conn_chk = get_tenant_conn(g.tenant_id)
|
||||||
|
cur_chk = conn_chk.cursor()
|
||||||
|
cur_chk.execute("SELECT count(*) FROM branches WHERE is_active = true")
|
||||||
|
current_branches = cur_chk.fetchone()[0]
|
||||||
|
cur_chk.close()
|
||||||
|
conn_chk.close()
|
||||||
|
|
||||||
|
allowed, limit, current = check_limit(g.tenant_id, 'max_branches', current_branches)
|
||||||
|
if not allowed:
|
||||||
|
plan_key = get_plan(g.tenant_id)
|
||||||
|
nxt = next_plan(plan_key)
|
||||||
|
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||||
|
return jsonify({'error': f'Plan limit reached ({limit} branches). Upgrade to {nxt_name}.'}), 403
|
||||||
|
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -76,6 +92,22 @@ def create_employee():
|
|||||||
if not data.get(f):
|
if not data.get(f):
|
||||||
return jsonify({'error': f'{f} required'}), 400
|
return jsonify({'error': f'{f} required'}), 400
|
||||||
|
|
||||||
|
# Plan limit check
|
||||||
|
from services.billing import check_limit, next_plan, PLANS, get_plan
|
||||||
|
conn_chk = get_tenant_conn(g.tenant_id)
|
||||||
|
cur_chk = conn_chk.cursor()
|
||||||
|
cur_chk.execute("SELECT count(*) FROM employees WHERE is_active = true")
|
||||||
|
current_employees = cur_chk.fetchone()[0]
|
||||||
|
cur_chk.close()
|
||||||
|
conn_chk.close()
|
||||||
|
|
||||||
|
allowed, limit, current = check_limit(g.tenant_id, 'max_employees', current_employees)
|
||||||
|
if not allowed:
|
||||||
|
plan_key = get_plan(g.tenant_id)
|
||||||
|
nxt = next_plan(plan_key)
|
||||||
|
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||||
|
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
||||||
|
|
||||||
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
|
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant']
|
||||||
if data['role'] not in valid_roles:
|
if data['role'] not in valid_roles:
|
||||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||||
@@ -126,6 +158,69 @@ def create_employee():
|
|||||||
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/currency', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_currency():
|
||||||
|
"""Get currency config for this tenant."""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('currency', 'exchange_rate_usd_mxn')")
|
||||||
|
cfg = {}
|
||||||
|
for row in cur.fetchall():
|
||||||
|
cfg[row[0]] = row[1]
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
from config import DEFAULT_CURRENCY, EXCHANGE_RATE_USD_MXN
|
||||||
|
return jsonify({
|
||||||
|
'currency': cfg.get('currency', DEFAULT_CURRENCY),
|
||||||
|
'exchange_rate': float(cfg.get('exchange_rate_usd_mxn', str(EXCHANGE_RATE_USD_MXN))),
|
||||||
|
'currencies': {
|
||||||
|
'MXN': {'symbol': '$', 'name': 'Peso Mexicano'},
|
||||||
|
'USD': {'symbol': 'US$', 'name': 'US Dollar'},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/currency', methods=['PUT'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def update_currency():
|
||||||
|
"""Update currency config for this tenant."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
currency = data.get('currency', 'MXN')
|
||||||
|
rate = data.get('exchange_rate')
|
||||||
|
|
||||||
|
if currency not in ('MXN', 'USD'):
|
||||||
|
return jsonify({'error': 'currency must be MXN or USD'}), 400
|
||||||
|
if rate is not None:
|
||||||
|
try:
|
||||||
|
rate = float(rate)
|
||||||
|
if rate <= 0:
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({'error': 'exchange_rate must be a positive number'}), 400
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES ('currency', %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (currency,))
|
||||||
|
|
||||||
|
if rate is not None:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO tenant_config (key, value) VALUES ('exchange_rate_usd_mxn', %s)
|
||||||
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||||
|
""", (str(rate),))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({'message': 'Currency config updated', 'currency': currency})
|
||||||
|
|
||||||
|
|
||||||
@config_bp.route('/business', methods=['GET'])
|
@config_bp.route('/business', methods=['GET'])
|
||||||
@require_auth()
|
@require_auth()
|
||||||
def get_business():
|
def get_business():
|
||||||
@@ -170,3 +265,52 @@ def get_theme():
|
|||||||
'--radius': '8px',
|
'--radius': '8px',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Billing / Subscription ──────────────────────────
|
||||||
|
|
||||||
|
@config_bp.route('/billing', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_billing():
|
||||||
|
"""Get current plan, usage stats, and available plans."""
|
||||||
|
from services.billing import get_plan_details, PLANS, PLAN_ORDER
|
||||||
|
|
||||||
|
plan = get_plan_details(g.tenant_id)
|
||||||
|
|
||||||
|
# Get current usage counts
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT count(*) FROM inventory WHERE is_active = true")
|
||||||
|
products = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT count(*) FROM employees WHERE is_active = true")
|
||||||
|
employees = cur.fetchone()[0]
|
||||||
|
cur.execute("SELECT count(*) FROM branches WHERE is_active = true")
|
||||||
|
branches = cur.fetchone()[0]
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'current_plan': plan,
|
||||||
|
'usage': {
|
||||||
|
'products': products,
|
||||||
|
'employees': employees,
|
||||||
|
'branches': branches,
|
||||||
|
},
|
||||||
|
'plans': {k: {**v, 'key': k} for k, v in PLANS.items()},
|
||||||
|
'plan_order': PLAN_ORDER,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@config_bp.route('/billing/upgrade', methods=['POST'])
|
||||||
|
@require_auth('config.edit')
|
||||||
|
def upgrade_billing():
|
||||||
|
"""Upgrade tenant plan."""
|
||||||
|
from services.billing import upgrade_plan
|
||||||
|
data = request.get_json() or {}
|
||||||
|
new_plan = data.get('plan')
|
||||||
|
if not new_plan:
|
||||||
|
return jsonify({'error': 'plan required'}), 400
|
||||||
|
result = upgrade_plan(g.tenant_id, new_plan)
|
||||||
|
if 'error' in result:
|
||||||
|
return jsonify(result), 400
|
||||||
|
return jsonify(result)
|
||||||
|
|||||||
@@ -204,6 +204,23 @@ def create_item():
|
|||||||
if not branch_id:
|
if not branch_id:
|
||||||
return jsonify({'error': 'branch_id required'}), 400
|
return jsonify({'error': 'branch_id required'}), 400
|
||||||
|
|
||||||
|
# Plan limit check
|
||||||
|
from services.billing import check_limit, next_plan, PLANS, get_plan
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur_count = conn.cursor()
|
||||||
|
cur_count.execute("SELECT count(*) FROM inventory WHERE is_active = true")
|
||||||
|
current_products = cur_count.fetchone()[0]
|
||||||
|
cur_count.close()
|
||||||
|
|
||||||
|
allowed, limit, current = check_limit(g.tenant_id, 'max_products', current_products)
|
||||||
|
if not allowed:
|
||||||
|
conn.close()
|
||||||
|
plan_key = get_plan(g.tenant_id)
|
||||||
|
nxt = next_plan(plan_key)
|
||||||
|
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||||
|
return jsonify({'error': f'Plan limit reached ({limit} products). Upgrade to {nxt_name}.'}), 403
|
||||||
|
|
||||||
|
conn.close()
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
|||||||
360
pos/blueprints/marketplace_bp.py
Normal file
360
pos/blueprints/marketplace_bp.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# /home/Autopartes/pos/blueprints/marketplace_bp.py
|
||||||
|
"""Marketplace B2B: bodegas publish inventory, talleres/refaccionarias browse and order."""
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from middleware import require_auth
|
||||||
|
from tenant_db import get_master_conn, get_tenant_conn
|
||||||
|
|
||||||
|
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."""
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/order', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def create_order():
|
||||||
|
"""Create a marketplace order from buyer to seller.
|
||||||
|
|
||||||
|
Body:
|
||||||
|
seller_id: int (required)
|
||||||
|
items: [{ part_number, part_name, quantity, unit_price }] (required)
|
||||||
|
notes: str (optional)
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
seller_id = data.get('seller_id')
|
||||||
|
items = data.get('items', [])
|
||||||
|
|
||||||
|
if not seller_id:
|
||||||
|
return jsonify({'error': 'seller_id required'}), 400
|
||||||
|
if not items:
|
||||||
|
return jsonify({'error': 'items required (non-empty array)'}), 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
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/orders', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def list_orders():
|
||||||
|
"""List marketplace orders (as buyer or seller).
|
||||||
|
|
||||||
|
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)
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@marketplace_bp.route('/orders/<int:order_id>/status', methods=['PUT'])
|
||||||
|
@require_auth()
|
||||||
|
def update_order_status(order_id):
|
||||||
|
"""Update order status. Seller can confirm/ship/deliver/cancel. Buyer can cancel if pending.
|
||||||
|
|
||||||
|
Body:
|
||||||
|
status: 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
tenant_id = g.tenant_id
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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})
|
||||||
10
pos/gunicorn.conf.py
Normal file
10
pos/gunicorn.conf.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import multiprocessing
|
||||||
|
|
||||||
|
bind = "0.0.0.0:5001"
|
||||||
|
workers = multiprocessing.cpu_count() * 2 + 1
|
||||||
|
worker_class = "sync"
|
||||||
|
timeout = 120
|
||||||
|
keepalive = 5
|
||||||
|
accesslog = "/var/log/nexus-pos/access.log"
|
||||||
|
errorlog = "/var/log/nexus-pos/error.log"
|
||||||
|
loglevel = "info"
|
||||||
32
pos/migrations/v1.6_marketplace.sql
Normal file
32
pos/migrations/v1.6_marketplace.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- Marketplace orders (cross-tenant)
|
||||||
|
-- These tables go in nexus_autoparts (master DB), not tenant DBs
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS marketplace_orders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
buyer_tenant_id INTEGER NOT NULL,
|
||||||
|
seller_tenant_id INTEGER NOT NULL,
|
||||||
|
buyer_name VARCHAR(200),
|
||||||
|
seller_name VARCHAR(200),
|
||||||
|
total NUMERIC(12,2),
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending, confirmed, shipped, delivered, cancelled
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS marketplace_order_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
order_id INTEGER REFERENCES marketplace_orders(id),
|
||||||
|
part_number VARCHAR(100),
|
||||||
|
part_name VARCHAR(300),
|
||||||
|
quantity INTEGER,
|
||||||
|
unit_price NUMERIC(12,2),
|
||||||
|
subtotal NUMERIC(12,2)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mp_orders_buyer ON marketplace_orders(buyer_tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mp_orders_seller ON marketplace_orders(seller_tenant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mp_orders_status ON marketplace_orders(status);
|
||||||
|
|
||||||
|
-- Add is_seller flag to tenants table
|
||||||
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS is_seller BOOLEAN DEFAULT FALSE;
|
||||||
@@ -3,3 +3,4 @@ psycopg2-binary>=2.9
|
|||||||
PyJWT>=2.8
|
PyJWT>=2.8
|
||||||
bcrypt>=4.0
|
bcrypt>=4.0
|
||||||
lxml>=4.9
|
lxml>=4.9
|
||||||
|
gunicorn>=22.0
|
||||||
|
|||||||
3
pos/run.sh
Executable file
3
pos/run.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd /home/Autopartes/pos
|
||||||
|
gunicorn -c gunicorn.conf.py "app:create_app()"
|
||||||
74
pos/services/billing.py
Normal file
74
pos/services/billing.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Subscription billing for Nexus POS SaaS."""
|
||||||
|
|
||||||
|
from tenant_db import get_master_conn
|
||||||
|
|
||||||
|
PLANS = {
|
||||||
|
'free': {'name': 'Gratis', 'price_mxn': 0, 'max_products': 100, 'max_employees': 2, 'max_branches': 1},
|
||||||
|
'basic': {'name': 'Basico', 'price_mxn': 499, 'max_products': 5000, 'max_employees': 5, 'max_branches': 2},
|
||||||
|
'pro': {'name': 'Pro', 'price_mxn': 1499, 'max_products': 50000, 'max_employees': 15, 'max_branches': 5},
|
||||||
|
'enterprise': {'name': 'Enterprise', 'price_mxn': 3999, 'max_products': None, 'max_employees': None, 'max_branches': None},
|
||||||
|
}
|
||||||
|
|
||||||
|
PLAN_ORDER = ['free', 'basic', 'pro', 'enterprise']
|
||||||
|
|
||||||
|
|
||||||
|
def get_plan(tenant_id):
|
||||||
|
"""Get current plan for a tenant."""
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT plan FROM tenants WHERE id = %s", (tenant_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return 'free'
|
||||||
|
plan_key = row[0] or 'free'
|
||||||
|
if plan_key not in PLANS:
|
||||||
|
plan_key = 'free'
|
||||||
|
return plan_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_plan_details(tenant_id):
|
||||||
|
"""Get plan key + full details for a tenant."""
|
||||||
|
plan_key = get_plan(tenant_id)
|
||||||
|
return {**PLANS[plan_key], 'key': plan_key}
|
||||||
|
|
||||||
|
|
||||||
|
def next_plan(current_plan):
|
||||||
|
"""Return the next upgrade plan key, or None if already enterprise."""
|
||||||
|
idx = PLAN_ORDER.index(current_plan) if current_plan in PLAN_ORDER else 0
|
||||||
|
if idx < len(PLAN_ORDER) - 1:
|
||||||
|
return PLAN_ORDER[idx + 1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_limit(tenant_id, resource, current_count):
|
||||||
|
"""Check if tenant is within plan limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
resource: One of 'max_products', 'max_employees', 'max_branches'
|
||||||
|
current_count: Current count of that resource
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(allowed: bool, limit: int|None, current: int)
|
||||||
|
"""
|
||||||
|
plan_key = get_plan(tenant_id)
|
||||||
|
plan = PLANS[plan_key]
|
||||||
|
limit = plan.get(resource)
|
||||||
|
if limit is None:
|
||||||
|
return (True, None, current_count)
|
||||||
|
return (current_count < limit, limit, current_count)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_plan(tenant_id, new_plan):
|
||||||
|
"""Change tenant's plan."""
|
||||||
|
if new_plan not in PLANS:
|
||||||
|
return {'error': f'Invalid plan: {new_plan}'}
|
||||||
|
conn = get_master_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("UPDATE tenants SET plan = %s WHERE id = %s", (new_plan, tenant_id))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
return {'success': True, 'plan': new_plan, 'details': PLANS[new_plan]}
|
||||||
@@ -1378,6 +1378,18 @@
|
|||||||
Reportes
|
Reportes
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<div class="sidebar__section-label">Gestión</div>
|
||||||
|
|
||||||
|
<a href="/pos/marketplace" class="nav-link">
|
||||||
|
<span class="nav-link__icon">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M1 3h14v2l-1.5 1.5V14H2.5V6.5L1 5V3z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
|
||||||
|
<path d="M6 10h4v4H6z" fill="currentColor" opacity="0.4"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
Marketplace B2B
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="sidebar__section-label">Sistema</div>
|
<div class="sidebar__section-label">Sistema</div>
|
||||||
|
|
||||||
<a href="/pos/config" class="nav-link">
|
<a href="/pos/config" class="nav-link">
|
||||||
@@ -1673,6 +1685,7 @@
|
|||||||
</div><!-- end app-shell -->
|
</div><!-- end app-shell -->
|
||||||
|
|
||||||
|
|
||||||
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/dashboard.js"></script>
|
<script src="/pos/static/js/dashboard.js"></script>
|
||||||
|
|||||||
459
pos/templates/marketplace.html
Normal file
459
pos/templates/marketplace.html
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es" data-theme="industrial">
|
||||||
|
<head>
|
||||||
|
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Marketplace B2B — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#F5A623" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-base);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
[data-theme="modern"] body {
|
||||||
|
background-image: radial-gradient(circle, var(--dot-grid-color) 1px, transparent 1px);
|
||||||
|
background-size: var(--dot-grid-size) var(--dot-grid-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mp-container { max-width: 1200px; margin: 0 auto; padding: var(--space-6); }
|
||||||
|
.mp-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-6); flex-wrap: wrap; gap: var(--space-4); }
|
||||||
|
.mp-header h1 { font-family: var(--font-heading); font-size: var(--text-heading-lg); font-weight: var(--font-weight-bold); }
|
||||||
|
|
||||||
|
.mp-tabs { display: flex; gap: var(--space-2); margin-bottom: var(--space-5); }
|
||||||
|
.mp-tab {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-1);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
transition: all var(--duration-fast) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.mp-tab:hover { background: var(--color-surface-2); }
|
||||||
|
.mp-tab.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
|
||||||
|
|
||||||
|
.search-bar { display: flex; gap: var(--space-3); margin-bottom: var(--space-5); }
|
||||||
|
.search-bar input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background: var(--color-surface-1);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.search-bar input:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-primary-alpha-20, rgba(26,115,232,0.2)); }
|
||||||
|
.search-bar select {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
background: var(--color-surface-1);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--color-primary); color: #fff; }
|
||||||
|
.btn-primary:hover { filter: brightness(1.1); }
|
||||||
|
.btn-sm { padding: var(--space-1) var(--space-3); font-size: var(--text-caption); }
|
||||||
|
.btn-success { background: var(--color-success, #34a853); color: #fff; }
|
||||||
|
.btn-danger { background: var(--color-error, #ea4335); color: #fff; }
|
||||||
|
|
||||||
|
.results-table { width: 100%; border-collapse: collapse; margin-bottom: var(--space-5); }
|
||||||
|
.results-table th, .results-table td {
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.results-table th {
|
||||||
|
background: var(--color-surface-2);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.results-table tr:hover td { background: var(--color-surface-1); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.badge-pending { background: #fff3cd; color: #856404; }
|
||||||
|
.badge-confirmed { background: #cce5ff; color: #004085; }
|
||||||
|
.badge-shipped { background: #d4edda; color: #155724; }
|
||||||
|
.badge-delivered { background: #d1ecf1; color: #0c5460; }
|
||||||
|
.badge-cancelled { background: #f8d7da; color: #721c24; }
|
||||||
|
|
||||||
|
.cart-panel {
|
||||||
|
position: fixed; right: 0; top: 0; bottom: 0; width: 380px;
|
||||||
|
background: var(--color-surface-1);
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
padding: var(--space-5);
|
||||||
|
display: none; flex-direction: column;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: -4px 0 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.cart-panel.open { display: flex; }
|
||||||
|
.cart-panel h3 { margin-bottom: var(--space-4); font-family: var(--font-heading); }
|
||||||
|
.cart-items { flex: 1; overflow-y: auto; }
|
||||||
|
.cart-item {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
}
|
||||||
|
.cart-item button { background: none; border: none; color: var(--color-error, #ea4335); cursor: pointer; font-size: 16px; }
|
||||||
|
.cart-total { padding: var(--space-4) 0; font-weight: var(--font-weight-bold); font-size: var(--text-body-lg); border-top: 2px solid var(--color-border); }
|
||||||
|
.cart-actions { display: flex; gap: var(--space-3); margin-top: var(--space-4); }
|
||||||
|
.cart-actions .btn { flex: 1; }
|
||||||
|
|
||||||
|
.empty-state { text-align: center; padding: var(--space-8); color: var(--color-text-muted); }
|
||||||
|
.empty-state svg { margin-bottom: var(--space-4); opacity: 0.4; }
|
||||||
|
|
||||||
|
.pagination { display: flex; justify-content: center; gap: var(--space-2); margin-top: var(--space-4); }
|
||||||
|
.pagination button { padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface-1); cursor: pointer; }
|
||||||
|
.pagination button.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
|
||||||
|
.pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cart-panel { width: 100%; }
|
||||||
|
.search-bar { flex-direction: column; }
|
||||||
|
.search-bar select { min-width: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="mp-container">
|
||||||
|
<div class="mp-header">
|
||||||
|
<h1>Marketplace B2B</h1>
|
||||||
|
<div style="display:flex;gap:var(--space-3);align-items:center">
|
||||||
|
<button class="btn btn-primary" onclick="toggleCart()">
|
||||||
|
Carrito (<span id="cart-count">0</span>)
|
||||||
|
</button>
|
||||||
|
<a href="/pos/dashboard" class="btn" style="background:var(--color-surface-2);border:1px solid var(--color-border)">Volver</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mp-tabs">
|
||||||
|
<button class="mp-tab active" data-tab="search" onclick="switchTab('search')">Buscar Partes</button>
|
||||||
|
<button class="mp-tab" data-tab="orders" onclick="switchTab('orders')">Mis Pedidos</button>
|
||||||
|
<button class="mp-tab" data-tab="sellers" onclick="switchTab('sellers')">Proveedores</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Tab -->
|
||||||
|
<div id="tab-search">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="search-input" placeholder="Buscar por numero de parte, nombre o marca..." />
|
||||||
|
<select id="seller-filter">
|
||||||
|
<option value="">Todos los proveedores</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-primary" onclick="doSearch()">Buscar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="search-results">
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 48 48" fill="none"><circle cx="20" cy="20" r="16" stroke="currentColor" stroke-width="3"/><path d="M32 32L44 44" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
|
<p>Busca partes en el inventario de proveedores registrados.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Orders Tab -->
|
||||||
|
<div id="tab-orders" style="display:none">
|
||||||
|
<div style="display:flex;gap:var(--space-3);margin-bottom:var(--space-4)">
|
||||||
|
<select id="order-role" onchange="loadOrders()">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
<option value="buyer">Como Comprador</option>
|
||||||
|
<option value="seller">Como Vendedor</option>
|
||||||
|
</select>
|
||||||
|
<select id="order-status" onchange="loadOrders()">
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="pending">Pendiente</option>
|
||||||
|
<option value="confirmed">Confirmado</option>
|
||||||
|
<option value="shipped">Enviado</option>
|
||||||
|
<option value="delivered">Entregado</option>
|
||||||
|
<option value="cancelled">Cancelado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="orders-list">
|
||||||
|
<div class="empty-state"><p>Cargando pedidos...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sellers Tab -->
|
||||||
|
<div id="tab-sellers" style="display:none">
|
||||||
|
<div id="sellers-list">
|
||||||
|
<div class="empty-state"><p>Cargando proveedores...</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cart Panel -->
|
||||||
|
<div class="cart-panel" id="cart-panel">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--space-4)">
|
||||||
|
<h3>Carrito de Pedido</h3>
|
||||||
|
<button onclick="toggleCart()" style="background:none;border:none;font-size:20px;cursor:pointer;color:var(--color-text-primary)">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="cart-items" id="cart-items"></div>
|
||||||
|
<div class="cart-total">Total: $<span id="cart-total">0.00</span></div>
|
||||||
|
<div class="cart-actions">
|
||||||
|
<button class="btn btn-danger" onclick="clearCart()">Vaciar</button>
|
||||||
|
<button class="btn btn-success" onclick="submitOrder()">Enviar Pedido</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const TOKEN = localStorage.getItem('pos_token');
|
||||||
|
const HEADERS = { 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
|
let cart = []; // { seller_id, seller_name, part_number, part_name, quantity, unit_price }
|
||||||
|
|
||||||
|
// ─── Tabs ──────────────────────────────────────
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.querySelectorAll('.mp-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
|
document.getElementById('tab-search').style.display = tab === 'search' ? '' : 'none';
|
||||||
|
document.getElementById('tab-orders').style.display = tab === 'orders' ? '' : 'none';
|
||||||
|
document.getElementById('tab-sellers').style.display = tab === 'sellers' ? '' : 'none';
|
||||||
|
if (tab === 'orders') loadOrders();
|
||||||
|
if (tab === 'sellers') loadSellers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Search ─────────────────────────────────────
|
||||||
|
function doSearch() {
|
||||||
|
const q = document.getElementById('search-input').value.trim();
|
||||||
|
if (q.length < 2) return;
|
||||||
|
const sellerId = document.getElementById('seller-filter').value;
|
||||||
|
let url = '/pos/api/marketplace/search?q=' + encodeURIComponent(q);
|
||||||
|
if (sellerId) url += '&seller_id=' + sellerId;
|
||||||
|
|
||||||
|
fetch(url, { headers: HEADERS }).then(r => r.json()).then(res => {
|
||||||
|
const el = document.getElementById('search-results');
|
||||||
|
if (!res.data || !res.data.length) {
|
||||||
|
el.innerHTML = '<div class="empty-state"><p>No se encontraron resultados.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '<table class="results-table"><thead><tr><th>Proveedor</th><th>No. Parte</th><th>Nombre</th><th>Marca</th><th>Precio</th><th>Stock</th><th></th></tr></thead><tbody>';
|
||||||
|
res.data.forEach(item => {
|
||||||
|
html += `<tr>
|
||||||
|
<td>${esc(item.seller_name)}</td>
|
||||||
|
<td><strong>${esc(item.part_number)}</strong></td>
|
||||||
|
<td>${esc(item.name)}</td>
|
||||||
|
<td>${esc(item.brand || '')}</td>
|
||||||
|
<td>$${fmt(item.price)}</td>
|
||||||
|
<td>${item.stock}</td>
|
||||||
|
<td><button class="btn btn-sm btn-primary" onclick="addToCart(${item.seller_id}, '${esc(item.seller_name)}', '${esc(item.part_number)}', '${esc(item.name)}', ${item.price})">+ Carrito</button></td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
if (res.pagination && res.pagination.pages > 1) {
|
||||||
|
html += '<div class="pagination">';
|
||||||
|
for (let i = 1; i <= Math.min(res.pagination.pages, 10); i++) {
|
||||||
|
html += `<button class="${i === res.pagination.page ? 'active' : ''}" onclick="searchPage(${i})">${i}</button>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
el.innerHTML = html;
|
||||||
|
}).catch(() => {
|
||||||
|
document.getElementById('search-results').innerHTML = '<div class="empty-state"><p>Error al buscar.</p></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('search-input').addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); });
|
||||||
|
|
||||||
|
// ─── Cart ────────────────────────────────────────
|
||||||
|
function addToCart(sellerId, sellerName, partNumber, partName, price) {
|
||||||
|
const existing = cart.find(c => c.seller_id === sellerId && c.part_number === partNumber);
|
||||||
|
if (existing) { existing.quantity++; }
|
||||||
|
else { cart.push({ seller_id: sellerId, seller_name: sellerName, part_number: partNumber, part_name: partName, quantity: 1, unit_price: price }); }
|
||||||
|
renderCart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFromCart(idx) { cart.splice(idx, 1); renderCart(); }
|
||||||
|
function clearCart() { cart = []; renderCart(); }
|
||||||
|
|
||||||
|
function toggleCart() {
|
||||||
|
document.getElementById('cart-panel').classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCart() {
|
||||||
|
const el = document.getElementById('cart-items');
|
||||||
|
document.getElementById('cart-count').textContent = cart.length;
|
||||||
|
if (!cart.length) {
|
||||||
|
el.innerHTML = '<div class="empty-state"><p>Carrito vacio</p></div>';
|
||||||
|
document.getElementById('cart-total').textContent = '0.00';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let total = 0;
|
||||||
|
let html = '';
|
||||||
|
cart.forEach((item, idx) => {
|
||||||
|
const sub = item.quantity * item.unit_price;
|
||||||
|
total += sub;
|
||||||
|
html += `<div class="cart-item">
|
||||||
|
<div>
|
||||||
|
<div><strong>${esc(item.part_number)}</strong></div>
|
||||||
|
<div style="font-size:11px;color:var(--color-text-muted)">${esc(item.seller_name)}</div>
|
||||||
|
<div>${item.quantity} x $${fmt(item.unit_price)} = $${fmt(sub)}</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="removeFromCart(${idx})">×</button>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
el.innerHTML = html;
|
||||||
|
document.getElementById('cart-total').textContent = fmt(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitOrder() {
|
||||||
|
if (!cart.length) return alert('El carrito esta vacio');
|
||||||
|
|
||||||
|
// Group cart items by seller
|
||||||
|
const bySeller = {};
|
||||||
|
cart.forEach(item => {
|
||||||
|
if (!bySeller[item.seller_id]) bySeller[item.seller_id] = { seller_id: item.seller_id, seller_name: item.seller_name, items: [] };
|
||||||
|
bySeller[item.seller_id].items.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const promises = Object.values(bySeller).map(group => {
|
||||||
|
return fetch('/pos/api/marketplace/order', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: HEADERS,
|
||||||
|
body: JSON.stringify({
|
||||||
|
seller_id: group.seller_id,
|
||||||
|
items: group.items.map(i => ({
|
||||||
|
part_number: i.part_number,
|
||||||
|
part_name: i.part_name,
|
||||||
|
quantity: i.quantity,
|
||||||
|
unit_price: i.unit_price,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
}).then(r => r.json());
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(promises).then(results => {
|
||||||
|
const errors = results.filter(r => r.error);
|
||||||
|
if (errors.length) {
|
||||||
|
alert('Algunos pedidos fallaron: ' + errors.map(e => e.error).join(', '));
|
||||||
|
} else {
|
||||||
|
alert('Pedido(s) creado(s) exitosamente: ' + results.map(r => '#' + r.id).join(', '));
|
||||||
|
cart = [];
|
||||||
|
renderCart();
|
||||||
|
toggleCart();
|
||||||
|
}
|
||||||
|
}).catch(() => alert('Error al enviar pedido'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Orders ──────────────────────────────────────
|
||||||
|
function loadOrders() {
|
||||||
|
const role = document.getElementById('order-role').value;
|
||||||
|
const status = document.getElementById('order-status').value;
|
||||||
|
let url = '/pos/api/marketplace/orders?';
|
||||||
|
if (role) url += 'role=' + role + '&';
|
||||||
|
if (status) url += 'status=' + status + '&';
|
||||||
|
|
||||||
|
fetch(url, { headers: HEADERS }).then(r => r.json()).then(res => {
|
||||||
|
const el = document.getElementById('orders-list');
|
||||||
|
if (!res.data || !res.data.length) {
|
||||||
|
el.innerHTML = '<div class="empty-state"><p>No hay pedidos.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '<table class="results-table"><thead><tr><th>#</th><th>Comprador</th><th>Vendedor</th><th>Total</th><th>Estado</th><th>Fecha</th><th></th></tr></thead><tbody>';
|
||||||
|
res.data.forEach(o => {
|
||||||
|
html += `<tr>
|
||||||
|
<td>${o.id}</td>
|
||||||
|
<td>${esc(o.buyer_name || '')}</td>
|
||||||
|
<td>${esc(o.seller_name || '')}</td>
|
||||||
|
<td>$${fmt(o.total)}</td>
|
||||||
|
<td><span class="badge badge-${o.status}">${o.status}</span></td>
|
||||||
|
<td>${new Date(o.created_at).toLocaleDateString('es-MX')}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm" style="background:var(--color-surface-2);border:1px solid var(--color-border)" onclick="viewOrderItems(${o.id})">Ver</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}).catch(() => {
|
||||||
|
document.getElementById('orders-list').innerHTML = '<div class="empty-state"><p>Error al cargar pedidos.</p></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewOrderItems(orderId) {
|
||||||
|
fetch('/pos/api/marketplace/orders/' + orderId + '/items', { headers: HEADERS })
|
||||||
|
.then(r => r.json()).then(res => {
|
||||||
|
if (!res.data || !res.data.length) return alert('Sin articulos');
|
||||||
|
let msg = 'Articulos del pedido #' + orderId + ':\n\n';
|
||||||
|
res.data.forEach(i => {
|
||||||
|
msg += `${i.part_number} - ${i.part_name}: ${i.quantity} x $${fmt(i.unit_price)} = $${fmt(i.subtotal)}\n`;
|
||||||
|
});
|
||||||
|
alert(msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sellers ─────────────────────────────────────
|
||||||
|
function loadSellers() {
|
||||||
|
fetch('/pos/api/marketplace/sellers', { headers: HEADERS }).then(r => r.json()).then(res => {
|
||||||
|
const el = document.getElementById('sellers-list');
|
||||||
|
const sel = document.getElementById('seller-filter');
|
||||||
|
if (!res.data || !res.data.length) {
|
||||||
|
el.innerHTML = '<div class="empty-state"><p>No hay proveedores registrados.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Update filter dropdown
|
||||||
|
sel.innerHTML = '<option value="">Todos los proveedores</option>';
|
||||||
|
res.data.forEach(s => {
|
||||||
|
sel.innerHTML += `<option value="${s.id}">${esc(s.name)}</option>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = '<table class="results-table"><thead><tr><th>Nombre</th><th>RFC</th><th>Subdominio</th></tr></thead><tbody>';
|
||||||
|
res.data.forEach(s => {
|
||||||
|
html += `<tr><td><strong>${esc(s.name)}</strong></td><td>${esc(s.rfc || '-')}</td><td>${esc(s.subdomain || '-')}</td></tr>`;
|
||||||
|
});
|
||||||
|
html += '</tbody></table>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}).catch(() => {
|
||||||
|
document.getElementById('sellers-list').innerHTML = '<div class="empty-state"><p>Error al cargar proveedores.</p></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────
|
||||||
|
function fmt(n) { return (parseFloat(n) || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
|
||||||
|
function esc(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
// Init: load sellers for filter
|
||||||
|
fetch('/pos/api/marketplace/sellers', { headers: HEADERS }).then(r => r.json()).then(res => {
|
||||||
|
const sel = document.getElementById('seller-filter');
|
||||||
|
if (res.data) res.data.forEach(s => {
|
||||||
|
sel.innerHTML += `<option value="${s.id}">${esc(s.name)}</option>`;
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
renderCart();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user