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

@@ -64,10 +64,12 @@ def _master_only(fn):
@catalog_bp.route('/brands', methods=['GET'])
@require_auth('catalog.view')
def brands():
from services.catalog_modes import normalize_mode
year_id = request.args.get('year_id', type=int)
mode = normalize_mode(request.args.get('mode'))
def _do(master):
data = catalog_service.get_brands(master, year_id=year_id)
return jsonify({'data': data})
data = catalog_service.get_brands(master, year_id=year_id, mode=mode)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
@@ -125,41 +127,191 @@ def engines():
@catalog_bp.route('/categories', methods=['GET'])
@require_auth('catalog.view')
def categories():
"""Categories for a vehicle.
OEM mode: TecDoc part_categories (id_part_category, name).
Local mode: 14 Nexpart top-level groups, filtered by what's available
for this vehicle. Returns 'slug' (string) instead of integer id.
"""
from services.catalog_modes import normalize_mode
mye_id = request.args.get('mye_id', type=int)
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
data = catalog_service.get_categories(master, mye_id)
return jsonify({'data': data})
if mode == 'local':
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
else:
data = catalog_service.get_categories(master, mye_id)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
@catalog_bp.route('/groups', methods=['GET'])
@require_auth('catalog.view')
def groups():
"""Subgroups for a vehicle + parent category.
OEM mode: TecDoc part_groups within a TecDoc part_category (integer ids).
Local mode: Nexpart subgroups within a Nexpart group (string slugs).
"""
from services.catalog_modes import normalize_mode
mye_id = request.args.get('mye_id', type=int)
category_id = request.args.get('category_id', type=int)
if not mye_id or not category_id:
return jsonify({'error': 'mye_id and category_id required'}), 400
category_slug = request.args.get('category_slug')
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
data = catalog_service.get_groups(master, mye_id, category_id)
return jsonify({'data': data})
if mode == 'local':
if not category_slug:
return jsonify({'error': 'category_slug required for local mode'}), 400
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug)
else:
if not category_id:
return jsonify({'error': 'category_id required for oem mode'}), 400
data = catalog_service.get_groups(master, mye_id, category_id)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
# ─── Parts with stock enrichment (master + tenant) ───
@catalog_bp.route('/part-types', methods=['GET'])
@require_auth('catalog.view')
def part_types():
"""Distinct part types (3rd subcategory level) for a vehicle + group/subgroup.
OEM mode: distinct name_part values within a TecDoc part_group_id.
Local mode: Nexpart Part Types within a Nexpart group + subgroup.
"""
from services.catalog_modes import normalize_mode
mye_id = request.args.get('mye_id', type=int)
group_id = request.args.get('group_id', type=int)
group_slug = request.args.get('group_slug') # parent Nexpart group
subgroup_slug = request.args.get('subgroup_slug') # current Nexpart subgroup
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
if mode == 'local':
if not group_slug or not subgroup_slug:
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
data = catalog_service.get_nexpart_part_types_for_vehicle(
master, mye_id, group_slug, subgroup_slug
)
else:
if not group_id:
return jsonify({'error': 'group_id required for oem mode'}), 400
data = catalog_service.get_part_types(master, mye_id, group_id)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
@require_auth('catalog.view')
def shop_supplies_groups():
"""Vehicle-independent groups (Chemicals + Tires/Tools)."""
def _do(master):
data = catalog_service.get_shop_supplies_groups()
return jsonify({'data': data})
return _master_only(_do)
@catalog_bp.route('/shop-supplies/subgroups', methods=['GET'])
@require_auth('catalog.view')
def shop_supplies_subgroups():
group_slug = request.args.get('group_slug')
if not group_slug:
return jsonify({'error': 'group_slug required'}), 400
def _do(master):
data = catalog_service.get_shop_supplies_subgroups(master, group_slug)
return jsonify({'data': data})
return _master_only(_do)
@catalog_bp.route('/shop-supplies/part-types', methods=['GET'])
@require_auth('catalog.view')
def shop_supplies_part_types():
group_slug = request.args.get('group_slug')
subgroup_slug = request.args.get('subgroup_slug')
if not group_slug or not subgroup_slug:
return jsonify({'error': 'group_slug and subgroup_slug required'}), 400
def _do(master):
data = catalog_service.get_shop_supplies_part_types(master, group_slug, subgroup_slug)
return jsonify({'data': data})
return _master_only(_do)
@catalog_bp.route('/shop-supplies/parts', methods=['GET'])
@require_auth('catalog.view')
def shop_supplies_parts():
group_slug = request.args.get('group_slug')
subgroup_slug = request.args.get('subgroup_slug')
part_type_slug = request.args.get('part_type_slug')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 30, type=int)
if not group_slug or not subgroup_slug or not part_type_slug:
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
def _do(master, tenant, branch_id):
result = catalog_service.get_shop_supplies_parts(
master, group_slug, subgroup_slug, part_type_slug,
tenant, branch_id, page, per_page,
)
return jsonify(result)
return _with_conns(_do)
@catalog_bp.route('/parts', methods=['GET'])
@require_auth('catalog.view')
def parts():
"""Parts list for the deepest navigation level.
Three call shapes (the endpoint chooses based on which params are present):
A) OEM mode legacy:
?mode=oem&mye_id=&group_id=&part_type=...
B) Local mode legacy (TecDoc-style):
?mode=local&mye_id=&group_id=&part_type=...
C) Local mode Nexpart navigation (NEW):
?mode=local&mye_id=&nexpart_group=&nexpart_subgroup=&nexpart_part_type=
"""
from services.catalog_modes import normalize_mode
mye_id = request.args.get('mye_id', type=int)
group_id = request.args.get('group_id', type=int)
part_type = request.args.get('part_type') # optional 3rd-level (legacy)
# Nexpart navigation slugs (Local mode only)
nexpart_group = request.args.get('nexpart_group')
nexpart_subgroup = request.args.get('nexpart_subgroup')
nexpart_part_type = request.args.get('nexpart_part_type')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 30, type=int)
if not mye_id or not group_id:
return jsonify({'error': 'mye_id and group_id required'}), 400
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
use_nexpart_nav = mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type
if not use_nexpart_nav and not group_id:
return jsonify({'error': 'group_id (or nexpart_group + subgroup + part_type) required'}), 400
def _do(master, tenant, branch_id):
result = catalog_service.get_parts(master, mye_id, group_id, tenant, branch_id, page, per_page)
if use_nexpart_nav:
result = catalog_service.get_parts_for_nexpart_triple(
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
tenant, branch_id, page, per_page,
)
elif mode == 'local':
result = catalog_service.get_parts_local(
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
)
else:
result = catalog_service.get_parts(
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
)
return jsonify(result)
return _with_conns(_do)

