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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
95
pos/blueprints/peer_bp.py
Normal 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),
|
||||
})
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user