View File

@@ -158,6 +158,61 @@ def create_employee():
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
@require_auth('config.edit')
def update_employee(emp_id):
"""Update an existing employee's name, email, role, branch, discount, active status.
If PIN is provided, it gets re-hashed. Otherwise PIN stays unchanged."""
import bcrypt
data = request.get_json() or {}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Check employee exists
cur.execute("SELECT id FROM employees WHERE id = %s", (emp_id,))
if not cur.fetchone():
cur.close(); conn.close()
return jsonify({'error': 'Employee not found'}), 404
# Build SET clause dynamically — only update provided fields
updates = []
params = []
field_map = {
'name': 'name', 'email': 'email', 'phone': 'phone',
'role': 'role', 'branch_id': 'branch_id',
'max_discount_pct': 'max_discount_pct', 'is_active': 'is_active',
}
for json_key, col in field_map.items():
if json_key in data:
updates.append(f"{col} = %s")
params.append(data[json_key])
# PIN update (only if provided and non-empty)
if data.get('pin') and len(str(data['pin'])) >= 4:
pin_hash = bcrypt.hashpw(str(data['pin']).encode(), bcrypt.gensalt()).decode()
updates.append("pin = %s")
params.append(pin_hash)
updates.append("password_hash = %s")
params.append(pin_hash)
if not updates:
cur.close(); conn.close()
return jsonify({'error': 'Nothing to update'}), 400
params.append(emp_id)
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
from services.audit import log_action
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
new_value={k: v for k, v in data.items() if k != 'pin'})
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'message': 'Employee updated'})
@config_bp.route('/currency', methods=['GET'])
@require_auth()
def get_currency():
@@ -244,6 +299,42 @@ def get_business():
})
@config_bp.route('/business', methods=['PUT'])
@require_auth('config.edit')
def update_business():
"""Save tenant business info to tenant_config."""
data = request.get_json() or {}
field_map = {
'razon_social': 'tenant_razon_social',
'nombre': 'tenant_nombre',
'rfc': 'tenant_rfc',
'regimen_fiscal': 'tenant_regimen_fiscal',
'direccion': 'tenant_direccion',
'telefono': 'tenant_telefono',
'email': 'tenant_email',
# Tax params
'tax_iva': 'tax_iva',
'tax_ieps': 'tax_ieps',
'invoice_serie': 'invoice_serie',
'invoice_folio': 'invoice_folio',
'default_currency': 'default_currency',
'default_payment_method': 'default_payment_method',
}
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
for field, key in field_map.items():
val = data.get(field)
if val is not None:
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (key, str(val).strip()))
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True})
@config_bp.route('/theme', methods=['GET'])
@require_auth()
def get_theme():

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)

95
pos/blueprints/peer_bp.py Normal file
View File

@@ -0,0 +1,95 @@
"""
Peer API — public endpoints for inter-instance communication.
These endpoints do NOT require auth (they're called machine-to-machine by
other Nexus instances on the network). They expose read-only inventory data
so the marketplace can aggregate stock across the whole Nexus network.
Routes:
GET /pos/api/peer/health — instance status + inventory count
GET /pos/api/peer/inventory — search this instance's inventory
"""
from flask import Blueprint, request, jsonify, g
from tenant_db import get_tenant_conn
from services import peer_service
peer_bp = Blueprint('peer', __name__, url_prefix='/pos/api/peer')
# ─── Which tenant to use for the peer endpoint? ──────────────────────────
# In production each instance serves one tenant. For the demo, we hardcode
# tenant_id=11 (the demo refaccionaria). This will be read from a config
# file in the future when each instance has exactly 1 active tenant.
import os
import json
def _get_local_tenant_id():
"""Read the local tenant ID from peers.json or fall back to 11."""
try:
cfg_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'peers.json')
with open(cfg_path, 'r') as f:
cfg = json.load(f)
return cfg.get('tenant_id', 11)
except Exception:
return 11
@peer_bp.route('/health', methods=['GET'])
def peer_health():
"""Public health check — no auth. Returns instance name + basic stats."""
tenant_id = _get_local_tenant_id()
inventory_count = 0
try:
conn = get_tenant_conn(tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT COUNT(*) FROM inventory i
WHERE i.is_active = TRUE
AND COALESCE((SELECT SUM(quantity) FROM inventory_operations WHERE inventory_id = i.id), 0) > 0
""")
inventory_count = cur.fetchone()[0]
cur.close()
conn.close()
except Exception as e:
print(f'[peer] health check DB error: {e}')
return jsonify({
'status': 'ok',
'instance_name': peer_service.get_instance_name(),
'instance_id': peer_service.get_instance_id(),
'inventory_count': inventory_count,
'peer_count': len(peer_service.get_peers()),
})
@peer_bp.route('/inventory', methods=['GET'])
def peer_inventory():
"""Public inventory search — no auth.
Called by other Nexus instances to see what this refaccionaria has in stock.
Returns minimal data: part number, name, brand, price, stock hint.
Does NOT expose exact stock quantities (competitive info).
Query params:
q: search term (optional — without it, returns popular/all items)
limit: max results (default 50, max 200)
"""
q = request.args.get('q', '').strip() or None
limit = min(request.args.get('limit', 50, type=int), 200)
tenant_id = _get_local_tenant_id()
try:
conn = get_tenant_conn(tenant_id)
data = peer_service.get_local_inventory(conn, query=q, limit=limit)
conn.close()
except Exception as e:
print(f'[peer] inventory query error: {e}')
data = []
return jsonify({
'instance_name': peer_service.get_instance_name(),
'data': data,
'count': len(data),
})

View File

@@ -505,7 +505,8 @@ def list_quotations():
where_clauses.append("q.status = %s")
params.append(status)
if g.branch_id:
where_clauses.append("q.branch_id = %s")
# Show both this branch's quotes AND branchless ones (e.g. WhatsApp)
where_clauses.append("(q.branch_id = %s OR q.branch_id IS NULL)")
params.append(g.branch_id)
where = " AND ".join(where_clauses)
@@ -515,7 +516,7 @@ def list_quotations():
cur.execute(f"""
SELECT q.id, q.customer_id, q.employee_id, q.subtotal, q.tax_total,
q.total, q.status, q.valid_until, q.created_at,
q.total, q.status, q.valid_until, q.created_at, q.notes,
c.name as customer_name, e.name as employee_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
@@ -527,6 +528,9 @@ def list_quotations():
quotations = []
for r in cur.fetchall():
notes = r[9] or ''
source = 'whatsapp' if notes.startswith('WA:') else 'pos'
wa_phone = notes.replace('WA:', '') if source == 'whatsapp' else None
quotations.append({
'id': r[0], 'customer_id': r[1], 'employee_id': r[2],
'subtotal': float(r[3]) if r[3] else 0,
@@ -534,7 +538,9 @@ def list_quotations():
'total': float(r[5]) if r[5] else 0,
'status': r[6], 'valid_until': str(r[7]) if r[7] else None,
'created_at': str(r[8]),
'customer_name': r[9], 'employee_name': r[10],
'customer_name': r[10], 'employee_name': r[11],
'source': source,
'wa_phone': wa_phone,
})
cur.close(); conn.close()
@@ -546,6 +552,146 @@ def list_quotations():
})
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
@require_auth('pos.sell')
def delete_quotation(quot_id):
"""Delete a quotation and its items."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
deleted = cur.rowcount
conn.commit()
cur.close()
conn.close()
if deleted == 0:
return jsonify({'error': 'Cotización no encontrada'}), 404
return jsonify({'ok': True, 'deleted_id': quot_id})
@pos_bp.route('/quotations/<int:quot_id>/print', methods=['POST'])
@require_auth('pos.sell')
def print_quotation_ticket(quot_id):
"""Generate a printable ticket for a quotation (ESC/POS or browser)."""
from flask import Response
from services.thermal_printer import generate_quotation_ticket
body = request.get_json(silent=True) or {}
printer_type = body.get('printer_type', 'escpos_raw')
width = int(body.get('width', 80))
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
q.created_at, q.notes, c.name as customer_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = %s
""", (quot_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
notes = row[6] or ''
wa_phone = notes.replace('WA:', '') if notes.startswith('WA:') else None
cur.execute("""
SELECT part_number, name, quantity, unit_price, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (quot_id,))
items = [{'part_number': r[0], 'name': r[1], 'quantity': r[2],
'unit_price': float(r[3]) if r[3] else 0,
'subtotal': float(r[4]) if r[4] else 0} for r in cur.fetchall()]
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
try:
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'tenant_%'")
for rw in cur.fetchall():
if rw[0] == 'tenant_nombre': business_info['name'] = rw[1]
elif rw[0] == 'tenant_rfc': business_info['rfc'] = rw[1]
elif rw[0] == 'tenant_direccion': business_info['address'] = rw[1]
except Exception:
pass
cur.close(); conn.close()
quote_data = {
'id': row[0],
'subtotal': float(row[1]) if row[1] else 0,
'tax_total': float(row[2]) if row[2] else 0,
'total': float(row[3]) if row[3] else 0,
'valid_until': str(row[4]) if row[4] else None,
'created_at': str(row[5]) if row[5] else '',
'customer_name': row[7] or '',
'wa_phone': wa_phone,
'items': items,
}
if printer_type == 'browser':
return jsonify(quote_data)
raw = generate_quotation_ticket(quote_data, business_info, width=width)
return Response(raw, mimetype='application/octet-stream',
headers={'Content-Disposition': f'attachment; filename=cotizacion_{quot_id}.bin'})
@pos_bp.route('/quotations/print-queue', methods=['GET'])
@require_auth('pos.sell')
def quotation_print_queue():
"""Return quotations that were confirmed via WhatsApp and haven't been
printed yet. The POS browser polls this endpoint and auto-prints.
Returns: {data: [{id, total, customer_name, wa_phone, confirmed_at}]}
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.total, q.notes, q.created_at,
c.name as customer_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.status = 'converted'
AND q.notes LIKE 'WA:%%'
AND NOT EXISTS (
SELECT 1 FROM tenant_config
WHERE key = 'printed_quote_' || q.id::text
)
ORDER BY q.created_at DESC
LIMIT 10
""")
rows = cur.fetchall()
data = []
for r in rows:
notes = r[2] or ''
data.append({
'id': r[0],
'total': float(r[1]) if r[1] else 0,
'wa_phone': notes.replace('WA:', '') if notes.startswith('WA:') else None,
'created_at': str(r[3]) if r[3] else '',
'customer_name': r[4] or '',
})
cur.close(); conn.close()
return jsonify({'data': data})
@pos_bp.route('/quotations/<int:quot_id>/mark-printed', methods=['POST'])
@require_auth('pos.sell')
def mark_quotation_printed(quot_id):
"""Mark a quotation as printed so it doesn't appear in the print queue again."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (f'printed_quote_{quot_id}', 'true'))
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True})
@pos_bp.route('/quotations/<int:quot_id>', methods=['GET'])
@require_auth('pos.view')
def get_quotation(quot_id):

View File

@@ -19,6 +19,133 @@ from services import whatsapp_service
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
Returns:
(formatted_text, first_part_dict) — first_part_dict is used by the
quotation system to know what to add when the user says "cotizar".
first_part_dict has: inventory_id, part_number, name, brand, price, tax_rate
"""
if not tenant_conn:
return None, None
try:
# Translate common English search terms to Spanish for local inventory
# (the AI sends search_query in English, but local inventory names
# are often in Spanish)
from services.translations import PART_TRANSLATIONS
search_terms = [search_query]
# Add the Spanish translation if we have one
for en, es in PART_TRANSLATIONS.items():
if en.upper() in search_query.upper():
search_terms.append(es)
break
# Build ILIKE conditions for all search terms
conditions = []
params = []
for term in search_terms:
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
like = f'%{term}%'
params.extend([like, like, like])
where_search = ' OR '.join(conditions)
cur = tenant_conn.cursor()
cur.execute(f"""
SELECT i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
COALESCE(s.stock, 0) AS stock,
i.unit, i.location
FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(quantity) AS stock
FROM inventory_operations
GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE i.is_active = TRUE
AND ({where_search})
ORDER BY
COALESCE(s.stock, 0) > 0 DESC,
i.name
LIMIT 10
""", params)
rows = cur.fetchall()
cur.close()
if not rows:
return ('❌ No tenemos esa parte en inventario actualmente.\n'
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None
# Split into in-stock and out-of-stock
in_stock = [r for r in rows if r[6] > 0]
out_stock = [r for r in rows if r[6] <= 0]
# Build the first-part dict for quotation tracking
# Use the first in-stock part, or first out-of-stock if none available
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
first_part = None
if best:
first_part = {
'inventory_id': None, # we'd need the id — fetch it
'part_number': best[0],
'name': best[1],
'brand': best[2] or '',
'price': float(best[3]) if best[3] else 0,
'tax_rate': 0.16,
'stock': best[6],
'unit': best[7] or 'PZA',
}
# Fetch the inventory ID for the quotation item FK
try:
cur2 = tenant_conn.cursor()
cur2.execute("SELECT id FROM inventory WHERE part_number = %s AND is_active = TRUE LIMIT 1",
(best[0],))
inv_row = cur2.fetchone()
if inv_row:
first_part['inventory_id'] = inv_row[0]
cur2.close()
except Exception:
pass
lines = []
if in_stock:
lines.append('✅ *Tenemos en stock:*')
lines.append('')
for r in in_stock:
part_num, name, brand, p1, p2, p3, stock, unit, location = r
brand_str = f'*{brand}*' if brand else ''
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
lines.append(f'{brand_str} {name}')
lines.append(f' #{part_num}{price_str} ({stock} {unit or "pzas"} disponibles)')
lines.append('')
else:
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
lines.append('')
for r in out_stock[:5]:
part_num, name, brand, p1, p2, p3, stock, unit, location = r
brand_str = f'*{brand}*' if brand else ''
price_str = f'${float(p1):,.2f}' if p1 else ''
lines.append(f'{brand_str} {name} #{part_num} {price_str}')
lines.append('')
lines.append('_Podemos pedirlo — consulta tiempo de entrega._')
# Vehicle context
if vehicle and vehicle.get('brand'):
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}"
lines.append(f'🚗 Vehículo: {v_str.strip()}')
lines.append('\n📞 _Escribe "cotizar" para agregar a tu cotización._')
return '\n'.join(lines), first_part
except Exception as e:
print(f"[WA-AI] Enrichment error: {e}")
return None, None
@whatsapp_bp.route('/status', methods=['GET'])
@require_auth()
def status():
@@ -45,7 +172,14 @@ def logout():
@whatsapp_bp.route('/webhook', methods=['POST'])
def webhook():
"""Receive messages from Baileys bridge (public, no auth)."""
"""Receive messages from Baileys bridge (public, no auth).
Flow:
1. Persist the incoming message to the tenant's whatsapp_messages log.
2. Build inventory context for the AI (what this tenant has in stock).
3. Ask the chatbot for a reply, enriched with that context.
4. Send the reply back via the Baileys bridge.
"""
data = request.get_json(force=True, silent=True) or {}
if data.get('event') != 'messages.upsert':
@@ -55,30 +189,205 @@ def webhook():
if not msg.get('phone') or msg.get('from_me'):
return jsonify({'ok': True})
# Save to DB if tenant connection available
# Reuse one tenant connection for the whole webhook path — we need it
# for persistence AND for the inventory-context lookup.
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
tenant_id = 11
tenant_conn = None
inventory_context = None
try:
# Try to get a tenant connection (use default tenant for webhook)
conn = get_tenant_conn(11) # TODO: resolve tenant from phone number
cur = conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id)
VALUES (%s, 'incoming', %s, %s)
ON CONFLICT DO NOTHING
""", (msg['phone'], msg['text'], msg['message_id']))
conn.commit()
cur.close()
conn.close()
except Exception:
pass
tenant_conn = get_tenant_conn(tenant_id)
# Auto-reply with AI chatbot
if msg.get('text'):
# 1. Log the incoming message (with contact display name)
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
VALUES (%s, 'incoming', %s, %s, %s)
ON CONFLICT DO NOTHING
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
tenant_conn.commit()
cur.close()
# 2. Build inventory context once per webhook call so the chatbot
# can say things like "tengo 5 Bosch BP-123 por $450".
try:
from services.ai_chat import chat
ai_resp = chat(msg['text'])
reply = ai_resp.get('message', '')
from services.ai_chat import get_inventory_context
inventory_context = get_inventory_context(tenant_conn)
except Exception as e:
print(f"[WA-AI] inventory_context failed: {e}")
inventory_context = None
except Exception as e:
print(f"[WA-AI] tenant connection failed: {e}")
# 3. Dispatch by media kind + quotation commands
reply = None
reply_to = msg.get('jid') or msg['phone']
media_kind = msg.get('media_kind', 'text')
clean_phone = msg.get('phone', '')
# ── Check for quotation commands FIRST (before AI) ──
if media_kind == 'text' and msg.get('text'):
from services.wa_quotation import (
detect_quote_intent, get_open_quotation, create_quotation,
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
)
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
if intent == 'add':
last_part = get_last_shown_part(clean_phone)
if not last_part:
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
elif tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if not qid:
qid = create_quotation(tenant_conn, clean_phone)
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
detail = get_quotation_detail(tenant_conn, qid)
item_count = len(detail['items']) if detail else 0
reply = (
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
)
elif intent == 'send':
if tenant_conn:
qid = get_open_quotation(tenant_conn, clean_phone)
if qid:
detail = get_quotation_detail(tenant_conn, qid)
reply = format_quotation_wa(detail)
if not reply:
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
else:
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
elif intent == 'clear':
if tenant_conn:
clear_quotation(tenant_conn, clean_phone)
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
elif intent == 'confirm':
if tenant_conn:
qid = confirm_quotation(tenant_conn, clean_phone)
if qid:
reply = (
f'✅ *Pedido confirmado!*\n\n'
f'Tu cotización #{qid} fue registrada.\n'
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
f'¡Gracias por tu compra! 🙏'
)
else:
reply = '⚠️ No tienes una cotización abierta para confirmar.'
if intent is not None:
# It was a quote command — send reply and skip the AI
if reply:
whatsapp_service.send_message(msg['phone'], reply)
result = whatsapp_service.send_message(reply_to, reply)
if tenant_conn:
try:
cur_save = tenant_conn.cursor()
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
tenant_conn.commit()
cur_save.close()
except Exception:
pass
# Clean up and return early
if tenant_conn:
try: tenant_conn.close()
except Exception: pass
return jsonify({'ok': True})
try:
if media_kind == 'image' and msg.get('media_base64'):
from services.ai_chat import chat_with_image
# Prompt: use the caption if provided, else default to
# "identify this part" which chat_with_image handles gracefully.
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
ai_resp = chat_with_image(
user_message=prompt,
image_base64=msg['media_base64'],
inventory_context=inventory_context,
)
reply = ai_resp.get('message', '') or ''
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
elif media_kind == 'audio' and msg.get('media_base64'):
# Voice note handling — transcribe first, then chat().
# See services.whisper_local for the transcriber.
try:
from services.whisper_local import transcribe_audio_base64
transcript = transcribe_audio_base64(
msg['media_base64'],
mimetype=msg.get('media_mimetype') or 'audio/ogg',
)
except ImportError:
transcript = None
print("[WA-AI] whisper_local not installed — voice notes skipped")
except Exception as e:
transcript = None
print(f"[WA-AI] Whisper transcription failed: {e}")
if transcript:
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
from services.ai_chat import chat
ai_resp = chat(transcript, inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Prefix the reply so the sender knows we understood the voice note
if reply:
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
else:
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
'Puedes escribirme el mensaje?')
elif msg.get('text'):
# Plain text message — standard chatbot flow
from services.ai_chat import chat
ai_resp = chat(msg['text'], inventory_context=inventory_context)
reply = ai_resp.get('message', '') or ''
# Enrich: if the AI returned a search_query, look up real parts
# from the catalog and append them to the WhatsApp reply.
search_q = ai_resp.get('search_query')
vehicle = ai_resp.get('vehicle')
if search_q and reply:
try:
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn)
if enrichment:
reply = reply + '\n\n' + enrichment
# Track the found part so "cotizar" can add it
if found_part:
from services.wa_quotation import set_last_shown_part
set_last_shown_part(clean_phone, found_part)
except Exception as enrich_err:
print(f"[WA-AI] Enrichment failed: {enrich_err}")
# Send reply if we produced one
if reply:
result = whatsapp_service.send_message(reply_to, reply)
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
# Save the bot's reply to DB so it shows in the WhatsApp UI
if tenant_conn:
try:
cur2 = tenant_conn.cursor()
cur2.execute("""
INSERT INTO whatsapp_messages (phone, direction, message_text)
VALUES (%s, 'outgoing', %s)
""", (msg['phone'], reply))
tenant_conn.commit()
cur2.close()
except Exception as db_err:
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
except Exception as e:
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
# 4. Clean up the connection
if tenant_conn is not None:
try:
tenant_conn.close()
except Exception:
pass
@@ -119,14 +428,37 @@ def conversations():
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Clean up phone format: strip @lid and @s.whatsapp.net suffixes
# so all variants of the same number are grouped together.
cur.execute("""
SELECT phone, MAX(message_text) as last_message, MAX(created_at) as last_at, COUNT(*) as msg_count
FROM whatsapp_messages
GROUP BY phone
WITH cleaned AS (
SELECT
REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') AS clean_phone,
message_text,
direction,
created_at,
push_name
FROM whatsapp_messages
)
SELECT clean_phone,
(ARRAY_AGG(message_text ORDER BY created_at DESC))[1] AS last_message,
(ARRAY_AGG(direction ORDER BY created_at DESC))[1] AS last_direction,
MAX(created_at) AS last_at,
COUNT(*) AS msg_count,
(ARRAY_AGG(push_name ORDER BY created_at DESC) FILTER (WHERE push_name IS NOT NULL AND push_name != ''))[1] AS contact_name
FROM cleaned
GROUP BY clean_phone
ORDER BY MAX(created_at) DESC
LIMIT 50
""")
convos = [{'phone': r[0], 'last_message': r[1], 'last_at': str(r[2]), 'count': r[3]} for r in cur.fetchall()]
convos = [{
'phone': r[0],
'last_message': r[1] or '',
'last_direction': r[2] or 'incoming',
'last_at': str(r[3]),
'count': r[4],
'contact_name': r[5] or '',
} for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify({'conversations': convos})
@@ -134,22 +466,68 @@ def conversations():
return jsonify({'conversations': [], 'error': str(e)})
@whatsapp_bp.route('/conversations/<phone>', methods=['GET'])
@whatsapp_bp.route('/conversations/<path:phone>', methods=['GET'])
@require_auth()
def conversation_messages(phone):
# Strip @lid or @s.whatsapp.net suffix for DB lookup
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Match all variants of this phone number
cur.execute("""
SELECT id, direction, message_text, created_at
FROM whatsapp_messages
WHERE phone = %s
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
ORDER BY created_at
LIMIT 100
""", (phone,))
msgs = [{'id': r[0], 'direction': r[1], 'text': r[2], 'date': str(r[3])} for r in cur.fetchall()]
""", (clean_phone,))
msgs = [{
'id': r[0],
'direction': r[1],
'message_text': r[2] or '',
'created_at': str(r[3]),
} for r in cur.fetchall()]
cur.close()
conn.close()
return jsonify({'messages': msgs})
except Exception as e:
return jsonify({'messages': [], 'error': str(e)})
@whatsapp_bp.route('/conversations/<path:phone>', methods=['DELETE'])
@require_auth()
def delete_conversation(phone):
"""Delete all messages for a phone number."""
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
DELETE FROM whatsapp_messages
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
""", (clean_phone,))
deleted = cur.rowcount
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'deleted': deleted})
except Exception as e:
return jsonify({'error': str(e)}), 500
@whatsapp_bp.route('/conversations', methods=['DELETE'])
@require_auth()
def delete_all_conversations():
"""Delete ALL whatsapp messages."""
try:
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("DELETE FROM whatsapp_messages")
deleted = cur.rowcount
conn.commit()
cur.close()
conn.close()
return jsonify({'ok': True, 'deleted': deleted})
except Exception as e:
return jsonify({'error': str(e)}), 500