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

@@ -54,6 +54,9 @@ def create_app():
from blueprints.marketplace_bp import marketplace_bp
app.register_blueprint(marketplace_bp)
from blueprints.peer_bp import peer_bp
app.register_blueprint(peer_bp)
# Health check
@app.route('/pos/health')
def health():
@@ -112,6 +115,10 @@ def create_app():
def pos_fleet():
return render_template('fleet.html')
@app.route('/pos/quotations')
def pos_quotations():
return render_template('quotations.html')
@app.route('/pos/whatsapp')
def pos_whatsapp():
return render_template('whatsapp.html')

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

19
pos/peers.json Normal file
View File

@@ -0,0 +1,19 @@
{
"instance_name": "Refaccionaria Demo",
"instance_id": "refac-demo-001",
"tenant_id": 11,
"peers": [
{
"name": "Refaccionaria B",
"url": "http://192.168.1.20:5001",
"enabled": true
},
{
"name": "Refaccionaria C",
"url": "http://192.168.1.30:5001",
"enabled": true
}
],
"peer_timeout_seconds": 3,
"notes": "Edit the 'peers' list with the actual IPs of the other instances on your network. Each instance has its own copy of this file with different peers."
}

View File

@@ -9,8 +9,20 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago.
# El modelo DEBE terminar en ":free" para garantizar costo $0.
# Alternativas gratuitas: "meta-llama/llama-4-scout:free", "google/gemma-3-27b-it:free"
MODEL = "qwen/qwen3.6-plus-preview:free"
MODEL = "qwen/qwen3.6-plus:free"
# Fallback chain: si el modelo principal tiene rate limit (429) o 404
# (deprecated), intenta los siguientes. Todos :free. Mezclamos proveedores
# distintos porque los rate limits aplican por-proveedor.
# Lista actualizada 2026-04-09 después de que qwen3.6-plus fue deprecated.
FALLBACK_MODELS = [
"openai/gpt-oss-120b:free", # OpenInference — gran cobertura
"google/gemma-4-31b-it:free", # Google — nuevo, 262K ctx
"qwen/qwen3-next-80b-a3b-instruct:free", # Alibaba — 262K ctx
"z-ai/glm-4.5-air:free", # Z.AI
"google/gemma-3-27b-it:free", # Google — backup vision
"meta-llama/llama-3.3-70b-instruct:free", # Meta — último fallback
]
def _validate_model(model_id):
"""Ensure only free models are used. Raises if model is not free."""
@@ -318,15 +330,155 @@ def classify_part(part_number):
return {"name": None, "brand": None, "vehicle": None, "category": None}
# ═══════════════════════════════════════════════════════════════════════════
# RESPONSE CACHE — reduces OpenRouter calls for repeated questions
# ═══════════════════════════════════════════════════════════════════════════
# Keyed by a normalized form of the user message. TTL 1 hour. Bypasses
# caching for messages containing VINs or specific part numbers (where the
# answer depends on the exact string).
import hashlib as _hashlib
import re as _re
import time as _time_chat
_RESPONSE_CACHE = {} # key → (expires_at, response_dict)
_CACHE_TTL_SECONDS = 3600 # 1 hour
_CACHE_MAX_SIZE = 1000
_CACHE_HITS = 0
_CACHE_MISSES = 0
# Stopwords that add noise but no meaning — stripped from cache keys.
_CACHE_STOPWORDS = {
'necesito', 'necesitas', 'me', 'das', 'dame', 'tienes', 'tiene', 'hay',
'quiero', 'quisiera', 'puedes', 'puede', 'favor', 'por', 'porfavor',
'hola', 'buenos', 'dias', 'tardes', 'noches', 'holaa',
'i', 'need', 'want', 'do', 'you', 'have', 'please',
}
# Patterns that disable caching — if the message contains any of these, we
# never cache the response because the answer is specific to that exact input.
# Rules designed to minimize false positives against normal Spanish queries
# like "necesito balatas para corolla 2018".
_CACHE_BYPASS_PATTERNS = [
# 17-char VIN (strict, no spaces, alphanumeric except I/O/Q)
_re.compile(r'\b[A-HJ-NPR-Z0-9]{17}\b'),
# Long numeric (12+ digits — too long to be a year/model code)
_re.compile(r'\b\d{12,}\b'),
# Mexican license plate: 3 letters + 3-4 digits
_re.compile(r'\b[A-Z]{3}[-\s]?\d{3,4}\b'),
# OEM with REQUIRED dash/slash separator(s), letters+digits on both sides,
# and a total length that makes it unlikely to be a brand+year collision.
# Example matches: "4G0-857-951-A", "0 986 4B7 013" (after normalizing).
_re.compile(r'\b[A-Z0-9]{2,}[-/][A-Z0-9]{2,}([-/][A-Z0-9]+)+\b'),
]
def _should_bypass_cache(message: str) -> bool:
"""True if the message has VIN / part number / plate — don't cache."""
if not message:
return True
upper = message.upper()
for pat in _CACHE_BYPASS_PATTERNS:
if pat.search(upper):
return True
return False
def _normalize_for_cache(message: str) -> str:
"""Lowercase, strip punctuation, collapse whitespace, drop stopwords."""
if not message:
return ''
s = message.lower().strip()
s = _re.sub(r'[¿?¡!.,;:()\[\]{}\'"]+', ' ', s)
s = _re.sub(r'\s+', ' ', s).strip()
tokens = [t for t in s.split() if t and t not in _CACHE_STOPWORDS]
return ' '.join(tokens)
def _cache_key(user_message: str, inventory_context: str | None) -> str | None:
"""Build a stable cache key for (message, inventory_context).
Returns None if the message should bypass the cache.
"""
if _should_bypass_cache(user_message):
return None
normalized = _normalize_for_cache(user_message)
if not normalized:
return None
# Hash the inventory context so same-tenant-same-question cache hits,
# different-tenant-same-question does NOT (inventory context differs).
ctx_hash = _hashlib.md5((inventory_context or '').encode()).hexdigest()[:12]
return f"{normalized}::{ctx_hash}"
def _cache_get(key: str):
global _CACHE_HITS, _CACHE_MISSES
if not key:
_CACHE_MISSES += 1
return None
entry = _RESPONSE_CACHE.get(key)
if not entry:
_CACHE_MISSES += 1
return None
expires_at, data = entry
if _time_chat.time() > expires_at:
_RESPONSE_CACHE.pop(key, None)
_CACHE_MISSES += 1
return None
_CACHE_HITS += 1
return data
def _cache_set(key: str, data: dict):
if not key or not data:
return
_RESPONSE_CACHE[key] = (_time_chat.time() + _CACHE_TTL_SECONDS, data)
# Bounded cache — evict oldest entries if we grow past the limit
if len(_RESPONSE_CACHE) > _CACHE_MAX_SIZE:
oldest_keys = sorted(
_RESPONSE_CACHE.items(), key=lambda kv: kv[1][0]
)[:200]
for k, _v in oldest_keys:
_RESPONSE_CACHE.pop(k, None)
def chat_cache_stats() -> dict:
"""Diagnostic helper: hit rate and cache size."""
total = _CACHE_HITS + _CACHE_MISSES
hit_rate = (_CACHE_HITS * 100 / total) if total else 0
return {
'entries': len(_RESPONSE_CACHE),
'hits': _CACHE_HITS,
'misses': _CACHE_MISSES,
'hit_rate_pct': round(hit_rate, 1),
'ttl_seconds': _CACHE_TTL_SECONDS,
}
def chat_cache_clear():
"""Manual cache invalidation — e.g. after inventory bulk changes."""
_RESPONSE_CACHE.clear()
def chat(user_message, conversation_history=None, inventory_context=None):
"""Send a message to the AI and get a response with search suggestions.
Caches responses for repeated identical questions (subject to bypass
rules — messages with VINs / part numbers / plates are never cached).
Args:
user_message: The user's chat message.
conversation_history: Previous messages in the conversation.
inventory_context: Optional inventory summary string to inject into the system prompt.
"""
_validate_model(MODEL) # Block paid models
# Cache lookup — only when there's no conversation history (stateless)
cache_key = None
if not conversation_history:
cache_key = _cache_key(user_message, inventory_context)
cached = _cache_get(cache_key)
if cached is not None:
print(f"[AI] Cache HIT for '{user_message[:40]}...'")
return cached
system_content = SYSTEM_PROMPT
if inventory_context:
@@ -337,10 +489,11 @@ def chat(user_message, conversation_history=None, inventory_context=None):
messages.extend(conversation_history)
messages.append({"role": "user", "content": user_message})
import time
max_retries = 3
last_error = None
for attempt in range(max_retries):
# Try each model in the fallback chain on 429 (rate limit)
for model_id in FALLBACK_MODELS:
_validate_model(model_id) # Block paid models
try:
resp = requests.post(
OPENROUTER_URL,
@@ -349,23 +502,32 @@ def chat(user_message, conversation_history=None, inventory_context=None):
"Content-Type": "application/json",
},
json={
"model": MODEL,
"model": model_id,
"messages": messages,
"max_tokens": 500,
"max_tokens": 800,
"temperature": 0.3,
},
timeout=20,
timeout=25,
)
if resp.status_code == 429:
# Rate limited — wait and retry
wait = (attempt + 1) * 5 # 5s, 10s, 15s
if attempt < max_retries - 1:
time.sleep(wait)
continue
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
resp.raise_for_status()
print(f"[AI] Rate limited on {model_id}, trying next model...")
last_error = "rate_limit"
continue
if resp.status_code >= 400:
print(f"[AI] HTTP {resp.status_code} on {model_id}: {resp.text[:200]}")
last_error = f"http_{resp.status_code}"
continue
data = resp.json()
content = data["choices"][0]["message"]["content"]
choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "").strip()
finish = choice.get("finish_reason", "")
if not content:
print(f"[AI] Empty response from {model_id} (finish={finish})")
last_error = "empty_response"
continue
print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)")
# Try to parse JSON response
try:
@@ -376,14 +538,27 @@ def chat(user_message, conversation_history=None, inventory_context=None):
parsed = json.loads(json_str)
else:
parsed = json.loads(stripped)
# Successful JSON response — cache it
if cache_key:
_cache_set(cache_key, parsed)
return parsed
except (json.JSONDecodeError, IndexError):
return {"message": content, "search_query": None, "vehicle": None}
fallback = {"message": content, "search_query": None, "vehicle": None}
# Cache the fallback too — the model gave us a real answer,
# it just wasn't JSON. Next hit saves the API call.
if cache_key:
_cache_set(cache_key, fallback)
return fallback
except Exception as e:
if attempt < max_retries - 1:
continue
return {
"message": f"Error de conexion: {str(e)}",
"search_query": None,
"vehicle": None,
}
print(f"[AI] Error with {model_id}: {e}")
last_error = str(e)
continue
# All models exhausted — DON'T cache errors, we want retries next time
if last_error == "rate_limit":
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
return {
"message": f"Error de conexion: {last_error}",
"search_query": None,
"vehicle": None,
}

View File

@@ -0,0 +1,129 @@
"""
Catalog modes — OEM vs Local bodega filtering for brand lists.
Two catalog modes coexist:
- 'oem' : Full TecDoc catalog (36+ vehicle brands from Apify import).
Use this for any customer-facing "find your exact OEM part" flow.
- 'local' : Curated list of vehicle brands that local bodegas in Mexico
actually service. Used while the TecDoc/Apify import is paused
or to simplify navigation for customers who only care about
what's available locally.
Both modes use the SAME navigation hierarchy (brand > model > year > engine >
category > parts). Only the initial brand list is filtered.
Edit LOCAL_BODEGA_BRANDS below to add/remove brands as the bodega network grows.
Brand names must match the `brands.name_brand` column in nexus_autoparts
(case-sensitive, uppercase).
"""
# ─── OEM mode — full North America coverage (imported from TecDoc) ──────────
OEM_BRANDS_NA = (
'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER',
'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI',
'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA',
'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE',
'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA',
'VOLVO', 'VW',
)
# ─── Local mode — brands actually stocked by Mexican bodegas ────────────────
# Popular Mexican market passenger cars + light trucks. Edit as needed.
LOCAL_BODEGA_BRANDS = (
'NISSAN', # Tsuru, Sentra, Versa, March, Tiida, Navara
'VW', # Jetta, Pointer, Vento, Gol, Polo, Beetle
'CHEVROLET', # Aveo, Chevy, Spark, Beat, Sonic, Sail
'FORD', # Fiesta, Focus, EcoSport, Ranger, Figo
'TOYOTA', # Corolla, Yaris, Hilux, Avanza, Tacoma
'HONDA', # Civic, City, CR-V, Fit, HR-V
'DODGE', # Attitude, Neon, Journey
'CHRYSLER',
'RAM', # Pickups
'HYUNDAI', # Accent, Grand i10, Tucson, Elantra
'KIA', # Rio, Forte, Sportage, Sorento
'MAZDA', # 2, 3, CX-5, CX-30
'MITSUBISHI', # Lancer, L200, Outlander
'RENAULT', # Logan, Sandero, Duster, Stepway
'SEAT', # Ibiza, Leon, Arona
'FIAT', # Uno, Palio, Mobi
'SUZUKI', # Swift, Vitara, Ignis, Ertiga
'JEEP', # Compass, Wrangler, Grand Cherokee, Renegade
'GMC', # Sierra, Terrain
'BUICK', # Encore, Enclave (GM)
)
def get_brands_for_mode(mode):
"""Return the tuple of allowed brand names for a given catalog mode.
Args:
mode: 'oem' or 'local'. Anything else defaults to 'oem'.
Returns:
A tuple of uppercase brand name strings.
"""
if mode == 'local':
return LOCAL_BODEGA_BRANDS
return OEM_BRANDS_NA
def normalize_mode(raw):
"""Normalize a raw mode string from a query param or header."""
if not raw:
return 'oem'
cleaned = str(raw).strip().lower()
return 'local' if cleaned == 'local' else 'oem'
# ─── Local mode — priority aftermarket manufacturer brands ─────────────────
# Ordered list. Brands earlier in the list are shown first and get the top
# "priority" badge in the UI. Match `manufacturers.name_manufacture` (uppercase).
#
# Tier 1 (most trusted / most stocked in Mexican bodegas) — shown first.
# Tier 2 (also popular but not always on every shelf) — shown second.
# Anything not in either list is "other" and shown last.
LOCAL_PRIORITY_MANUFACTURERS_TIER1 = (
'BOSCH', # Universal — ignition, sensors, filters, wipers
'GATES', # Bandas / timing belts
'MONROE', # Amortiguadores
'DENSO', # Ignition, cooling, AC
'MANN-FILTER', # Filtros
'MAHLE', # Filtros, pistones, termostatos
'NGK', # Bujias
'BREMBO', # Frenos premium
'KYB', # Amortiguadores
'SKF', # Rodamientos
)
LOCAL_PRIORITY_MANUFACTURERS_TIER2 = (
'DELPHI',
'VALEO',
'HELLA',
'DAYCO',
'SACHS',
'CHAMPION',
'WAGNER',
'FRAM',
'NSK',
)
# Combined flat tuple (Tier1 followed by Tier2) — used for SQL IN clauses
# and for determining "any priority" status.
LOCAL_PRIORITY_MANUFACTURERS = LOCAL_PRIORITY_MANUFACTURERS_TIER1 + LOCAL_PRIORITY_MANUFACTURERS_TIER2
def get_priority_tier(manufacturer_name):
"""Return 1 for tier 1, 2 for tier 2, 3 for everything else.
Used both by the sort order and by the UI to render a priority badge.
"""
if not manufacturer_name:
return 3
name = manufacturer_name.upper()
if name in LOCAL_PRIORITY_MANUFACTURERS_TIER1:
return 1
if name in LOCAL_PRIORITY_MANUFACTURERS_TIER2:
return 2
return 3

View File

@@ -42,19 +42,22 @@ def _clean_model_name(name):
# VEHICLE HIERARCHY NAVIGATION
# ─────────────────────────────────────────────────────────────────────────────
NORTH_AMERICA_BRANDS = (
'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER',
'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI',
'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA',
'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE',
'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA',
'VOLVO', 'VW',
)
from services.catalog_modes import get_brands_for_mode
# Legacy alias — kept for backwards compatibility with any existing imports.
# Prefer `catalog_modes.OEM_BRANDS_NA` in new code.
NORTH_AMERICA_BRANDS = get_brands_for_mode('oem')
def get_brands(master_conn, year_id=None):
"""Get vehicle brands available in Mexico/USA/Canada that have MYE entries.
If year_id is provided, only brands that have models for that year."""
def get_brands(master_conn, year_id=None, mode='oem'):
"""Get vehicle brands that have MYE entries, filtered by catalog mode.
Args:
master_conn: Connection to the nexus_autoparts master DB.
year_id: Optional — only return brands with models for that year.
mode: 'oem' (full TecDoc coverage) or 'local' (curated bodega list).
"""
allowed = list(get_brands_for_mode(mode))
cur = master_conn.cursor()
if year_id:
cur.execute("""
@@ -64,7 +67,7 @@ def get_brands(master_conn, year_id=None):
JOIN model_year_engine mye ON mye.model_id = m.id_model
WHERE b.name_brand = ANY(%s) AND mye.year_id = %s
ORDER BY b.name_brand
""", (list(NORTH_AMERICA_BRANDS), year_id))
""", (allowed, year_id))
else:
cur.execute("""
SELECT DISTINCT b.id_brand, b.name_brand
@@ -73,7 +76,7 @@ def get_brands(master_conn, year_id=None):
JOIN model_year_engine mye ON mye.model_id = m.id_model
WHERE b.name_brand = ANY(%s)
ORDER BY b.name_brand
""", (list(NORTH_AMERICA_BRANDS),))
""", (allowed,))
rows = cur.fetchall()
cur.close()
return [{'id_brand': r[0], 'name_brand': r[1]} for r in rows]
@@ -189,6 +192,509 @@ def get_categories(master_conn, mye_id):
return [{'id_part_category': r[0], 'name': translate_category(r[1]), 'part_count': r[2]} for r in rows]
# ─────────────────────────────────────────────────────────────────────────────
# NEXPART HIERARCHY (Local mode) — filtered by what TecDoc has for this vehicle
# ─────────────────────────────────────────────────────────────────────────────
# ─── In-memory cache for vehicle → Nexpart classification ─────────────────
# Key: mye_id (int). Value: (expires_at_timestamp, classified_dict).
# TTL is short (5 min) because catalog data rarely changes but we don't
# want stale data lingering across sessions. Single-process cache —
# Gunicorn workers each have their own, which is fine for this workload.
import time as _time
_CLASSIFY_CACHE = {}
_CLASSIFY_TTL_SECONDS = 300
def _classify_cache_get(mye_id):
entry = _CLASSIFY_CACHE.get(mye_id)
if entry is None:
return None
expires_at, data = entry
if _time.time() > expires_at:
_CLASSIFY_CACHE.pop(mye_id, None)
return None
return data
def _classify_cache_set(mye_id, data):
_CLASSIFY_CACHE[mye_id] = (_time.time() + _CLASSIFY_TTL_SECONDS, data)
# Simple unbounded-growth protection: if cache grows past 500 entries,
# evict the oldest half. Real production would use an LRU library.
if len(_CLASSIFY_CACHE) > 500:
sorted_keys = sorted(_CLASSIFY_CACHE.items(), key=lambda kv: kv[1][0])
for k, _v in sorted_keys[:250]:
_CLASSIFY_CACHE.pop(k, None)
def classify_cache_clear():
"""Manual cache invalidation — call after catalog import."""
_CLASSIFY_CACHE.clear()
def classify_cache_stats():
"""Diagnostic helper for the cache state."""
now = _time.time()
alive = sum(1 for expires, _ in _CLASSIFY_CACHE.values() if expires > now)
return {
'total_entries': len(_CLASSIFY_CACHE),
'alive': alive,
'expired': len(_CLASSIFY_CACHE) - alive,
'ttl_seconds': _CLASSIFY_TTL_SECONDS,
}
def _classify_vehicle_parts(master_conn, mye_id):
"""Classify all TecDoc parts for a vehicle into Nexpart triples.
Runs the matcher once per distinct part name, builds a nested dict:
{
"Brake System...": {
"Front Friction, Drums & Rotors": {
"Front Disc Brake Rotor": [oem_part_id, ...],
...
},
...
},
...
}
Drops parts whose name has no Nexpart equivalent (UNMAPPED_STRATEGY=drop).
Used by all 3 Nexpart-filtered functions below — cached by mye_id so
one navigation sequence (categories → subgroups → part types → parts)
does the classification work exactly once.
"""
# Cache hit — skip the query and matcher entirely
cached = _classify_cache_get(mye_id)
if cached is not None:
return cached
from services.nexpart_taxonomy import tecdoc_to_nexpart
cur = master_conn.cursor()
cur.execute("""
SELECT p.id_part, p.name_part
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = %s
""", (mye_id,))
rows = cur.fetchall()
cur.close()
classified = {}
for part_id, name_part in rows:
triple = tecdoc_to_nexpart(name_part)
if not triple:
continue # drop unmapped (Decision 2)
group, subgroup, part_type = triple
classified.setdefault(group, {}) \
.setdefault(subgroup, {}) \
.setdefault(part_type, []) \
.append(part_id)
_classify_cache_set(mye_id, classified)
return classified
def get_nexpart_groups_for_vehicle(master_conn, mye_id):
"""Local mode: return Nexpart top-level groups that have parts for this vehicle.
Output shape mirrors get_categories() but uses `slug` (string) instead of
integer category_id. Empty groups are dropped so the user only sees
categories with at least one matched part.
"""
from services.nexpart_taxonomy import (
NEXPART_TAXONOMY,
translate_taxonomy_node,
)
classified = _classify_vehicle_parts(master_conn, mye_id)
result = []
# Iterate in canonical Nexpart order so the UI is stable
for group in NEXPART_TAXONOMY.keys():
if group not in classified:
continue
# Count distinct part_types matched in this group across all subgroups
part_count = sum(
len(parts)
for subgroup_dict in classified[group].values()
for parts in subgroup_dict.values()
)
result.append({
'slug': group,
'name': translate_taxonomy_node(group),
'name_en': group,
'part_count': part_count,
})
return result
def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug):
"""Local mode: return Nexpart subgroups within a group that have vehicle parts."""
from services.nexpart_taxonomy import (
NEXPART_TAXONOMY,
translate_taxonomy_node,
)
classified = _classify_vehicle_parts(master_conn, mye_id)
group_data = classified.get(group_slug, {})
if not group_data:
return []
# Iterate in the canonical order from NEXPART_TAXONOMY for stability
canonical_order = list(NEXPART_TAXONOMY.get(group_slug, {}).keys())
result = []
for subgroup in canonical_order:
if subgroup not in group_data:
continue
part_count = sum(len(p) for p in group_data[subgroup].values())
result.append({
'slug': subgroup,
'name': translate_taxonomy_node(subgroup),
'name_en': subgroup,
'part_count': part_count,
})
return result
# ═══════════════════════════════════════════════════════════════════════════
# SHOP SUPPLIES — vehicle-independent parts (Network Shop Supplies tab)
# ═══════════════════════════════════════════════════════════════════════════
# These live under 2 Nexpart groups that don't require a vehicle selection:
# - Chemicals, Waxes & Lubricants (oils, fluids, additives)
# - Tires, Wheels, Tools & Accessory Parts (TPMS, lug nuts, universal clips)
#
# The navigation skips the Year→Make→Model→Engine chain and goes directly
# to group selection. The query scans `parts` globally without joining
# `vehicle_parts` (which is HUGE), so it's fast.
# The 2 Nexpart groups that are safely vehicle-independent.
_SHOP_SUPPLIES_GROUPS = (
"Chemicals, Waxes & Lubricants",
"Tires, Wheels, Tools & Accessory Parts",
)
# Maps each Nexpart Part Type in the universal groups to a list of SQL ILIKE
# patterns that match the actual TecDoc name_part values. This inverts the
# forward matcher (which goes TecDoc → Nexpart) — here we're asking "which
# TecDoc part names should be classified into this Nexpart Part Type?"
#
# Built by inspecting real name_part values in the parts table. Grow this
# map when you see shop supplies that are missing from the results.
SHOP_SUPPLIES_PATTERNS = {
# Chemicals, Waxes & Lubricants
"Engine Oil": ["Engine Oil"],
"Automatic Transmission Fluid": ["Automatic Transmission Fluid", "Transmission Oil"],
# Tires & Wheels (TPMS + lug hardware)
"TPMS Sensor": ["TPMS%", "Tire Pressure Sensor%"],
"TPMS Programmable Sensor": ["%TPMS%Programmable%"],
"TPMS Sensor Service Kit": ["%TPMS%Service Kit%", "%TPMS%Repair Kit%"],
"TPMS Sensor Valve Assembly": ["%TPMS%Valve%"],
"TPMS Valve Kit": ["%TPMS%Valve Kit%", "Valve, tyre pressure control system"],
"TPMS Programmable Sensor Valve Kit": ["%TPMS%Programmable%Valve%"],
"Wheel Lug Nut": ["Wheel Nut"],
"Wheel Lug Stud": ["Wheel Bolt", "Wheel Stud"],
# Bumper & License Plate (universal clips)
"Bumper Clip": ["%Clip%bumper%", "%bumper%Clip%"],
"Bumper Cover Retainer": ["%bumper cover%", "Retainer%bumper%"],
"Bumper Cover Trim Panel Retainer": ["%Retainer%trim%", "%bumper trim%"],
"License Plate Bracket Rivet": ["%license plate%rivet%", "%plate%bracket%"],
# Hood, Fender & Body Parts (universal clips)
"Cowl Panel Retainer": ["%cowl%retainer%", "Clip, cowl%"],
"Door Sill Plate Clip": ["%door sill%clip%", "Clip, sill%"],
"Exterior Molding Clip": ["%Clip%trim%", "%trim%strip%Clip%"],
"Interior Panel Clip": ["Clip, trim%"],
"Rocker Panel Molding Retainer": ["%rocker%retainer%"],
"Spoiler Clip": ["Clip, spoiler%", "%spoiler%Clip%"],
"Undercar Shield Clip": ["%undercar shield%", "%underbody%Clip%"],
# Tools, Jacks, Hardware & Manuals — mostly not present in TecDoc
"Cooling System Flush Gun Kit": ["%cooling system flush%"],
"Molding Clip": ["Clip, moulding%", "Clip, molding%"],
"Multi-Purpose Retainer": ["Retainer%", "Clip, mounting%"],
"Interior Panel Retainer": ["Retainer%panel%", "Clip, interior panel%"],
# Interior & Steering Wheel — mostly connectors (sparse in TecDoc)
"Accelerator Pedal Sensor Connector": ["%accelerator pedal%connector%"],
"Clutch Pedal Position Switch Connector": ["%clutch pedal%connector%"],
"Console Trim Panel Clip": ["%console%clip%"],
# Electronics Audio/Visual & Mirrors
"Antenna Mast": ["%antenna mast%", "%antenna%"],
"Interior Rear View Mirror Connector": ["%rear view mirror%connector%"],
"Interior Rear View Mirror Mounting Base": ["%mirror%mounting base%"],
"Keyless Entry Transmitter Cover": ["%keyless%cover%"],
"Lane Departure System Camera": ["%lane departure%"],
}
def _shop_supplies_count_by_part_type(master_conn, part_type_names):
"""Given a list of Nexpart Part Type names (the Shop Supplies-eligible ones),
return {part_type: total_count} using the SHOP_SUPPLIES_PATTERNS map.
Uses one query per Part Type because the patterns are OR'd via ILIKE and
we need a per-PT count. Still fast because patterns are indexed via
trigram if enabled, or just full-scan on 1.5M rows (~500ms total).
"""
result = {}
cur = master_conn.cursor()
for pt in part_type_names:
patterns = SHOP_SUPPLIES_PATTERNS.get(pt)
if not patterns:
continue
# Build a WHERE clause with multiple ILIKE ORs
like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns))
cur.execute(
f"SELECT COUNT(*) FROM parts WHERE {like_parts}",
patterns,
)
count = cur.fetchone()[0] or 0
if count > 0:
result[pt] = count
cur.close()
return result
def _shop_supplies_oem_ids(master_conn, part_type_name, limit=5000):
"""Return the OEM id_part values that match a Shop Supplies Part Type."""
patterns = SHOP_SUPPLIES_PATTERNS.get(part_type_name)
if not patterns:
return []
cur = master_conn.cursor()
like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns))
cur.execute(
f"SELECT id_part FROM parts WHERE {like_parts} LIMIT %s",
patterns + [limit],
)
ids = [row[0] for row in cur.fetchall()]
cur.close()
return ids
def get_shop_supplies_groups():
"""Return the 2 Nexpart groups that don't require a vehicle.
Unlike the vehicle-scoped get_nexpart_groups_for_vehicle(), this returns
ALL subgroups of these groups regardless of whether there are matching
parts in the DB — that check happens at the subgroup level to avoid
scanning `parts` multiple times.
"""
from services.nexpart_taxonomy import (
NEXPART_TAXONOMY,
translate_taxonomy_node,
)
result = []
for group in _SHOP_SUPPLIES_GROUPS:
if group not in NEXPART_TAXONOMY:
continue
subgroup_count = len(NEXPART_TAXONOMY[group])
part_type_count = sum(len(pts) for pts in NEXPART_TAXONOMY[group].values())
result.append({
'slug': group,
'name': translate_taxonomy_node(group),
'name_en': group,
'part_count': part_type_count, # count of distinct Part Types, not parts
'subgroup_count': subgroup_count,
})
return result
def get_shop_supplies_subgroups(master_conn, group_slug):
"""Return subgroups in a Shop Supplies group that have actual TecDoc parts."""
from services.nexpart_taxonomy import (
NEXPART_TAXONOMY,
translate_taxonomy_node,
)
if group_slug not in _SHOP_SUPPLIES_GROUPS:
return []
if group_slug not in NEXPART_TAXONOMY:
return []
subgroups = NEXPART_TAXONOMY[group_slug]
# Count each Part Type via the SHOP_SUPPLIES_PATTERNS map (ILIKE-based
# inverse search that handles naming gaps between Nexpart and TecDoc).
all_part_types = [pt for pts in subgroups.values() for pt in pts]
counts_by_pt = _shop_supplies_count_by_part_type(master_conn, all_part_types)
result = []
for sg_name, pt_list in subgroups.items():
total = sum(counts_by_pt.get(pt, 0) for pt in pt_list)
if total == 0:
continue
result.append({
'slug': sg_name,
'name': translate_taxonomy_node(sg_name),
'name_en': sg_name,
'part_count': total,
})
return result
def get_shop_supplies_part_types(master_conn, group_slug, subgroup_slug):
"""Return Part Types within a Shop Supplies subgroup that have TecDoc parts."""
from services.nexpart_taxonomy import (
NEXPART_TAXONOMY,
translate_taxonomy_node,
)
if group_slug not in _SHOP_SUPPLIES_GROUPS:
return []
subgroups = NEXPART_TAXONOMY.get(group_slug, {})
part_types = subgroups.get(subgroup_slug, [])
if not part_types:
return []
counts_by_pt = _shop_supplies_count_by_part_type(master_conn, part_types)
# Also fetch a sample image for each matched Part Type
cur = master_conn.cursor()
result = []
for pt in part_types:
cnt = counts_by_pt.get(pt, 0)
if cnt == 0:
continue
patterns = SHOP_SUPPLIES_PATTERNS.get(pt, [])
if patterns:
like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns))
cur.execute(
f"SELECT image_url FROM parts WHERE ({like_parts}) AND image_url IS NOT NULL LIMIT 1",
patterns,
)
row = cur.fetchone()
sample_image = row[0] if row else None
else:
sample_image = None
result.append({
'slug': pt,
'name': translate_taxonomy_node(pt),
'name_en': pt,
'variant_count': cnt,
'sample_image': sample_image,
})
cur.close()
return result
def get_shop_supplies_parts(master_conn, group_slug, subgroup_slug, part_type_slug,
tenant_conn, branch_id, page=1, per_page=30):
"""Return paginated parts (with aftermarket enrichment) for a Shop Supplies triple.
Same output shape as get_parts_for_nexpart_triple() — reuses get_parts_local
with an explicit OEM part ID list.
"""
from services.nexpart_taxonomy import NEXPART_TAXONOMY
if group_slug not in _SHOP_SUPPLIES_GROUPS:
return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'}
# Validate that the requested part type exists in the taxonomy
valid_pts = NEXPART_TAXONOMY.get(group_slug, {}).get(subgroup_slug, [])
if part_type_slug not in valid_pts:
return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'}
# Fetch OEM IDs via the inverse-pattern map (handles TecDoc/Nexpart name gaps)
oem_part_ids = _shop_supplies_oem_ids(master_conn, part_type_slug)
if not oem_part_ids:
return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'}
# Reuse the aftermarket-enriched query path
return get_parts_local(
master_conn, mye_id=None, group_id=None,
tenant_conn=tenant_conn, branch_id=branch_id,
page=page, per_page=per_page,
oem_part_ids=oem_part_ids,
)
def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug,
part_type_slug, tenant_conn, branch_id,
page=1, per_page=30):
"""Local mode: fetch parts (aftermarket-prioritized) for a Nexpart triple.
Steps:
1. Classify the vehicle's parts to find which OEM id_part values
map to (group, subgroup, part_type).
2. Delegate to get_parts_local() with the resulting OEM part IDs.
Returns the same shape as get_parts_local().
"""
classified = _classify_vehicle_parts(master_conn, mye_id)
part_ids = (
classified
.get(group_slug, {})
.get(subgroup_slug, {})
.get(part_type_slug, [])
)
if not part_ids:
return {
'data': [],
'pagination': _pagination(page, per_page, 0),
'mode': 'local',
}
return get_parts_local(
master_conn, mye_id=None, group_id=None,
tenant_conn=tenant_conn, branch_id=branch_id,
page=page, per_page=per_page,
oem_part_ids=part_ids,
)
def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup_slug):
"""Local mode: return Nexpart part types within a subgroup that have vehicle parts.
Output shape matches get_part_types() so the frontend can render with
minimal branching: each item has slug + name + variant_count + sample_image.
"""
from services.nexpart_taxonomy import (
NEXPART_TAXONOMY,
translate_taxonomy_node,
)
classified = _classify_vehicle_parts(master_conn, mye_id)
subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {})
if not subgroup_data:
return []
# Pull a sample image for each part type — single query, all part_ids at once
all_part_ids = [
pid
for pids in subgroup_data.values()
for pid in pids
]
image_map = {}
if all_part_ids:
cur = master_conn.cursor()
cur.execute("""
SELECT id_part, image_url
FROM parts
WHERE id_part = ANY(%s) AND image_url IS NOT NULL
""", (all_part_ids,))
for pid, url in cur.fetchall():
image_map[pid] = url
cur.close()
canonical_order = NEXPART_TAXONOMY.get(group_slug, {}).get(subgroup_slug, [])
result = []
for pt in canonical_order:
if pt not in subgroup_data:
continue
part_ids = subgroup_data[pt]
sample_image = next((image_map[pid] for pid in part_ids if pid in image_map), None)
result.append({
'slug': pt,
'name': translate_taxonomy_node(pt),
'name_en': pt,
'variant_count': len(part_ids),
'sample_image': sample_image,
})
return result
def get_groups(master_conn, mye_id, category_id):
"""Get part groups (subcategories) for this vehicle + category, with part counts."""
cur = master_conn.cursor()
@@ -209,16 +715,62 @@ def get_groups(master_conn, mye_id, category_id):
return [{'id_part_group': r[0], 'name': r[1], 'part_count': r[2]} for r in rows]
def get_part_types(master_conn, mye_id, group_id):
"""Get distinct part types within a vehicle+group (Nexpart-style 3rd subcategory level).
A "part type" is a unique part name within a group — e.g. within "Brake System"
group, the types might be "Brake Disc", "Brake Pad", "Brake Caliper", each with
multiple OEM/aftermarket variants.
Returns: [{name, slug, variant_count, sample_image}]
- name: display name (Spanish if available, else original)
- slug: URL-safe key used to filter parts (the original English name_part)
- variant_count: how many distinct OEM parts exist for this type
- sample_image: image URL of the first variant (for thumbnail)
"""
cur = master_conn.cursor()
# Use ORIGINAL name_part as the slug (matches DB column for filtering),
# but display the Spanish translation if available.
cur.execute("""
SELECT
p.name_part AS slug,
COALESCE(p.name_es, p.name_part) AS display_name,
COUNT(*) AS variants,
(ARRAY_AGG(p.image_url) FILTER (WHERE p.image_url IS NOT NULL))[1] AS sample_image
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = %s
AND p.group_id = %s
GROUP BY p.name_part, COALESCE(p.name_es, p.name_part)
ORDER BY variants DESC, display_name ASC
""", (mye_id, group_id))
rows = cur.fetchall()
cur.close()
return [
{
'slug': r[0],
'name': translate_part_name(r[1]),
'variant_count': r[2],
'sample_image': r[3],
}
for r in rows
]
# ─────────────────────────────────────────────────────────────────────────────
# PARTS LIST + DETAIL (with stock enrichment)
# ─────────────────────────────────────────────────────────────────────────────
def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per_page=30):
def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per_page=30, part_type=None):
"""Get parts for a vehicle + part group, enriched with local stock + bodega indicator.
1. Fetch parts from nexus_autoparts (vehicle_parts + parts) — paginated
2. For each OEM number, look up tenant inventory for local stock
3. For each part_id, check warehouse_inventory for bodega availability
Optional part_type filter (string): when provided, only returns parts whose
name_part matches exactly. Used for the 3rd subcategory level (Nexpart-style).
Returns: {data: [...], pagination: {...}}
"""
per_page = min(per_page, 100)
@@ -226,13 +778,20 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
cur = master_conn.cursor()
extra_where = ""
extra_params_count = (mye_id, group_id)
extra_params_fetch = (mye_id, group_id, per_page, offset)
if part_type:
extra_where = " AND p.name_part = %s"
extra_params_count = (mye_id, group_id, part_type)
extra_params_fetch = (mye_id, group_id, part_type, per_page, offset)
# Count total (bounded — uses indexed mye_id + group_id join)
cur.execute("""
SELECT COUNT(*)
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = %s AND p.group_id = %s
""", (mye_id, group_id))
WHERE vp.model_year_engine_id = %s AND p.group_id = %s""" + extra_where, extra_params_count)
total = cur.fetchone()[0]
# Fetch page of parts
@@ -241,10 +800,10 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
p.description, p.description_es, p.image_url
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = %s AND p.group_id = %s
WHERE vp.model_year_engine_id = %s AND p.group_id = %s""" + extra_where + """
ORDER BY p.name_part
LIMIT %s OFFSET %s
""", (mye_id, group_id, per_page, offset))
""", extra_params_fetch)
rows = cur.fetchall()
if not rows:
@@ -289,6 +848,185 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
return {'data': items, 'pagination': _pagination(page, per_page, total)}
def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id,
page=1, per_page=30, part_type=None, oem_part_ids=None):
"""Local catalog mode: show aftermarket parts instead of OEM.
Two filtering modes:
A) `oem_part_ids` provided → fetch aftermarket equivalents for that
specific list of OEM IDs. Used by get_parts_for_nexpart_triple()
(Nexpart navigation in Local mode).
B) `oem_part_ids` is None → use (mye_id, group_id, optional part_type)
to find OEM parts via vehicle_parts join. Legacy path for the
TecDoc-style Local navigation.
Flow (mode B; mode A skips step 1 since IDs are already known):
1. Find OEM parts for the vehicle+group.
2. For each OEM part, pull all aftermarket equivalents.
3. Join manufacturers to get brand name.
4. Join warehouse_inventory to check bodega availability.
5. Sort by priority tier, then in-stock first, then manufacturer name.
6. Paginate.
Returns:
{data: [...], pagination: {...}, mode: 'local'}
Each part item: manufacturer, priority_tier, in_stock_network,
warehouse_price, plus the standard fields.
"""
from services.catalog_modes import (
LOCAL_PRIORITY_MANUFACTURERS_TIER1,
LOCAL_PRIORITY_MANUFACTURERS_TIER2,
get_priority_tier,
)
per_page = min(per_page, 100)
offset = (page - 1) * per_page
cur = master_conn.cursor()
tier1 = list(LOCAL_PRIORITY_MANUFACTURERS_TIER1)
tier2 = list(LOCAL_PRIORITY_MANUFACTURERS_TIER2)
# ─── Build the WHERE clause for the OEM-side filter ───
if oem_part_ids is not None:
# Mode A: explicit OEM ID list (Nexpart navigation)
where_clause = "p.id_part = ANY(%s)"
where_params_count = (oem_part_ids,)
from_join_count = """
FROM parts p
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
"""
else:
# Mode B: vehicle+group filter (legacy TecDoc navigation)
from_join_count = """
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
"""
where_clause = "vp.model_year_engine_id = %s AND p.group_id = %s"
where_params_count = (mye_id, group_id)
if part_type:
where_clause += " AND p.name_part = %s"
where_params_count = (mye_id, group_id, part_type)
# Count total aftermarket parts
cur.execute(
"SELECT COUNT(*) " + from_join_count + " WHERE " + where_clause,
where_params_count,
)
total = cur.fetchone()[0]
# Priority-sorted fetch — same WHERE clause as the COUNT, plus tiers + paging.
fetch_params = list(where_params_count) + [tier1, tier2, per_page, offset]
cur.execute("""
WITH aftermarket_for_vehicle AS (
SELECT DISTINCT
ap.id_aftermarket_parts,
ap.oem_part_id,
ap.part_number,
COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name,
ap.price_usd,
m.name_manufacture,
p.oem_part_number,
COALESCE(p.name_es, p.name_part) AS oem_name,
COALESCE(p.description_es, p.description) AS oem_desc,
p.image_url AS oem_image
""" + from_join_count + """
WHERE """ + where_clause + """
),
stock_per_oem AS (
SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock
FROM warehouse_inventory
WHERE stock_quantity > 0
GROUP BY part_id
)
SELECT afv.id_aftermarket_parts,
afv.oem_part_id,
afv.part_number,
afv.am_name,
afv.price_usd,
afv.name_manufacture,
afv.oem_part_number,
afv.oem_name,
afv.oem_desc,
afv.oem_image,
COALESCE(s.bodega_count, 0) AS bodega_count,
s.min_price AS warehouse_price,
COALESCE(s.total_stock, 0) AS warehouse_stock,
CASE
WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 1
WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 2
ELSE 3
END AS tier
FROM aftermarket_for_vehicle afv
LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id
ORDER BY tier ASC,
(COALESCE(s.bodega_count, 0) > 0) DESC,
afv.name_manufacture ASC,
afv.am_name ASC
LIMIT %s OFFSET %s
""", fetch_params)
rows = cur.fetchall()
cur.close()
if not rows:
return {'data': [], 'pagination': _pagination(page, per_page, total), 'mode': 'local'}
# Enrich with tenant local stock (look up by OEM part number).
# Use a different name to avoid shadowing the `oem_part_ids` parameter.
oem_numbers = list({r[6] for r in rows if r[6]})
result_oem_ids = list({r[1] for r in rows if r[1]})
local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, result_oem_ids)
items = []
for r in rows:
aft_id = r[0]
oem_part_id = r[1]
aft_number = r[2]
aft_name = r[3]
price_usd = r[4]
manufacturer = r[5]
oem_number = r[6]
oem_name = r[7]
oem_desc = r[8]
oem_image = r[9]
bodega_count = r[10]
warehouse_price = r[11]
warehouse_stock = r[12]
tier = r[13]
# Tenant local stock (refaccionaria's own inventory)
local = local_map.get(oem_number) or local_map.get(f'cat:{oem_part_id}')
image_url = (local.get('image_url') if local else None) or oem_image
items.append({
# Keep fields compatible with OEM mode output so the frontend
# can render with minimal branching.
'id_part': oem_part_id, # OEM id used for detail drill-down
'id_aftermarket': aft_id, # aftermarket row id (for future use)
'oem_part_number': oem_number,
'part_number': aft_number, # aftermarket SKU
'name': translate_part_name(aft_name or oem_name),
'description': oem_desc,
'image_url': image_url,
'manufacturer': manufacturer,
'priority_tier': tier, # 1, 2, or 3
'local_stock': local['stock'] if local else 0,
'local_price': local['price_1'] if local else None,
'bodega_count': bodega_count,
'warehouse_stock': warehouse_stock,
'warehouse_price': float(warehouse_price) if warehouse_price is not None else None,
'in_stock_network': bodega_count > 0,
'price_usd': float(price_usd) if price_usd is not None else None,
})
return {'data': items, 'pagination': _pagination(page, per_page, total), 'mode': 'local'}
def get_part_detail(master_conn, part_id, tenant_conn, branch_id):
"""Get full detail for a single part: catalog info, local stock, bodegas, alternatives.
@@ -538,7 +1276,13 @@ def _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, catalog_part_ids)
Returns: dict keyed by oem_number (or 'cat:{id}') -> {stock, price_1, ...}
Matches by: part_number = oem_number OR catalog_part_id = id
Public-catalog-safe: when tenant_conn is None (public browsing, no tenant
context) returns an empty dict so the parts list still renders without
local stock/price enrichment.
"""
if tenant_conn is None:
return {}
if not oem_numbers and not catalog_part_ids:
return {}

View File

@@ -0,0 +1,810 @@
"""
Marketplace B2B — service layer for bodegas, warehouse inventory and
Purchase Orders (Phase 1).
State machine:
draft → submitted → confirmed → ready → delivered → closed
↘ rejected (terminal)
Public API is grouped by concern:
- Bodegas: list_bodegas, get_bodega, verify_bodega
- Inventory: upload_inventory_csv, search_inventory
- POs: create_po_draft, submit_po, transition_po,
get_po_detail, list_pos_for_buyer, list_pos_for_seller
- Notifications: notify_po_status_change (used internally by transition_po)
All DB calls take a `master_conn` (psycopg2 connection to nexus_autoparts).
The caller is responsible for committing and closing.
"""
import csv
import io
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
# ═══════════════════════════════════════════════════════════════════════════
# STATE MACHINE
# ═══════════════════════════════════════════════════════════════════════════
PO_STATUSES = ('draft', 'submitted', 'confirmed', 'rejected', 'ready', 'delivered', 'closed')
# Map: current_status → {new_status: {actor_kinds}}
# 'buyer' = user who created the PO; 'seller' = bodega owner/user
PO_TRANSITIONS = {
'draft': {'submitted': {'buyer'}},
'submitted': {'confirmed': {'seller'}, 'rejected': {'seller'}},
'confirmed': {'ready': {'seller'}},
'ready': {'delivered': {'buyer', 'seller'}},
'delivered': {'closed': {'buyer', 'seller'}},
# terminal: rejected, closed
}
def _is_valid_transition(from_status: str, to_status: str, actor_kind: str) -> bool:
allowed = PO_TRANSITIONS.get(from_status, {}).get(to_status)
if not allowed:
return False
return actor_kind in allowed
# ═══════════════════════════════════════════════════════════════════════════
# BODEGAS
# ═══════════════════════════════════════════════════════════════════════════
def list_bodegas(master_conn, verified_only: bool = True, city: str = None) -> list[dict]:
"""Return all bodegas, optionally filtered."""
cur = master_conn.cursor()
clauses = []
params = []
if verified_only:
clauses.append("verified = TRUE")
if city:
clauses.append("LOWER(city) = LOWER(%s)")
params.append(city)
where = "WHERE " + " AND ".join(clauses) if clauses else ""
cur.execute(f"""
SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state, verified
FROM bodegas
{where}
ORDER BY name
""", params)
rows = cur.fetchall()
cur.close()
return [
{
'id_bodega': r[0], 'name': r[1], 'owner_name': r[2],
'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6],
'verified': r[7],
}
for r in rows
]
def get_bodega(master_conn, bodega_id: int) -> Optional[dict]:
cur = master_conn.cursor()
cur.execute("""
SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state,
address, verified, commission_pct
FROM bodegas WHERE id_bodega = %s
""", (bodega_id,))
r = cur.fetchone()
cur.close()
if not r:
return None
return {
'id_bodega': r[0], 'name': r[1], 'owner_name': r[2],
'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6],
'address': r[7], 'verified': r[8], 'commission_pct': float(r[9] or 0),
}
def create_bodega(master_conn, *, name: str, whatsapp_phone: str,
owner_name: str = None, email: str = None,
city: str = None, state: str = None, address: str = None) -> int:
"""Register a new bodega (unverified by default). Admin verifies later."""
cur = master_conn.cursor()
cur.execute("""
INSERT INTO bodegas (name, owner_name, whatsapp_phone, email, city, state, address)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id_bodega
""", (name, owner_name, whatsapp_phone, email, city, state, address))
bodega_id = cur.fetchone()[0]
cur.close()
return bodega_id
def verify_bodega(master_conn, bodega_id: int) -> bool:
cur = master_conn.cursor()
cur.execute("""
UPDATE bodegas SET verified = TRUE, verified_at = NOW() WHERE id_bodega = %s
""", (bodega_id,))
ok = cur.rowcount > 0
cur.close()
return ok
# ═══════════════════════════════════════════════════════════════════════════
# INVENTORY — warehouse_inventory CSV upload + search
# ═══════════════════════════════════════════════════════════════════════════
def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
"""Bulk-upload a bodega's inventory from a CSV string.
Expected columns (case-insensitive, whitespace-tolerant):
part_number, stock, price
Optional:
min_order, warehouse_location, currency
Resolution rules:
- part_number matches `parts.oem_part_number` exactly (case-sensitive).
- Parts not found in the master catalog are skipped and reported.
- Existing rows for (bodega_id, part_id, warehouse_location) are updated
via UPSERT; new rows are inserted.
Returns a summary dict: {ok, inserted, updated, skipped, errors}
"""
reader = csv.DictReader(io.StringIO(csv_text))
# Normalize header names
fieldnames = [f.strip().lower() for f in (reader.fieldnames or [])]
required = {'part_number', 'stock', 'price'}
missing = required - set(fieldnames)
if missing:
return {
'ok': False,
'error': f'Columnas faltantes en CSV: {", ".join(sorted(missing))}',
'inserted': 0, 'updated': 0, 'skipped': 0,
}
# Resolve bodega → its legacy user_id (warehouse_inventory still requires it)
cur = master_conn.cursor()
cur.execute("SELECT id_bodega FROM bodegas WHERE id_bodega = %s", (bodega_id,))
if not cur.fetchone():
cur.close()
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
inserted = 0
updated = 0
skipped = 0
errors = []
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
norm = {k.strip().lower(): (v or '').strip() for k, v in row.items()}
part_number = norm.get('part_number', '')
stock_str = norm.get('stock', '0')
price_str = norm.get('price', '0')
if not part_number:
errors.append(f'Fila {i}: part_number vacio')
skipped += 1
continue
try:
stock = int(stock_str)
price = float(price_str)
except ValueError:
errors.append(f'Fila {i}: stock o price invalido')
skipped += 1
continue
# Resolve part_number → part_id
cur.execute(
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
(part_number,)
)
row_part = cur.fetchone()
if not row_part:
errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo')
skipped += 1
continue
part_id = row_part[0]
# Resolve user_id from the bodega (use bodega_id as fallback if null)
user_id = norm.get('user_id') or bodega_id # backward compat
try:
user_id = int(user_id)
except (ValueError, TypeError):
user_id = bodega_id
location = norm.get('warehouse_location') or 'Principal'
currency = (norm.get('currency') or 'MXN').upper()
min_order = int(norm.get('min_order') or 1)
# UPSERT on (user_id, part_id, warehouse_location) — the existing
# unique constraint. Don't block if user_id FK fails.
try:
cur.execute("""
INSERT INTO warehouse_inventory
(user_id, part_id, price, stock_quantity, min_order_quantity,
warehouse_location, bodega_id, currency, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (user_id, part_id, warehouse_location)
DO UPDATE SET
price = EXCLUDED.price,
stock_quantity = EXCLUDED.stock_quantity,
min_order_quantity = EXCLUDED.min_order_quantity,
bodega_id = EXCLUDED.bodega_id,
currency = EXCLUDED.currency,
updated_at = NOW()
RETURNING (xmax = 0) AS inserted
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
was_insert = cur.fetchone()[0]
if was_insert:
inserted += 1
else:
updated += 1
except Exception as e:
errors.append(f'Fila {i}: DB error: {str(e)[:100]}')
skipped += 1
master_conn.rollback() # so next INSERTs can proceed
continue
cur.close()
master_conn.commit()
return {
'ok': True,
'inserted': inserted,
'updated': updated,
'skipped': skipped,
'errors': errors[:20], # cap to avoid huge responses
'total_errors': len(errors),
}
def search_inventory(master_conn, *, query: str = None, brand: str = None,
city: str = None, limit: int = 50) -> list[dict]:
"""Browse warehouse_inventory filtered by query / brand / city.
Returns parts WITH stock > 0 from VERIFIED bodegas only.
Aggregates identical parts across bodegas so the buyer sees each part once
with a list of bodegas that have it in stock.
"""
cur = master_conn.cursor()
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
params = []
if query:
clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
like = f'%{query}%'
params.extend([like, like, like])
if brand:
# Search by vehicle brand via vehicle_parts → model_year_engine → models → brands.
# Too slow for this MVP. Instead, match on aftermarket manufacturer name.
clauses.append("""
EXISTS (
SELECT 1 FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s)
)
""")
params.append(brand)
if city:
clauses.append("LOWER(b.city) = LOWER(%s)")
params.append(city)
where_sql = " AND ".join(clauses)
cur.execute(f"""
SELECT
p.id_part,
p.oem_part_number,
COALESCE(p.name_es, p.name_part) AS name,
p.image_url,
COUNT(DISTINCT b.id_bodega) AS bodega_count,
MIN(wi.price) AS min_price,
MAX(wi.price) AS max_price,
SUM(wi.stock_quantity) AS total_stock,
-- List of bodega names that have this part in stock
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
JOIN parts p ON p.id_part = wi.part_id
WHERE {where_sql}
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
ORDER BY total_stock DESC
LIMIT %s
""", params + [limit])
rows = cur.fetchall()
cur.close()
return [
{
'id_part': r[0],
'oem_part_number': r[1],
'name': r[2],
'image_url': r[3],
'bodega_count': r[4],
'min_price': float(r[5]) if r[5] is not None else None,
'max_price': float(r[6]) if r[6] is not None else None,
'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar',
'bodega_names': r[8], # may expose; adjust if sensitive
}
for r in rows
]
def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
"""Return the list of verified bodegas that currently have a given OEM part
in stock. Used when the buyer wants to pick WHICH bodega to order from.
"""
cur = master_conn.cursor()
cur.execute("""
SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone,
wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
WHERE wi.part_id = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
ORDER BY wi.price ASC
""", (part_id,))
rows = cur.fetchall()
cur.close()
return [
{
'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3],
'price': float(r[4]) if r[4] is not None else None,
'stock_hint': 'En stock', # don't expose exact quantity
'min_order': r[6] or 1,
'currency': r[7] or 'MXN',
}
for r in rows
]
# ═══════════════════════════════════════════════════════════════════════════
# PURCHASE ORDERS
# ═══════════════════════════════════════════════════════════════════════════
def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
buyer_display_name: str, buyer_phone: str, buyer_email: str,
bodega_id: int, items: list,
delivery_method: str = 'pickup',
delivery_address: str = None,
buyer_notes: str = None) -> int:
"""Create a PO in 'draft' status with its items.
Args:
items: list of dicts with keys: part_id, quantity, unit_price (optional)
If unit_price is missing, it's pulled from warehouse_inventory.
Returns the new po_id.
"""
if not items:
raise ValueError('A PO must have at least one item')
cur = master_conn.cursor()
# Create header
cur.execute("""
INSERT INTO purchase_orders (
buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email,
bodega_id, status, delivery_method, delivery_address, buyer_notes
) VALUES (%s, %s, %s, %s, %s, %s, 'draft', %s, %s, %s)
RETURNING id_po
""", (
buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email,
bodega_id, delivery_method, delivery_address, buyer_notes,
))
po_id = cur.fetchone()[0]
# Insert items
total = 0.0
for item in items:
part_id = int(item['part_id'])
quantity = int(item['quantity'])
if quantity < 1:
continue
# Lookup part info + price
cur.execute("""
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
FROM parts p
LEFT JOIN warehouse_inventory wi
ON wi.part_id = p.id_part AND wi.bodega_id = %s
WHERE p.id_part = %s LIMIT 1
""", (bodega_id, part_id))
r = cur.fetchone()
if not r:
continue
oem, name, db_price = r
unit_price = float(item.get('unit_price') or db_price or 0)
subtotal = round(unit_price * quantity, 2)
total += subtotal
cur.execute("""
INSERT INTO purchase_order_items
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
# Update header total
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
(round(total, 2), po_id))
# Log initial status
cur.execute("""
INSERT INTO po_status_history (po_id, from_status, to_status, actor_user_id, actor_kind, note)
VALUES (%s, NULL, 'draft', %s, 'buyer', 'PO creado')
""", (po_id, buyer_user_id))
cur.close()
master_conn.commit()
return po_id
def transition_po(master_conn, *, po_id: int, new_status: str,
actor_user_id: int, actor_kind: str,
note: str = None) -> dict:
"""Transition a PO to a new status with full validation and notification.
Returns: {ok, from_status, to_status, notified} or {ok: False, error}
"""
if new_status not in PO_STATUSES:
return {'ok': False, 'error': f'Invalid status: {new_status}'}
cur = master_conn.cursor()
cur.execute("SELECT status FROM purchase_orders WHERE id_po = %s FOR UPDATE", (po_id,))
row = cur.fetchone()
if not row:
cur.close()
return {'ok': False, 'error': f'PO {po_id} not found'}
from_status = row[0]
if not _is_valid_transition(from_status, new_status, actor_kind):
cur.close()
return {
'ok': False,
'error': f'Transition {from_status}{new_status} not allowed for {actor_kind}',
}
# Timestamp columns per state
ts_field = {
'submitted': 'submitted_at',
'confirmed': 'confirmed_at',
'ready': 'ready_at',
'delivered': 'delivered_at',
'closed': 'closed_at',
}.get(new_status)
if ts_field:
cur.execute(
f"UPDATE purchase_orders SET status = %s, {ts_field} = NOW() WHERE id_po = %s",
(new_status, po_id),
)
else:
cur.execute("UPDATE purchase_orders SET status = %s WHERE id_po = %s",
(new_status, po_id))
# Log history row
cur.execute("""
INSERT INTO po_status_history
(po_id, from_status, to_status, actor_user_id, actor_kind, note)
VALUES (%s, %s, %s, %s, %s, %s)
""", (po_id, from_status, new_status, actor_user_id, actor_kind, note))
cur.close()
master_conn.commit()
# Fire notifications — non-blocking (failures logged, not raised)
notified = []
try:
notified = notify_po_status_change(master_conn, po_id, new_status)
except Exception as e:
print(f'[marketplace] notification failed for PO {po_id}: {e}')
return {
'ok': True,
'from_status': from_status,
'to_status': new_status,
'notified': notified,
}
def get_po_detail(master_conn, po_id: int) -> Optional[dict]:
cur = master_conn.cursor()
cur.execute("""
SELECT po.id_po, po.buyer_tenant_id, po.buyer_user_id, po.buyer_display_name,
po.buyer_phone, po.buyer_email,
po.bodega_id, b.name AS bodega_name, b.whatsapp_phone AS bodega_phone,
b.email AS bodega_email,
po.status, po.total_amount, po.currency,
po.buyer_notes, po.seller_notes,
po.delivery_method, po.delivery_address,
po.created_at, po.submitted_at, po.confirmed_at, po.ready_at,
po.delivered_at, po.closed_at
FROM purchase_orders po
JOIN bodegas b ON b.id_bodega = po.bodega_id
WHERE po.id_po = %s
""", (po_id,))
r = cur.fetchone()
if not r:
cur.close()
return None
po = {
'id_po': r[0], 'buyer_tenant_id': r[1], 'buyer_user_id': r[2],
'buyer_display_name': r[3], 'buyer_phone': r[4], 'buyer_email': r[5],
'bodega_id': r[6], 'bodega_name': r[7],
'bodega_phone': r[8], 'bodega_email': r[9],
'status': r[10],
'total_amount': float(r[11]) if r[11] is not None else 0.0,
'currency': r[12],
'buyer_notes': r[13], 'seller_notes': r[14],
'delivery_method': r[15], 'delivery_address': r[16],
'created_at': r[17].isoformat() if r[17] else None,
'submitted_at': r[18].isoformat() if r[18] else None,
'confirmed_at': r[19].isoformat() if r[19] else None,
'ready_at': r[20].isoformat() if r[20] else None,
'delivered_at': r[21].isoformat() if r[21] else None,
'closed_at': r[22].isoformat() if r[22] else None,
}
# Items
cur.execute("""
SELECT id_po_item, part_id, oem_part_number, part_name, manufacturer,
quantity, unit_price, subtotal, confirmed_qty, notes
FROM purchase_order_items WHERE po_id = %s ORDER BY id_po_item
""", (po_id,))
po['items'] = [
{
'id_po_item': ir[0], 'part_id': ir[1], 'oem_part_number': ir[2],
'part_name': ir[3], 'manufacturer': ir[4],
'quantity': ir[5],
'unit_price': float(ir[6]) if ir[6] is not None else 0.0,
'subtotal': float(ir[7]) if ir[7] is not None else 0.0,
'confirmed_qty': ir[8],
'notes': ir[9],
}
for ir in cur.fetchall()
]
# Status history
cur.execute("""
SELECT from_status, to_status, actor_kind, note, created_at
FROM po_status_history WHERE po_id = %s ORDER BY created_at
""", (po_id,))
po['history'] = [
{
'from_status': h[0], 'to_status': h[1], 'actor_kind': h[2],
'note': h[3], 'at': h[4].isoformat() if h[4] else None,
}
for h in cur.fetchall()
]
cur.close()
return po
def list_pos_for_buyer(master_conn, buyer_tenant_id: int, buyer_user_id: int = None,
limit: int = 50) -> list[dict]:
"""Return POs created by a buyer (filtered by tenant or user)."""
cur = master_conn.cursor()
clauses = ['po.buyer_tenant_id = %s']
params = [buyer_tenant_id]
if buyer_user_id is not None:
clauses.append('po.buyer_user_id = %s')
params.append(buyer_user_id)
where = ' AND '.join(clauses)
cur.execute(f"""
SELECT po.id_po, po.status, po.total_amount, po.currency,
po.bodega_id, b.name AS bodega_name,
po.created_at, po.submitted_at,
(SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count
FROM purchase_orders po
JOIN bodegas b ON b.id_bodega = po.bodega_id
WHERE {where}
ORDER BY po.created_at DESC
LIMIT %s
""", params + [limit])
rows = cur.fetchall()
cur.close()
return [
{
'id_po': r[0], 'status': r[1],
'total_amount': float(r[2]) if r[2] is not None else 0.0,
'currency': r[3],
'bodega_id': r[4], 'bodega_name': r[5],
'created_at': r[6].isoformat() if r[6] else None,
'submitted_at': r[7].isoformat() if r[7] else None,
'item_count': r[8],
}
for r in rows
]
def list_pos_for_seller(master_conn, bodega_id: int, limit: int = 50) -> list[dict]:
"""Inbox: POs addressed to a seller (bodega)."""
cur = master_conn.cursor()
cur.execute("""
SELECT po.id_po, po.status, po.total_amount, po.currency,
po.buyer_tenant_id, po.buyer_display_name, po.buyer_phone,
po.created_at, po.submitted_at,
(SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count
FROM purchase_orders po
WHERE po.bodega_id = %s AND po.status != 'draft'
ORDER BY
CASE po.status
WHEN 'submitted' THEN 1
WHEN 'confirmed' THEN 2
WHEN 'ready' THEN 3
ELSE 4
END,
po.submitted_at DESC
LIMIT %s
""", (bodega_id, limit))
rows = cur.fetchall()
cur.close()
return [
{
'id_po': r[0], 'status': r[1],
'total_amount': float(r[2]) if r[2] is not None else 0.0,
'currency': r[3],
'buyer_tenant_id': r[4], 'buyer_display_name': r[5], 'buyer_phone': r[6],
'created_at': r[7].isoformat() if r[7] else None,
'submitted_at': r[8].isoformat() if r[8] else None,
'item_count': r[9],
}
for r in rows
]
# ═══════════════════════════════════════════════════════════════════════════
# NOTIFICATIONS — WhatsApp + Email
# ═══════════════════════════════════════════════════════════════════════════
# Per-status message templates. Each is a (subject, body) tuple.
# The body is plain text — same text goes to WA and email, with an optional
# HTML wrapper for email.
_PO_MESSAGE_TEMPLATES = {
'submitted': (
'Nuevo pedido Nexus #{po_id}',
'Tienes un nuevo pedido en Nexus Marketplace.\n\n'
'Pedido: #{po_id}\n'
'Comprador: {buyer_display_name}\n'
'Total: ${total_amount:,.2f} {currency}\n'
'Items: {item_count}\n\n'
'Entra al marketplace para confirmar o rechazar.'
),
'confirmed': (
'Pedido #{po_id} confirmado por {bodega_name}',
'Tu pedido fue confirmado.\n\n'
'Pedido: #{po_id}\n'
'Bodega: {bodega_name}\n'
'Total: ${total_amount:,.2f} {currency}\n\n'
'Te avisaremos cuando este listo para recoger / entregar.'
),
'rejected': (
'Pedido #{po_id} rechazado',
'Tu pedido fue rechazado por {bodega_name}.\n\n'
'Pedido: #{po_id}\n'
'Puedes intentar con otra bodega en el marketplace.'
),
'ready': (
'Pedido #{po_id} listo',
'Tu pedido esta listo.\n\n'
'Pedido: #{po_id}\n'
'Bodega: {bodega_name}\n'
'Metodo: {delivery_method}\n\n'
'Pasa a recogerlo o espera la entrega.'
),
'delivered': (
'Pedido #{po_id} entregado',
'El pedido #{po_id} fue marcado como entregado.\n'
'Gracias por usar Nexus Marketplace.'
),
'closed': (
'Pedido #{po_id} cerrado',
'El pedido #{po_id} fue cerrado.'
),
}
def notify_po_status_change(master_conn, po_id: int, new_status: str) -> list[str]:
"""Send WhatsApp + email notification about a PO status change.
Returns a list of channel names that were successfully notified
(e.g. ['whatsapp', 'email']). Failures are logged but not raised.
"""
template = _PO_MESSAGE_TEMPLATES.get(new_status)
if not template:
return [] # no message defined for this status
po = get_po_detail(master_conn, po_id)
if not po:
return []
# Resolve context variables for the template
ctx = {
'po_id': po_id,
'buyer_display_name': po.get('buyer_display_name') or 'Cliente',
'bodega_name': po.get('bodega_name') or 'Bodega',
'total_amount': po.get('total_amount') or 0,
'currency': po.get('currency') or 'MXN',
'delivery_method': po.get('delivery_method') or 'pickup',
'item_count': len(po.get('items') or []),
}
subject_tpl, body_tpl = template
try:
subject = subject_tpl.format(**ctx)
body = body_tpl.format(**ctx)
except (KeyError, ValueError) as e:
print(f'[marketplace] template format error for {new_status}: {e}')
return []
# Decide the recipient based on who should be notified for this status
# - submitted → notify seller (new order arrived)
# - confirmed/rejected/ready → notify buyer (status update)
# - delivered → notify both (handled as 2 sends)
# - closed → notify buyer
recipients = []
if new_status == 'submitted':
recipients = [{
'kind': 'seller',
'phone': po.get('bodega_phone'),
'email': po.get('bodega_email'),
}]
elif new_status in ('confirmed', 'rejected', 'ready', 'closed'):
recipients = [{
'kind': 'buyer',
'phone': po.get('buyer_phone'),
'email': po.get('buyer_email'),
}]
elif new_status == 'delivered':
recipients = [
{'kind': 'buyer', 'phone': po.get('buyer_phone'), 'email': po.get('buyer_email')},
{'kind': 'seller', 'phone': po.get('bodega_phone'), 'email': po.get('bodega_email')},
]
channels_used = []
for recipient in recipients:
# WhatsApp
if recipient.get('phone'):
try:
from services import whatsapp_service
result = whatsapp_service.send_message(recipient['phone'], body)
if result and not result.get('error'):
channels_used.append(f"whatsapp:{recipient['kind']}")
except Exception as e:
print(f'[marketplace] WA send failed: {e}')
# Email
if recipient.get('email'):
try:
sent = _send_email(recipient['email'], subject, body)
if sent:
channels_used.append(f"email:{recipient['kind']}")
except Exception as e:
print(f'[marketplace] email send failed: {e}')
return channels_used
def _send_email(to_email: str, subject: str, body_text: str) -> bool:
"""Send a plain-text email via SMTP (config in pos/config.py).
Returns True if the mail was actually sent, False if SMTP is not
configured (silent no-op so dev environments don't crash).
"""
import config
if not config.SMTP_USER or not config.SMTP_PASS:
print('[marketplace] SMTP not configured — skipping email')
return False
msg = MIMEMultipart('alternative')
msg['From'] = config.SMTP_FROM
msg['To'] = to_email
msg['Subject'] = subject
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as server:
server.starttls()
server.login(config.SMTP_USER, config.SMTP_PASS)
server.send_message(msg)
print(f'[marketplace] email sent to {to_email}: {subject}')
return True

View File

@@ -0,0 +1,745 @@
"""
Nexpart Taxonomy — Universal parts classification used in Local catalog mode.
Source of truth: /home/Autopartes/CapturasWeb/nexpart_hierarchy.txt
Total: 14 Groups → 103 Subgroups → 558 Part Types
This module loads the Nexpart hierarchy from the .txt file and provides
helpers to:
1. List all groups / subgroups / part types
2. Map a TecDoc `parts.name_part` value to (group, subgroup, part_type)
3. Translate any node name to Spanish using the existing translations.py
Business decisions (locked in by user 2026-04-08):
1. AMBIGUITY: first match wins (the order in nexpart_hierarchy.txt is
Nexpart's own canonical order, so the first match is also Nexpart's
primary classification).
2. UNMAPPED: drop. Parts without a clean Nexpart match do NOT appear in
Local mode. Local mode is intentionally smaller and more consistent.
3. LANGUAGE: bilingual via translations.py — single source of truth.
The hierarchy is stored in English; the UI translates each node
on-the-fly using `translate_taxonomy_node()`.
"""
import os
import re
from typing import Optional
# ============================================================================
# CONSTANTS
# ============================================================================
UNMAPPED_STRATEGY = "drop"
LANGUAGE_STRATEGY = "bilingual_taxonomy"
# Path to the source-of-truth hierarchy text file
_HIERARCHY_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "..", "CapturasWeb", "nexpart_hierarchy.txt"
)
# ============================================================================
# HIERARCHY PARSER
# ============================================================================
# The list of valid groups, in canonical order (matches Nexpart's own order
# from the screenshots). Used to disambiguate "is this line a group header?"
# from "is this line a subgroup name?" — both can be capitalized.
_KNOWN_GROUPS = (
"IGNITION & FILTERS",
"BELTS, HOSES, WATER PUMPS & COOLING SYSTEM PARTS",
"STARTING & CHARGING SYSTEM PARTS (ALTERNATORS, BATTERIES & CABLES)",
"BRAKE SYSTEM, WHEEL BEARINGS, STUDS, NUTS & HARDWARE",
"FUEL & EMISSIONS PARTS",
"HEATING & AIR CONDITIONING",
"ENGINE PARTS",
"DRIVETRAIN PARTS",
"STEERING & SUSPENSION PARTS",
"EXHAUST, CLUTCH & FLYWHEEL PARTS",
"WIPERS, LAMPS & FUSES",
"BODY PARTS, CABLES, CAPS, ELECTRICAL MOTORS, SWITCHES & OTHER MISCELLANEOUS PARTS",
"CHEMICALS, WAXES & LUBRICANTS",
"TIRES, WHEELS, TOOLS & ACCESSORY PARTS",
)
def _parse_hierarchy_file() -> dict:
"""Parse nexpart_hierarchy.txt into a nested dict.
Returns:
{
"Ignition & Filters": {
"Computers & Relays": ["Engine Control Module (ECM)", ...],
...
},
...
}
"""
taxonomy = {}
current_group = None
current_subgroup = None
if not os.path.exists(_HIERARCHY_PATH):
return taxonomy
with open(_HIERARCHY_PATH, "r", encoding="utf-8") as f:
for line in f:
line = line.rstrip("\n")
# Skip comments, blank lines, and decoration rules
if not line or line.startswith("#"):
continue
if set(line.strip()) <= {"", " "}:
continue
if line.strip() == "SUMMARY":
break # End-of-file marker
# Group header: ALL CAPS line that matches a known group
if line.strip().upper() in _KNOWN_GROUPS:
# Convert to title case for display, preserving the original
# casing from the .txt file (which already mixes Title Case)
current_group = line.strip().title() \
.replace("Ac ", "AC ") \
.replace("Pcv", "PCV") \
.replace("Ecm", "ECM") \
.replace("Cv ", "CV ") \
.replace("Vvt", "VVT") \
.replace("Tpms", "TPMS") \
.replace("Hvac", "HVAC") \
.replace("Abs ", "ABS ") \
.replace("Egr", "EGR")
taxonomy.setdefault(current_group, {})
current_subgroup = None
continue
# Part type: lines with leading " - "
if line.lstrip().startswith("- "):
if current_group and current_subgroup:
pt = line.lstrip()[2:].strip()
taxonomy[current_group][current_subgroup].append(pt)
continue
# Subgroup: a non-empty line that's not a comment, not a header,
# not a part type, and starts with a non-space character.
if line[0] not in (" ", "\t"):
current_subgroup = line.strip()
if current_group:
taxonomy[current_group].setdefault(current_subgroup, [])
return taxonomy
# Load at import time
NEXPART_TAXONOMY = _parse_hierarchy_file()
# ============================================================================
# FLAT INDEX FOR FAST LOOKUP
# ============================================================================
# Building these once at import time means O(1) lookups during requests.
def _build_indexes():
"""Build flat lookup tables from the nested taxonomy."""
# part_type_lower → list of (group, subgroup, original_part_type)
# We use lowercase keys so the matcher is case-insensitive.
part_type_index = {}
all_part_types = [] # ordered list, in canonical Nexpart order
for group, subgroups in NEXPART_TAXONOMY.items():
for subgroup, part_types in subgroups.items():
for pt in part_types:
key = pt.strip().lower()
part_type_index.setdefault(key, []).append((group, subgroup, pt))
all_part_types.append((group, subgroup, pt))
return part_type_index, all_part_types
_PART_TYPE_INDEX, _ALL_PART_TYPES = _build_indexes()
# ============================================================================
# DECISION 1 — RESOLVE AMBIGUITY (first-match wins)
# ============================================================================
# Manual overrides for ambiguous part names. Key = lowercase TecDoc name
# (as fed to the matcher). Value = the subgroup WHERE the part should
# canonically live when a mechanic thinks about it.
#
# These beat the first-match rule. Add entries when you see that your users
# expect a part in a different subgroup than the one Nexpart's canonical
# order picks. Leave empty at start — grow incrementally from feedback.
#
# Example: a Mexican mechanic troubleshooting a failed emissions test will
# look for an O2 sensor under "Catalytic Converter" (system-level thinking),
# not "Emission Sensors, Relays, Solenoids & Switches" (component-level).
AMBIGUITY_OVERRIDES = {
# tecdoc name (lowercase) -> preferred subgroup name (exact string)
# (populated as real usage surfaces mismatches)
# 'oxygen sensor': 'Catalytic Converter',
}
def resolve_ambiguous_subgroup(tecdoc_name: str, candidates: list) -> tuple:
"""Pick the canonical (group, subgroup, part_type) for an ambiguous name.
Resolution order:
1. AMBIGUITY_OVERRIDES dict — manual curation wins over everything.
2. First-match in canonical Nexpart order (Decision 1 locked in).
Search by the user still finds the part from anywhere via the flat
index; the override only affects which subgroup the part "lives in"
during hierarchical navigation.
Args:
tecdoc_name: e.g. "Oxygen Sensor"
candidates: list of (group, subgroup, part_type) tuples
Returns:
A single (group, subgroup, part_type) tuple.
"""
# 1. Manual override wins
key = (tecdoc_name or '').strip().lower()
preferred_subgroup = AMBIGUITY_OVERRIDES.get(key)
if preferred_subgroup:
for cand in candidates:
if cand[1] == preferred_subgroup:
return cand
# Override pointed to a subgroup not in the candidate set —
# log and fall through to first-match.
# (Using print to stay import-free; swap for logger if available.)
print(f"[taxonomy] AMBIGUITY_OVERRIDES['{key}'] = '{preferred_subgroup}' "
f"not in candidates {[c[1] for c in candidates]}; falling back")
# 2. First-match in canonical order
return candidates[0]
# ============================================================================
# DECISION 2 — UNMAPPED HANDLING (drop)
# ============================================================================
# When a TecDoc name doesn't match any Nexpart Part Type, the matcher
# returns None and the caller filters it out of Local mode results.
# ============================================================================
# CORE MATCHER: tecdoc_to_nexpart()
# ============================================================================
def tecdoc_to_nexpart(tecdoc_name: str) -> Optional[tuple]:
"""Map a TecDoc part name to its Nexpart (group, subgroup, part_type).
Matching strategy (in order of preference):
1. Exact match (case-insensitive) on the full Part Type name.
2. Substring match — TecDoc name CONTAINS a known Part Type.
Example: "Front Brake Pad Set" contains "Brake Pad Set" → match.
3. Reverse substring — known Part Type contains the TecDoc name.
Example: TecDoc "Wiper" matches Nexpart "Wiper Arm". Less precise,
used as last resort.
Args:
tecdoc_name: value from `parts.name_part` (English)
Returns:
(group, subgroup, part_type) if matched, None otherwise.
Per Decision 2, callers should filter out None values.
"""
if not tecdoc_name:
return None
name_lower = tecdoc_name.strip().lower()
if not name_lower:
return None
# 1. Exact match
if name_lower in _PART_TYPE_INDEX:
candidates = _PART_TYPE_INDEX[name_lower]
return resolve_ambiguous_subgroup(tecdoc_name, candidates)
# 2. Substring match (TecDoc contains Nexpart Part Type)
# Prefer the LONGEST match — more specific wins on a tie of position.
best_match = None
best_len = 0
for pt_key, candidates in _PART_TYPE_INDEX.items():
if pt_key in name_lower and len(pt_key) > best_len:
best_match = candidates
best_len = len(pt_key)
if best_match:
return resolve_ambiguous_subgroup(tecdoc_name, best_match)
# 3. Reverse substring (Nexpart Part Type contains TecDoc) — last resort
for pt_key, candidates in _PART_TYPE_INDEX.items():
if name_lower in pt_key and len(name_lower) >= 4:
# Min length 4 to avoid false matches on short words like "Cap"
return resolve_ambiguous_subgroup(tecdoc_name, candidates)
return None
# ============================================================================
# DECISION 3 — BILINGUAL VIA translations.py
# ============================================================================
# Curated translations for the 14 top-level groups + common subgroups.
# These are full-string (not substring) so they always win over the partial
# matcher in translations.py and produce clean Spanish display.
TAXONOMY_OVERRIDES_ES = {
# ─── Top-level groups (14) ───
"Ignition & Filters": "Encendido y Filtros",
"Belts, Hoses, Water Pumps & Cooling System Parts": "Bandas, Mangueras, Bombas de Agua y Sistema de Enfriamiento",
"Starting & Charging System Parts (Alternators, Batteries & Cables)": "Sistema de Arranque y Carga (Alternadores, Baterías y Cables)",
"Brake System, Wheel Bearings, Studs, Nuts & Hardware": "Sistema de Frenos, Baleros, Birlos, Tuercas y Ferretería",
"Fuel & Emissions Parts": "Combustible y Emisiones",
"Heating & Air Conditioning": "Calefacción y Aire Acondicionado",
"Engine Parts": "Partes de Motor",
"Drivetrain Parts": "Tren Motriz",
"Steering & Suspension Parts": "Dirección y Suspensión",
"Exhaust, Clutch & Flywheel Parts": "Escape, Clutch y Volante",
"Wipers, Lamps & Fuses": "Limpiaparabrisas, Luces y Fusibles",
"Body Parts, Cables, Caps, Electrical Motors, Switches & Other Miscellaneous Parts": "Carrocería, Cables, Tapones, Motores Eléctricos, Switches y Misceláneos",
"Chemicals, Waxes & Lubricants": "Químicos, Ceras y Lubricantes",
"Tires, Wheels, Tools & Accessory Parts": "Llantas, Rines, Herramientas y Accesorios",
# ─── Common subgroups (the most-used ones; expand as needed) ───
"Filters & PCV": "Filtros y PCV",
"Spark Plugs & Glow Plugs": "Bujías",
"Tune-Up & Ignition Parts": "Afinación y Encendido",
"Belts, Tensioners & Pulleys": "Bandas, Tensores y Poleas",
"Radiators & Electric Fan Motors": "Radiadores y Motoventiladores",
"Thermostats, Housings & Radiator Caps": "Termostatos, Carcasas y Tapones de Radiador",
"Water Pumps, Fan Blades & Clutches": "Bombas de Agua, Aspas y Fan Clutches",
"Alternators & Voltage Regulators": "Alternadores y Reguladores de Voltaje",
"Batteries": "Baterías",
"Starters": "Marchas / Arrancadores",
"ABS Controls & Parts": "Controles y Partes de ABS",
"Front Friction, Drums & Rotors": "Frenos Delanteros: Pastillas, Tambores y Discos",
"Rear Friction, Drums & Rotors": "Frenos Traseros: Pastillas, Tambores y Discos",
"Front Wheel Bearings & Seals": "Baleros y Sellos de Rueda Delantera",
"Rear Wheel Bearings & Seals": "Baleros y Sellos de Rueda Trasera",
"Master Cylinders, Boosters & Switches": "Cilindros Maestros, Boosters y Switches",
"Fuel Pumps & Tanks": "Bombas y Tanques de Gasolina",
"Fuel Injection Parts, Mass Air Flow Sensors": "Inyección, Sensores MAF",
"Turbochargers & Superchargers": "Turbos y Compresores",
"AC Compressors, Kits & Parts": "Compresores de A/C y Kits",
"AC Condensers & Evaporators": "Condensadores y Evaporadores de A/C",
"Cams, Lifters & Timing Parts": "Árboles de Levas, Buzos y Distribución",
"Crankshafts & Bearings": "Cigüeñales y Metales",
"Pistons, Rings & Rods": "Pistones, Anillos y Bielas",
"Heads & Manifolds": "Cabezas y Múltiples",
"Engine Mounts & Other Miscellaneous Engine Parts": "Soportes de Motor y Otros",
"Driveshafts, U-Joints & CV (Constant Velocity) Parts": "Flechas, Crucetas y Juntas Homocinéticas",
"Automatic Transmission Seals": "Sellos de Transmisión Automática",
"Manual Transmission Seals": "Sellos de Transmisión Manual",
"Transmission & Parts": "Transmisión y Partes",
"Ball Joints & Control Arms": "Rótulas y Horquillas",
"Shock Absorbers & Struts": "Amortiguadores y Strut",
"Steering Linkages, Rods & Arms": "Direcciones, Bieletas y Brazos",
"Sway Bars, Stabilizer Bars, Strut Rods & Parts": "Barras Estabilizadoras y Tornillos",
"All Exhaust & Diagrams": "Sistema de Escape Completo",
"Catalytic Converter": "Convertidor Catalítico",
"Clutches & Clutch Kits": "Clutches y Kits",
"Manifolds & Headers": "Múltiples y Headers",
"Arms, Blades & Refills": "Brazos, Plumas y Repuestos",
"Headlamps & Flashers": "Faros y Direccionales",
"Exterior Lamps": "Luces Exteriores",
"Interior Lamps": "Luces Interiores",
"Wiper Motors & Washer Pumps": "Motores de Limpia y Bombas de Agua",
"Bumpers & License Plates": "Defensas y Placas",
"Door, Window & Tailgate Parts": "Puertas, Ventanas y Cajuela",
"Engine & Transmission Lubricants & Additives": "Aceites de Motor y Transmisión",
"Tires & Wheels": "Llantas y Rines",
"Tools, Jacks, Hardware & Manuals": "Herramientas, Gatos y Hardware",
# ─── Remaining subgroups (phase 2 translation coverage) ───
"Computers & Relays": "Computadoras y Relés",
"Ignition Wires": "Cables de Bujía",
"Miscellaneous Ignition Parts": "Conectores y Misceláneos de Encendido",
"Engine Coolant & Bypass Hoses": "Mangueras de Refrigerante y Bypass",
"Heater & Other Hoses": "Mangueras de Calefacción y Otras",
"Sensors, Switches & Relays": "Sensores, Switches y Relés",
"Starter Solenoids, Switches & Relays": "Solenoides de Marcha, Switches y Relés",
"Brake Cables, Studs, Nuts & Spindle Nuts": "Cables, Birlos y Tuercas de Freno",
"Front Brake Hardware & ABS Sensors": "Ferretería y Sensores ABS Delanteros",
"Front Calipers, Wheel Cylinders, Hoses": "Calipers, Cilindros y Mangueras Delanteras",
"Miscellaneous Disc Hardware": "Ferretería Misceláneo de Disco",
"Miscellaneous Drum Hardware": "Ferretería Misceláneo de Tambor",
"Miscellaneous Hydraulic Parts & Brake Specifications": "Hidráulica y Especificaciones de Freno",
"Rear Brake Hardware & ABS Sensors": "Ferretería y Sensores ABS Traseros",
"Rear Calipers, Wheel Cylinders, Hoses": "Calipers, Cilindros y Mangueras Traseras",
"Carburetors, Carburetor Kits & Components": "Carburadores, Kits y Componentes",
"EGR & Emissions Valves": "EGR y Válvulas de Emisiones",
"Emission Sensors, Relays, Solenoids & Switches": "Sensores de Emisiones, Relés, Solenoides y Switches",
"Fuel Injection Harnesses, Connectors & Miscellaneous Parts": "Arneses, Conectores e Inyección Misceláneos",
"Fuel Injection Sensors, Relays & Switches": "Sensores, Relés y Switches de Inyección",
"AC Accumulators, Receiver Driers & Valves": "Acumuladores, Secadores y Válvulas de A/C",
"AC Hose Assemblies & Fittings": "Mangueras y Conexiones de A/C",
"AC Relays & Switches": "Relés y Switches de A/C",
"AC, Heating & Ventilation Gaskets, O-Rings, Kits, Doors & Actuators": "Juntas, O-Rings, Puertas y Actuadores A/C",
"Blower Motors & Parts": "Motores de Ventilador y Partes",
"Heater Cores & Heater Control Valves": "Radiadores de Calefacción y Válvulas",
"Engine Block Parts": "Partes de Bloque de Motor",
"Engines & Kits": "Motores y Kits",
"Gasket Sets": "Juegos de Juntas",
"Individual Gaskets & Seals": "Juntas y Sellos Individuales",
"Intake & Exhaust Valves": "Válvulas de Admisión y Escape",
"Rockers & Push Rods": "Balancines y Varillas de Empuje",
"Vacuum & Oil Pumps": "Bombas de Vacío y Aceite",
"Axle & Differential Parts": "Partes de Eje y Diferencial",
"Electronics, Sensors, Relays & Miscellaneous Parts": "Electrónica, Sensores y Misceláneos",
"Manual Transmission Bearings": "Baleros de Transmisión Manual",
"Spindles & Hubs": "Husillos y Mazas",
"Transmission Kits & Gaskets": "Kits y Juntas de Transmisión",
"Alignment Kits & Tools": "Kits y Herramientas de Alineación",
"King Pins, Trailing Arms, Alignment & Other Chassis": "Pivotes, Brazos y Otros de Chasis",
"Power Steering Pumps, Hoses & Kits": "Bombas, Mangueras y Kits de Dirección Hidráulica",
"Rack & Pinion, Gear Box, Power Cylinder": "Cremallera, Caja de Dirección y Cilindro",
"Clutch Hydraulics": "Hidráulica de Clutch",
"Individual Exhaust Parts": "Partes de Escape Individuales",
"Miscellaneous Clutch Parts": "Partes Misceláneas de Clutch",
"Lighting Modules & Switches": "Módulos y Switches de Iluminación",
"Lighting Relays & Sensors": "Relés y Sensores de Luces",
"Caps": "Tapones",
"Cruise Control Parts": "Partes de Control de Crucero",
"Electrical Motors": "Motores Eléctricos",
"Glass": "Cristales",
"Hood & Tailgate Parts": "Partes de Cofre y Cajuela",
"Hoods Fenders & Body Parts": "Cofres, Salpicaderas y Carrocería",
"Lift Supports": "Amortiguadores de Cofre/Cajuela",
"Switches, Relays & Miscellaneous Parts": "Switches, Relés y Misceláneos",
"Wheel & Hardware": "Rines y Ferretería",
"Bumper & License Plate": "Defensas y Placas",
"Electronics Audio/Visual & Mirrors": "Electrónica, Audio y Espejos",
"Hood, Fender & Body Parts": "Cofre, Salpicaderas y Carrocería",
"Interior & Steering Wheel": "Interior y Volante",
# ─── High-value part types (most-searched in real use) ───
# Ignition & Filters
"Engine Control Module (ECM)": "Módulo de Control del Motor (ECM)",
"Ignition Relay": "Relé de Encendido",
"Transmission Control Module": "Módulo de Control de Transmisión",
"Engine Air Filter": "Filtro de Aire del Motor",
"Engine Oil Filter": "Filtro de Aceite del Motor",
"Engine Oil Filter Adapter": "Adaptador de Filtro de Aceite",
"Engine Oil Filter Housing": "Carcasa de Filtro de Aceite",
"Vapor Canister": "Canister de Vapor",
"Vapor Canister Purge Valve": "Válvula de Purga del Canister",
"Vapor Canister Purge Solenoid": "Solenoide de Purga del Canister",
"Spark Plug Set": "Juego de Bujías",
"Direct Ignition Coil": "Bobina de Encendido Directo",
"Ignition Coil": "Bobina de Encendido",
"Ignition Kit": "Kit de Encendido",
# Belts / Cooling
"Engine Timing Belt": "Banda de Distribución",
"Engine Timing Belt Component Kit": "Kit de Componentes de Distribución",
"Engine Timing Belt Kit with Water Pump": "Kit de Distribución con Bomba de Agua",
"Engine Timing Chain": "Cadena de Distribución",
"Engine Timing Chain Guide": "Guía de Cadena de Distribución",
"Engine Timing Chain Tensioner": "Tensor de Cadena de Distribución",
"Accessory Drive Belt Tensioner Assembly": "Tensor de Banda Accesoria",
"Accessory Drive Belt Tensioner Pulley": "Polea Tensora de Banda Accesoria",
"Serpentine Belt": "Banda Serpentina",
"Radiator": "Radiador",
"Radiator Coolant Hose": "Manguera de Refrigerante del Radiador",
"Engine Coolant Reservoir": "Depósito de Refrigerante",
"Engine Water Pump": "Bomba de Agua del Motor",
"Engine Water Pump Gasket": "Junta de Bomba de Agua",
"Engine Water Pump Pulley": "Polea de Bomba de Agua",
"Engine Coolant Thermostat": "Termostato de Refrigerante",
"Engine Coolant Thermostat Housing": "Carcasa de Termostato",
"Engine Coolant Temperature Sensor": "Sensor de Temperatura de Refrigerante",
"Engine Cooling Fan": "Ventilador de Enfriamiento",
"Engine Cooling Fan Assembly": "Conjunto de Ventilador de Enfriamiento",
"HVAC Heater Hose": "Manguera de Calefacción HVAC",
# Starting & Charging
"Alternator": "Alternador",
"Vehicle Battery": "Batería del Vehículo",
"Starter": "Marcha / Arrancador",
"Ignition Lock Cylinder": "Switch de Encendido (Cilindro)",
"Ignition Switch": "Switch de Encendido",
# Brake System
"ABS Wheel Speed Sensor": "Sensor de Velocidad de Rueda ABS",
"Front Disc Brake Pad Set": "Juego de Pastillas Delanteras",
"Rear Disc Brake Pad Set": "Juego de Pastillas Traseras",
"Front Disc Brake Rotor": "Disco de Freno Delantero",
"Rear Disc Brake Rotor": "Disco de Freno Trasero",
"Front Disc Brake Caliper": "Caliper de Freno Delantero",
"Rear Disc Brake Caliper": "Caliper de Freno Trasero",
"Front Brake Hydraulic Hose": "Manguera Hidráulica Delantera",
"Rear Brake Hydraulic Hose": "Manguera Hidráulica Trasera",
"Brake Master Cylinder": "Cilindro Maestro de Frenos",
"Power Brake Booster": "Booster de Frenos",
"Front Wheel Bearing": "Balero de Rueda Delantera",
"Rear Wheel Bearing": "Balero de Rueda Trasera",
"Front Wheel Bearing and Hub Assembly": "Balero y Maza Delantera",
"Rear Wheel Bearing and Hub Assembly": "Balero y Maza Trasera",
"Wheel Lug Nut": "Tuerca de Rueda (Birlo)",
"Wheel Lug Stud": "Birlo de Rueda",
# Fuel & Emissions
"Electric Fuel Pump": "Bomba Eléctrica de Gasolina",
"Fuel Pump Module Assembly": "Conjunto de Módulo de Bomba de Gasolina",
"Fuel Level Sensor": "Sensor de Nivel de Gasolina",
"Fuel Tank Cap": "Tapón de Tanque de Gasolina",
"Fuel Injector": "Inyector de Gasolina",
"Fuel Injector Set": "Juego de Inyectores",
"Fuel Injection Throttle Body": "Cuerpo de Aceleración",
"Mass Air Flow Sensor": "Sensor MAF (Flujo de Aire)",
"Oxygen Sensor": "Sensor de Oxígeno",
"Engine Camshaft Position Sensor": "Sensor de Posición de Árbol de Levas",
"Engine Crankshaft Position Sensor": "Sensor de Posición del Cigüeñal",
"Engine Knock Sensor": "Sensor de Detonación",
"Manifold Absolute Pressure Sensor": "Sensor MAP (Presión Absoluta)",
"Turbocharger": "Turbocargador",
# Heating & AC
"A/C Compressor": "Compresor de A/C",
"A/C Condenser": "Condensador de A/C",
"A/C Evaporator Core": "Evaporador de A/C",
"A/C Expansion Valve": "Válvula de Expansión de A/C",
"A/C Receiver Drier/Desiccant Element": "Filtro Deshidratador de A/C",
"A/C Hose Assembly": "Manguera de A/C",
"HVAC Blower Motor": "Motor de Ventilador HVAC",
"HVAC Blower Motor Resistor": "Resistencia de Ventilador HVAC",
"HVAC Heater Core": "Radiador de Calefacción",
"HVAC Blend Door Actuator": "Actuador de Puerta de Mezcla",
# Engine Parts
"Engine Camshaft": "Árbol de Levas",
"Engine Harmonic Balancer": "Damper / Polea del Cigüeñal",
"Engine Crankshaft Main Bearing Set": "Juego de Metales de Bancada",
"Engine Piston": "Pistón",
"Engine Piston Ring Set": "Juego de Anillos de Pistón",
"Engine Connecting Rod Bearing Set": "Juego de Metales de Biela",
"Engine Cylinder Head Gasket": "Junta de Cabeza de Cilindros",
"Engine Cylinder Head Bolt Set": "Juego de Tornillos de Cabeza",
"Engine Intake Manifold": "Múltiple de Admisión",
"Engine Intake Manifold Gasket": "Junta de Múltiple de Admisión",
"Engine Valve Cover": "Tapa de Válvulas",
"Engine Valve Cover Gasket": "Junta de Tapa de Válvulas",
"Engine Oil Pan": "Cárter de Aceite",
"Engine Oil Pan Gasket": "Junta de Cárter",
"Engine Oil Pump": "Bomba de Aceite",
"Engine Oil Pressure Sender": "Sensor de Presión de Aceite",
"Engine Oil Pressure Switch": "Switch de Presión de Aceite",
"Engine Mount": "Soporte de Motor",
"Engine Rocker Arm": "Balancín",
"Engine Exhaust Valve": "Válvula de Escape",
"Engine Intake Valve": "Válvula de Admisión",
"Engine Valve Spring": "Resorte de Válvula",
"Engine Valve Stem Oil Seal": "Sello de Válvula",
# Drivetrain
"CV Axle Assembly": "Flecha Homocinética Completa",
"CV Axle Shaft": "Flecha Homocinética",
"Automatic Transmission Mount": "Soporte de Transmisión Automática",
"Automatic Transmission Oil Cooler": "Enfriador de Aceite de Transmisión",
"Automatic Transmission Oil Pan": "Cárter de Transmisión Automática",
"Manual Transmission Mount": "Soporte de Transmisión Manual",
"Transmission Filter Kit": "Kit de Filtro de Transmisión",
"Transmission Oil Pan": "Cárter de Transmisión",
"Spindle Nut": "Tuerca de Husillo",
"Vehicle Speed Sensor": "Sensor de Velocidad del Vehículo",
# Steering & Suspension
"Suspension Ball Joint": "Rótula de Suspensión",
"Suspension Control Arm Bushing": "Buje de Horquilla",
"Suspension Control Arm and Ball Joint Assembly": "Horquilla con Rótula",
"Suspension Shock Absorber": "Amortiguador",
"Suspension Strut": "Strut de Suspensión",
"Suspension Strut Assembly": "Conjunto de Strut",
"Suspension Strut Mount": "Base de Strut",
"Suspension Stabilizer Bar Link": "Terminal de Barra Estabilizadora",
"Steering Tie Rod End": "Terminal de Dirección",
"Rack and Pinion Assembly": "Cremallera de Dirección",
"Steering Column": "Columna de Dirección",
# Exhaust/Clutch
"Catalytic Converter": "Convertidor Catalítico",
"Catalytic Converter Gasket": "Junta de Convertidor Catalítico",
"Exhaust Manifold": "Múltiple de Escape",
"Exhaust Manifold Gasket": "Junta de Múltiple de Escape",
"Exhaust Muffler": "Mofle",
"Exhaust Muffler Assembly": "Conjunto de Mofle",
"Exhaust Pipe": "Tubo de Escape",
"Exhaust Clamp": "Abrazadera de Escape",
"Clutch Slave Cylinder": "Cilindro Esclavo de Clutch",
"Transmission Clutch Kit": "Kit de Clutch",
# Wipers/Lamps
"Wiper Arm": "Brazo de Limpiaparabrisas",
"Wiper Blade": "Pluma Limpiaparabrisas",
"Wiper Motor": "Motor de Limpiaparabrisas",
"Wiper Switch": "Switch de Limpiaparabrisas",
"Headlight Bulb": "Foco de Faro",
"Tail Light Bulb": "Foco de Calavera",
"Brake Light Bulb": "Foco de Freno",
"Turn Signal Light Bulb": "Foco Direccional",
"Fog Light Bulb": "Foco Antiniebla",
"Back Up Light Bulb": "Foco de Reversa",
"License Plate Light Bulb": "Foco de Placa",
"Dome Light Bulb": "Foco de Domo",
"Washer Fluid Reservoir Cap": "Tapón de Depósito de Limpiaparabrisas",
"Headlight Switch": "Switch de Luces",
"Turn Signal Switch": "Switch de Direccionales",
"Multi-Function Switch": "Switch Multifunciones",
"Hazard Warning Switch": "Switch de Intermitentes",
# Body / Electrical / Misc
"Door Lock Actuator": "Actuador de Cerradura",
"Door Lock Actuator Motor": "Motor de Actuador de Cerradura",
"Window Motor": "Motor de Ventana",
"Window Regulator": "Elevador de Ventana",
"Window Motor and Regulator Assembly": "Motor y Elevador de Ventana",
"Sunroof Motor": "Motor de Quemacocos",
"Exterior Door Handle": "Manija Exterior de Puerta",
"Interior Door Handle": "Manija Interior de Puerta",
"Door Mirror Glass": "Cristal de Espejo",
"Horn Relay": "Relé de Claxon",
"Liftgate Lift Support": "Amortiguador de Cajuela",
"Cruise Control Switch": "Switch de Control de Crucero",
"Engine Coolant Reservoir Cap": "Tapón de Depósito de Refrigerante",
"Engine Oil Filler Cap": "Tapón de Llenado de Aceite",
"Radiator Cap": "Tapón de Radiador",
"TPMS Sensor": "Sensor TPMS",
"TPMS Programmable Sensor": "Sensor TPMS Programable",
# Chemicals / Tools
"Automatic Transmission Fluid": "Aceite de Transmisión Automática",
"Engine Oil": "Aceite de Motor",
}
def translate_taxonomy_node(english_name: str) -> str:
"""Translate a Nexpart group / subgroup / part type to Spanish.
STRICT lookup only — no partial substitution. The order:
1. TAXONOMY_OVERRIDES_ES — full-string curated translations.
2. PART_TRANSLATIONS exact match (from services.translations).
3. Fallback: return the English original UNCHANGED.
Why strict-only: partial substitution within a compound name produces
ugly hybrids ("Front Tambor de Freno", "Engine Filtro de Aceite").
For taxonomy display we'd rather show clean English than dirty Spanish.
Untranslated entries are visible reminders to extend the override dict.
Args:
english_name: the canonical English name (group, subgroup, or part type)
Returns:
Spanish display string, or the English original if no exact match.
"""
if not english_name:
return english_name
# 1. Curated overrides (highest priority)
if english_name in TAXONOMY_OVERRIDES_ES:
return TAXONOMY_OVERRIDES_ES[english_name]
# 2. Exact match in PART_TRANSLATIONS
try:
from services.translations import PART_TRANSLATIONS
if english_name in PART_TRANSLATIONS:
return PART_TRANSLATIONS[english_name]
except ImportError:
pass
# 3. Fallback — return English unchanged
return english_name
def list_untranslated_nodes() -> dict:
"""Diagnostic helper: list every taxonomy node missing a Spanish entry.
Useful for filling in TAXONOMY_OVERRIDES_ES incrementally — run this
in a one-off script to see exactly what still needs translation.
Returns:
{"groups": [...], "subgroups": [...], "part_types": [...]}
"""
try:
from services.translations import PART_TRANSLATIONS
known = set(PART_TRANSLATIONS.keys()) | set(TAXONOMY_OVERRIDES_ES.keys())
except ImportError:
known = set(TAXONOMY_OVERRIDES_ES.keys())
missing = {"groups": [], "subgroups": [], "part_types": []}
for group, subgroups in NEXPART_TAXONOMY.items():
if group not in known:
missing["groups"].append(group)
for subgroup, part_types in subgroups.items():
if subgroup not in known:
missing["subgroups"].append(subgroup)
for pt in part_types:
if pt not in known:
missing["part_types"].append(pt)
return missing
# ============================================================================
# PUBLIC API — used by catalog_service / blueprints
# ============================================================================
def get_groups() -> list:
"""Return the 14 top-level groups in canonical order.
Each item: {"name": english, "name_es": spanish, "subgroup_count": int}
"""
return [
{
"name": group,
"name_es": translate_taxonomy_node(group),
"subgroup_count": len(subgroups),
}
for group, subgroups in NEXPART_TAXONOMY.items()
]
def get_subgroups(group_name: str) -> list:
"""Return all subgroups for a given group.
Each item: {"name": english, "name_es": spanish, "part_type_count": int}
"""
subgroups = NEXPART_TAXONOMY.get(group_name, {})
return [
{
"name": subgroup,
"name_es": translate_taxonomy_node(subgroup),
"part_type_count": len(part_types),
}
for subgroup, part_types in subgroups.items()
]
def get_part_types(group_name: str, subgroup_name: str) -> list:
"""Return all part types within a group + subgroup.
Each item: {"name": english, "name_es": spanish}
"""
subgroups = NEXPART_TAXONOMY.get(group_name, {})
part_types = subgroups.get(subgroup_name, [])
return [
{
"name": pt,
"name_es": translate_taxonomy_node(pt),
}
for pt in part_types
]
def stats() -> dict:
"""Return totals — useful for healthcheck and debugging."""
total_subgroups = sum(len(sg) for sg in NEXPART_TAXONOMY.values())
total_part_types = sum(
len(pts)
for sg in NEXPART_TAXONOMY.values()
for pts in sg.values()
)
return {
"groups": len(NEXPART_TAXONOMY),
"subgroups": total_subgroups,
"part_types": total_part_types,
"indexed_keys": len(_PART_TYPE_INDEX),
}

View File

@@ -0,0 +1,240 @@
"""
Peer-to-peer inventory service for multi-instance Nexus deployments.
Each Nexus instance is autonomous (own DB, own POS) but can see inventory
from other instances on the network. The marketplace fans out to all peers
and merges results so users see stock from the whole Nexus network.
Architecture:
- peers.json: config file listing known peer instances (name + URL)
- /pos/api/peer/inventory: public endpoint each instance exposes (no auth)
- search_all_peers(): fan-out query to all enabled peers + local DB
For the demo (LAN), peers are static IPs in peers.json.
For production (clients on own networks), this will evolve into a central
hub model where each instance reports to a cloud server.
"""
import json
import os
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
# ─── Config ──────────────────────────────────────────────────────────────
_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'peers.json')
_config_cache = None
def _load_config():
"""Load peers.json, cached in memory after first read."""
global _config_cache
if _config_cache is not None:
return _config_cache
try:
with open(_CONFIG_PATH, 'r') as f:
_config_cache = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f'[peer] Warning: could not load {_CONFIG_PATH}: {e}')
_config_cache = {'instance_name': 'Unknown', 'peers': [], 'peer_timeout_seconds': 3}
return _config_cache
def reload_config():
"""Force-reload peers.json (call after editing the file)."""
global _config_cache
_config_cache = None
return _load_config()
def get_instance_name() -> str:
return _load_config().get('instance_name', 'Unknown')
def get_instance_id() -> str:
return _load_config().get('instance_id', 'unknown')
def get_peers() -> list[dict]:
"""Return list of enabled peers: [{name, url, enabled}]"""
cfg = _load_config()
return [p for p in cfg.get('peers', []) if p.get('enabled', True)]
def get_timeout() -> int:
return _load_config().get('peer_timeout_seconds', 3)
# ─── Local inventory query (what WE expose to peers) ─────────────────────
def get_local_inventory(tenant_conn, query: str = None, limit: int = 50) -> list[dict]:
"""Query this instance's inventory for the peer endpoint.
Returns parts WITH stock > 0, with enough detail for the marketplace
to render results (part number, name, brand, price, stock hint).
No exact stock numbers — just 'En stock' (per business decision).
"""
cur = tenant_conn.cursor()
# Build WHERE clause
clauses = ["COALESCE(s.stock, 0) > 0", "i.is_active = TRUE"]
params = []
if query:
clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s)")
like = f'%{query}%'
params.extend([like, like, like])
where = " AND ".join(clauses)
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.brand, i.price_1,
COALESCE(s.stock, 0) AS stock,
i.unit, i.catalog_part_id
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 {where}
ORDER BY i.name
LIMIT %s
""", params + [limit])
rows = cur.fetchall()
cur.close()
return [
{
'id': r[0],
'part_number': r[1],
'name': r[2],
'brand': r[3] or '',
'price': float(r[4]) if r[4] else None,
'stock_hint': 'En stock' if r[5] > 0 else 'Agotado',
'unit': r[6] or 'PZA',
'catalog_part_id': r[7],
}
for r in rows
]
# ─── Peer fan-out query ──────────────────────────────────────────────────
def _query_one_peer(peer: dict, query: str, limit: int) -> dict:
"""Send a search request to one peer instance. Returns results or error."""
url = peer['url'].rstrip('/') + '/pos/api/peer/inventory'
params = {'limit': limit}
if query:
params['q'] = query
try:
resp = requests.get(url, params=params, timeout=get_timeout())
if resp.status_code == 200:
data = resp.json()
# Tag each result with the source instance name
items = data.get('data', [])
for item in items:
item['source_instance'] = peer['name']
item['source_url'] = peer['url']
return {'ok': True, 'name': peer['name'], 'data': items}
else:
return {'ok': False, 'name': peer['name'], 'error': f'HTTP {resp.status_code}'}
except requests.exceptions.Timeout:
return {'ok': False, 'name': peer['name'], 'error': 'timeout'}
except requests.exceptions.ConnectionError:
return {'ok': False, 'name': peer['name'], 'error': 'offline'}
except Exception as e:
return {'ok': False, 'name': peer['name'], 'error': str(e)[:100]}
def search_all_peers(tenant_conn, query: str = None, limit: int = 50) -> dict:
"""Search local inventory + all enabled peers in parallel.
Returns:
{
"local": { "name": "...", "data": [...] },
"peers": [
{"name": "Refac B", "data": [...], "ok": True},
{"name": "Refac C", "data": [...], "ok": True},
...
],
"merged": [...], # all results combined, local first
"total": N,
"errors": [...] # peers that failed
}
"""
peers = get_peers()
# Local results
local_data = get_local_inventory(tenant_conn, query=query, limit=limit)
for item in local_data:
item['source_instance'] = get_instance_name()
item['source_url'] = 'local'
# Fan-out to peers in parallel
peer_results = []
errors = []
if peers:
with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor:
futures = {
executor.submit(_query_one_peer, p, query, limit): p
for p in peers
}
for future in as_completed(futures):
result = future.result()
if result['ok']:
peer_results.append(result)
else:
errors.append(result)
print(f'[peer] {result["name"]}: {result["error"]}')
# Merge: local first, then peers (sorted by name within each source)
merged = list(local_data)
for pr in peer_results:
merged.extend(pr.get('data', []))
return {
'local': {
'name': get_instance_name(),
'data': local_data,
'count': len(local_data),
},
'peers': peer_results,
'merged': merged,
'total': len(merged),
'errors': errors,
}
# ─── Health check for the peer network ───────────────────────────────────
def check_peer_health() -> list[dict]:
"""Ping all peers and return status. Useful for the admin dashboard."""
peers = get_peers()
results = []
def _ping(peer):
try:
url = peer['url'].rstrip('/') + '/pos/api/peer/health'
resp = requests.get(url, timeout=get_timeout())
if resp.status_code == 200:
data = resp.json()
return {
'name': peer['name'],
'url': peer['url'],
'status': 'online',
'instance_name': data.get('instance_name'),
'inventory_count': data.get('inventory_count'),
}
return {'name': peer['name'], 'url': peer['url'], 'status': f'error:{resp.status_code}'}
except Exception as e:
return {'name': peer['name'], 'url': peer['url'], 'status': f'offline:{str(e)[:50]}'}
if peers:
with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor:
results = list(executor.map(_ping, peers))
return results

View File

@@ -105,6 +105,93 @@ def generate_ticket(sale_data, business_info, width=80):
return bytes(buf)
def generate_quotation_ticket(quote_data, business_info, width=80):
"""Generate ESC/POS bytes for a quotation ticket.
Args:
quote_data: dict with keys: id, items[{name, part_number, quantity, unit_price, subtotal}],
subtotal, tax_total, total, valid_until, customer_name, wa_phone, created_at
business_info: dict with name, rfc, address
width: 58 or 80 (mm)
Returns: bytes ready to send to printer
"""
chars = 32 if width == 58 else 48
buf = bytearray()
buf += INIT
# Header
buf += ALIGN_CENTER
buf += LARGE_SIZE
buf += (business_info.get('name', 'NEXUS POS') + '\n').encode('cp437', errors='replace')
buf += NORMAL_SIZE
if business_info.get('rfc'):
buf += (business_info['rfc'] + '\n').encode('cp437', errors='replace')
if business_info.get('address'):
buf += (business_info['address'] + '\n').encode('cp437', errors='replace')
buf += b'\n'
# Title
buf += BOLD_ON + DOUBLE_HEIGHT
buf += 'COTIZACION\n'.encode('cp437', errors='replace')
buf += NORMAL_SIZE + BOLD_OFF
buf += b'\n'
# Folio + date
buf += ALIGN_LEFT
buf += BOLD_ON
buf += f'Folio: COT-{quote_data.get("id", "")}\n'.encode('cp437', errors='replace')
buf += BOLD_OFF
buf += f'Fecha: {str(quote_data.get("created_at", ""))[:10]}\n'.encode('cp437', errors='replace')
buf += f'Vigencia: {quote_data.get("valid_until", "7 dias")}\n'.encode('cp437', errors='replace')
if quote_data.get('customer_name'):
buf += f'Cliente: {quote_data["customer_name"]}\n'.encode('cp437', errors='replace')
if quote_data.get('wa_phone'):
buf += f'WhatsApp: {quote_data["wa_phone"]}\n'.encode('cp437', errors='replace')
buf += ('-' * chars + '\n').encode()
# Column header
buf += BOLD_ON
hdr = _format_line('Cant Descripcion', 'Importe', chars)
buf += (hdr + '\n').encode('cp437', errors='replace')
buf += BOLD_OFF
buf += ('-' * chars + '\n').encode()
# Items
for item in quote_data.get('items', []):
name = item.get('name', '')[:chars - 10]
part_no = item.get('part_number', '')
qty = item.get('quantity', 1)
subtotal = item.get('subtotal', 0)
buf += f'{qty}x {name}\n'.encode('cp437', errors='replace')
if part_no:
buf += f' #{part_no}\n'.encode('cp437', errors='replace')
buf += ALIGN_RIGHT
buf += f'${subtotal:,.2f}\n'.encode('cp437', errors='replace')
buf += ALIGN_LEFT
buf += ('-' * chars + '\n').encode()
# Totals
buf += ALIGN_RIGHT
buf += _total_line('Subtotal:', quote_data.get('subtotal', 0), chars).encode('cp437', errors='replace')
buf += _total_line('IVA:', quote_data.get('tax_total', 0), chars).encode('cp437', errors='replace')
buf += BOLD_ON + DOUBLE_HEIGHT
buf += _total_line('TOTAL:', quote_data.get('total', 0), chars).encode('cp437', errors='replace')
buf += NORMAL_SIZE + BOLD_OFF
# Footer
buf += b'\n'
buf += ALIGN_CENTER
buf += 'Esta cotizacion no es comprobante fiscal\n'.encode('cp437', errors='replace')
buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace')
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
buf += b'\n\n\n'
buf += PARTIAL_CUT
return bytes(buf)
def _format_line(left, right, width):
"""Pad a left-right line to fill the ticket width."""
space = width - len(left) - len(right)

View File

@@ -14,8 +14,20 @@ def decode_vin(vin):
return {"error": "VIN debe tener exactamente 17 caracteres alfanumericos (sin I, O, Q)."}
url = f"https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/{vin}?format=json"
resp = requests.get(url, timeout=10)
resp.raise_for_status()
# NHTSA's free API can be slow (5-30s). Retry once on timeout.
import time
for attempt in range(2):
try:
resp = requests.get(url, timeout=25)
resp.raise_for_status()
break
except requests.exceptions.Timeout:
if attempt == 0:
time.sleep(2)
continue
return {"error": "El servidor NHTSA no respondio. Intenta de nuevo en unos segundos."}
except requests.exceptions.RequestException as e:
return {"error": f"Error de conexion con NHTSA: {str(e)[:100]}"}
data = resp.json()["Results"][0]
error_text = data.get("ErrorText", "") or ""

View File

@@ -0,0 +1,284 @@
"""
WhatsApp Quotation Service — conversational quote builder.
Tracks per-phone "open quotations" so a customer can ask about multiple
parts over several messages and receive a single formatted quotation at
the end.
Flow:
1. Customer asks about a part → bot shows local inventory match
2. Customer says "cotizar" / "agregar" → last-shown part added to quote
3. Repeat for more parts
4. Customer says "enviar cotización" / "listo" → formatted quote sent
5. Customer says "limpiar" / "nueva cotización" → quote cleared
The quotation is stored in the tenant's existing `quotations` +
`quotation_items` tables so it also appears in the POS quotation list.
"""
import re
from datetime import date, timedelta
# ─── Intent detection ────────────────────────────────────────────────
# Commands the customer can type (case-insensitive, accent-insensitive)
# NOTE: "si" is NOT here — it's handled as 'confirm' to avoid ambiguity
# with "si" after a quotation was sent (which means "confirm order").
_ADD_PATTERNS = re.compile(
r'^(cotizar|agregar|agregalo|agrega|añadir|quiero ese|ese mero|'
r'dame ese|lo quiero|me lo apartas|si.?cotiza)$',
re.IGNORECASE
)
_SEND_PATTERNS = re.compile(
r'^(enviar cotizaci[oó]n|listo|enviar|mandar cotizaci[oó]n|ya es todo|'
r'eso es todo|mandame la cotizaci[oó]n|terminé|termine|ver cotizaci[oó]n|'
r'mi cotizaci[oó]n|total|cuanto es)$',
re.IGNORECASE
)
_CLEAR_PATTERNS = re.compile(
r'^(limpiar|nueva cotizaci[oó]n|borrar cotizaci[oó]n|empezar de nuevo|cancelar cotizaci[oó]n)$',
re.IGNORECASE
)
# "si", "va", "confirmo" — confirm the quotation (close it as accepted)
_CONFIRM_PATTERNS = re.compile(
r'^(si|sí|va|confirmo|confirmar|acepto|de acuerdo|ok|okay|dale)$',
re.IGNORECASE
)
_QTY_PATTERN = re.compile(
r'^(cotizar|agregar|dame|quiero)\s+(\d+)$',
re.IGNORECASE
)
def detect_quote_intent(text, has_open_quote=False):
"""Detect if the message is a quotation command.
Args:
text: the user's message
has_open_quote: True if this phone has an active quotation
Returns:
('add', quantity) — add last part to quote
('send', None) — send the full quotation
('clear', None) — clear the quotation
('confirm', None) — confirm/accept the quotation
(None, None) — not a quote command, pass to AI
"""
if not text:
return None, None
t = text.strip()
# Check for quantity: "cotizar 3", "agregar 5"
qty_match = _QTY_PATTERN.match(t)
if qty_match:
return 'add', int(qty_match.group(2))
if _ADD_PATTERNS.match(t):
return 'add', 1
if _SEND_PATTERNS.match(t):
return 'send', None
if _CLEAR_PATTERNS.match(t):
return 'clear', None
# "si" / "va" / "confirmo" — only counts as 'confirm' when there's
# an open quote. Otherwise pass to the AI as normal conversation.
if has_open_quote and _CONFIRM_PATTERNS.match(t):
return 'confirm', None
return None, None
def confirm_quotation(tenant_conn, phone):
"""Mark the open quotation as confirmed/accepted."""
qid = get_open_quotation(tenant_conn, phone)
if not qid:
return None
cur = tenant_conn.cursor()
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
clear_last_shown(phone)
return qid
# ─── In-memory last-shown-part per phone ─────────────────────────────
# Tracks what part the bot last showed so "cotizar" knows what to add.
# Key: phone (clean, no @lid). Value: dict with inventory item info.
_last_shown = {}
def set_last_shown_part(phone, part_info):
"""Store the last part shown to this phone number.
part_info: dict with keys inventory_id, part_number, name, brand,
price, stock, unit
"""
_last_shown[phone] = part_info
def get_last_shown_part(phone):
return _last_shown.get(phone)
def clear_last_shown(phone):
_last_shown.pop(phone, None)
# ─── Quotation CRUD ─────────────────────────────────────────────────
def get_open_quotation(tenant_conn, phone):
"""Find an active quotation for this phone, or None."""
cur = tenant_conn.cursor()
cur.execute("""
SELECT id FROM quotations
WHERE notes LIKE %s AND status = 'active'
ORDER BY created_at DESC LIMIT 1
""", (f'%WA:{phone}%',))
row = cur.fetchone()
cur.close()
return row[0] if row else None
def create_quotation(tenant_conn, phone):
"""Create a new quotation tagged with this phone number."""
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO quotations (subtotal, tax_total, total, status, notes, valid_until)
VALUES (0, 0, 0, 'active', %s, %s)
RETURNING id
""", (f'WA:{phone}', date.today() + timedelta(days=7)))
qid = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
return qid
def add_item_to_quotation(tenant_conn, quote_id, part_info, quantity=1):
"""Add a part to an existing quotation and recalculate totals."""
price = float(part_info.get('price') or 0)
tax_rate = float(part_info.get('tax_rate') or 0.16)
subtotal = round(price * quantity, 2)
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO quotation_items
(quotation_id, inventory_id, part_number, name, quantity, unit_price, tax_rate, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
quote_id,
part_info.get('inventory_id'),
part_info.get('part_number', ''),
part_info.get('name', ''),
quantity,
price,
tax_rate,
subtotal,
))
# Recalculate totals
cur.execute("""
SELECT COALESCE(SUM(subtotal), 0),
COALESCE(SUM(subtotal * tax_rate), 0)
FROM quotation_items WHERE quotation_id = %s
""", (quote_id,))
sub, tax = cur.fetchone()
cur.execute("""
UPDATE quotations SET subtotal = %s, tax_total = %s, total = %s
WHERE id = %s
""", (sub, tax, round(sub + tax, 2), quote_id))
tenant_conn.commit()
cur.close()
return subtotal
def get_quotation_detail(tenant_conn, quote_id):
"""Return full quotation with items."""
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, subtotal, tax_total, total, status, valid_until, created_at
FROM quotations WHERE id = %s
""", (quote_id,))
q = cur.fetchone()
if not q:
cur.close()
return None
cur.execute("""
SELECT part_number, name, quantity, unit_price, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (quote_id,))
items = cur.fetchall()
cur.close()
return {
'id': q[0],
'subtotal': float(q[1]),
'tax_total': float(q[2]),
'total': float(q[3]),
'status': q[4],
'valid_until': str(q[5]) if q[5] else None,
'created_at': str(q[6]) if q[6] else None,
'items': [{
'part_number': it[0],
'name': it[1],
'quantity': it[2],
'unit_price': float(it[3]),
'tax_rate': float(it[4]),
'subtotal': float(it[5]),
} for it in items],
}
def clear_quotation(tenant_conn, phone):
"""Cancel the open quotation for this phone."""
qid = get_open_quotation(tenant_conn, phone)
if qid:
cur = tenant_conn.cursor()
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
clear_last_shown(phone)
return qid
# ─── Format quotation for WhatsApp ──────────────────────────────────
def format_quotation_wa(detail):
"""Format a quotation as a WhatsApp-friendly text message."""
if not detail or not detail.get('items'):
return None
lines = [
f'📄 *COTIZACIÓN #{detail["id"]}*',
f'Fecha: {detail["created_at"][:10] if detail.get("created_at") else "hoy"}',
f'Vigencia: {detail.get("valid_until") or "7 días"}',
'',
'─────────────────────',
]
for i, item in enumerate(detail['items'], 1):
qty = item['quantity']
price = item['unit_price']
sub = item['subtotal']
lines.append(f'{i}. {item["name"]}')
lines.append(f' #{item["part_number"]} × {qty} = ${sub:,.2f}')
lines.append('─────────────────────')
lines.append(f' Subtotal: ${detail["subtotal"]:,.2f}')
lines.append(f' IVA: ${detail["tax_total"]:,.2f}')
lines.append(f' *TOTAL: ${detail["total"]:,.2f}*')
lines.append('')
lines.append('_Responde "si" para confirmar el pedido._')
lines.append('_Responde "limpiar" para empezar de nuevo._')
return '\n'.join(lines)

View File

@@ -55,12 +55,63 @@ def logout():
def process_incoming(webhook_data):
"""Extract a normalized dict from a Baileys webhook payload.
Supports text messages, image messages, audio (voice notes), and video.
Media content comes pre-downloaded as base64 from the bridge so Python
doesn't have to re-authenticate with WhatsApp servers.
Returns:
dict with keys:
phone — numeric phone (no JID suffix)
jid — full remote JID (may be @s.whatsapp.net or @lid)
text — text content (plain text or media caption)
from_me — bool, True if we sent the message
message_id — WhatsApp message ID
media_kind — 'text' | 'image' | 'audio' | 'video'
media_base64 — base64 string if media, else None
media_mimetype — e.g. 'image/jpeg', 'audio/ogg'
is_voice_note — True for WhatsApp voice notes (audioMessage ptt)
"""
data = webhook_data.get('data', {})
key = data.get('key', {})
message = data.get('message', {})
# remoteJid can be phone@s.whatsapp.net or LID@lid
remote_jid = key.get('remoteJid', '')
phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '')
# The bridge now classifies and passes these extra fields. Fall back to
# the old parsing if they're missing (older bridge version).
media_kind = data.get('media_kind', 'text')
media_base64 = data.get('media_base64')
media_mimetype = data.get('media_mimetype')
media_caption = data.get('media_caption') or ''
is_voice_note = bool(data.get('media_ptt'))
push_name = data.get('push_name') or ''
# Text content:
# - For 'text' messages → conversation or extendedTextMessage
# - For 'image'/'video' → the caption (may be empty)
# - For 'audio' → empty (filled in later by Whisper transcription)
if media_kind == 'text':
text = (
message.get('conversation', '')
or message.get('extendedTextMessage', {}).get('text', '')
or ''
)
else:
text = media_caption
return {
'phone': key.get('remoteJid', '').replace('@s.whatsapp.net', ''),
'text': message.get('conversation', '') or message.get('extendedTextMessage', {}).get('text', ''),
'phone': phone,
'jid': remote_jid,
'text': text,
'from_me': key.get('fromMe', False),
'message_id': key.get('id', ''),
'media_kind': media_kind,
'media_base64': media_base64,
'media_mimetype': media_mimetype,
'is_voice_note': is_voice_note,
'push_name': push_name,
}

View File

@@ -0,0 +1,151 @@
"""
Local Whisper transcription service.
Uses faster-whisper (a CTranslate2-based port of OpenAI Whisper) for
transcribing short audio clips (WhatsApp voice notes) on the CPU.
Runs fully offline after the first model download. No API keys, no
per-minute cost. Model is lazy-loaded on first call and cached in memory
for the lifetime of the process.
Default model: 'tiny' — the smallest and fastest variant (~75 MB), good
enough for conversational Spanish. Change WHISPER_MODEL below to 'base'
(150 MB, slightly better accuracy) or 'small' (500 MB, noticeably better)
if you have the RAM and don't mind 2-3x slower inference.
"""
import base64 as _b64
import os
import subprocess
import tempfile
import threading
# ─── Config ──────────────────────────────────────────────────────────────
# 'base' is the sweet spot for Mexican Spanish voice notes on CPU:
# tiny (75 MB) — too small, misses words in noisy/robot audio
# base (150 MB) — good accuracy, ~2s per 30s clip on a modern CPU ← default
# small (500 MB) — best accuracy, ~5s per 30s clip, worth it if RAM permits
WHISPER_MODEL = "base"
WHISPER_DEVICE = "cpu"
WHISPER_COMPUTE = "int8" # int8 quantization — CPU-friendly, minimal quality loss
# ─── Lazy singleton model loader ─────────────────────────────────────────
_model = None
_model_lock = threading.Lock()
def _get_model():
"""Load the Whisper model on first use. Thread-safe."""
global _model
if _model is not None:
return _model
with _model_lock:
if _model is not None:
return _model
from faster_whisper import WhisperModel
print(f"[whisper] Loading {WHISPER_MODEL} model ({WHISPER_DEVICE}, {WHISPER_COMPUTE})...")
_model = WhisperModel(
WHISPER_MODEL,
device=WHISPER_DEVICE,
compute_type=WHISPER_COMPUTE,
)
print("[whisper] Model ready.")
return _model
# ─── Public API ──────────────────────────────────────────────────────────
def transcribe_audio_base64(audio_base64: str, mimetype: str = "audio/ogg",
language: str = "es") -> str | None:
"""Transcribe a base64-encoded audio blob to text.
Args:
audio_base64: Raw base64 string (no data: prefix).
mimetype: MIME type from the sender (e.g. 'audio/ogg' for WA voice notes).
language: ISO 639-1 code to bias the model. 'es' for Spanish MX.
Returns:
The transcribed text, or None if transcription fails or is empty.
"""
if not audio_base64:
return None
# Decode base64 → write to a temp file with the right extension so
# ffmpeg (invoked by faster-whisper/CTranslate2) picks the decoder.
ext = _extension_for_mimetype(mimetype)
try:
audio_bytes = _b64.b64decode(audio_base64)
except Exception as e:
print(f"[whisper] base64 decode failed: {e}")
return None
tmp_in = None
tmp_wav = None
try:
# Write the original audio to a temp file
tmp_in = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
tmp_in.write(audio_bytes)
tmp_in.close()
# WhatsApp voice notes are OGG/Opus — faster-whisper can handle it
# via its pyav decoder, but converting to 16kHz mono WAV first is
# more reliable across formats and ~2x faster.
tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp_wav.close()
rc = subprocess.run(
["ffmpeg", "-y", "-i", tmp_in.name,
"-ar", "16000", "-ac", "1",
"-f", "wav", tmp_wav.name],
capture_output=True,
)
if rc.returncode != 0:
print(f"[whisper] ffmpeg conversion failed: {rc.stderr.decode()[:200]}")
return None
# Run Whisper
# - beam_size=5 for better accuracy on short/noisy clips
# - no VAD filter (was trimming real speech in some tests)
# - condition_on_previous_text=False for short independent clips
model = _get_model()
segments, info = model.transcribe(
tmp_wav.name,
language=language,
beam_size=5,
vad_filter=False,
condition_on_previous_text=False,
)
text = " ".join(s.text.strip() for s in segments if s.text.strip())
text = text.strip()
if not text:
return None
print(f"[whisper] ({info.language}, {info.duration:.1f}s) → {text[:100]}")
return text
except Exception as e:
print(f"[whisper] transcription error: {e}")
return None
finally:
for f in (tmp_in, tmp_wav):
if f:
try:
os.unlink(f.name)
except Exception:
pass
def _extension_for_mimetype(mimetype: str) -> str:
"""Map a MIME type to a file extension ffmpeg understands."""
m = (mimetype or "").lower()
if "opus" in m or "ogg" in m:
return ".ogg"
if "mp3" in m or "mpeg" in m:
return ".mp3"
if "mp4" in m or "aac" in m:
return ".m4a"
if "wav" in m:
return ".wav"
if "webm" in m:
return ".webm"
return ".ogg" # WhatsApp voice notes are usually OGG/Opus

View File

@@ -0,0 +1,683 @@
/* ==========================================================================
POS-GLASS.CSS — Pixel-Perfect glassmorphism overlay for Nexus POS
Load AFTER tokens.css. Applies glass effects, glow, 3D buttons,
and animations to all POS pages without modifying inline styles.
========================================================================== */
/* ── Hidden scrollbar (global) ── */
html { scrollbar-width: none; }
html::-webkit-scrollbar { width: 0; }
/* ── Smooth font rendering ── */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ==========================================================================
SIDEBAR — Glass treatment
========================================================================== */
.sidebar,
.pos-sidebar {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border) !important;
}
.sidebar__logo {
position: relative;
}
.sidebar__logo-text {
position: relative;
}
/* Glow under logo text */
.sidebar__logo-text::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
right: 0;
height: 2px;
background: var(--gradient-accent);
border-radius: 1px;
opacity: 0.4;
filter: blur(2px);
}
/* Nav items — hover glow */
.sidebar__nav a,
.sidebar__nav-item,
.sidebar .nav-item {
transition: all 0.25s var(--ease-out) !important;
border-radius: var(--radius-md);
}
.sidebar__nav a:hover,
.sidebar__nav-item:hover,
.sidebar .nav-item:hover {
box-shadow: 0 0 12px var(--glow-color-soft);
}
.sidebar__nav a.active,
.sidebar__nav-item.active,
.sidebar .nav-item.active {
box-shadow: 0 0 16px var(--glow-color-soft), inset 0 0 0 1px var(--glass-border);
}
/* ==========================================================================
THEME BAR — Glass
========================================================================== */
.theme-bar {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--glass-border) !important;
}
/* ==========================================================================
CARDS — Glass with glow hover
========================================================================== */
.kpi-card,
.table-card,
.card,
.stat-card,
.chart-card,
.alert-card,
.config-card,
.fleet-card,
.report-card,
.invoice-card,
.customer-card,
.panel {
background: var(--glass-bg) !important;
backdrop-filter: blur(var(--glass-blur));
-webkit-backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--glass-border) !important;
transition: all 0.3s var(--ease-out) !important;
position: relative;
overflow: hidden;
}
/* Accent top-line on hover */
.kpi-card::before,
.table-card::before,
.chart-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: var(--gradient-accent);
transform: scaleX(0);
transform-origin: left;
transition: transform 0.4s var(--ease-out);
z-index: 1;
}
.kpi-card:hover::before,
.table-card:hover::before,
.chart-card:hover::before {
transform: scaleX(1);
}
.kpi-card:hover,
.table-card:hover,
.card:hover,
.stat-card:hover,
.chart-card:hover,
.config-card:hover,
.fleet-card:hover,
.report-card:hover {
border-color: var(--color-border-accent) !important;
box-shadow: 0 4px 20px var(--glow-color-soft);
}
/* KPI card accent bar — add glow */
.kpi-card__accent-bar {
box-shadow: 0 0 8px var(--glow-color-soft);
}
/* ==========================================================================
BUTTONS — 3D depth effect
========================================================================== */
/* Primary buttons */
.btn--primary,
button.primary,
.btn-primary,
input[type="submit"],
button[type="submit"] {
background: var(--gradient-accent) !important;
border: none !important;
box-shadow: 0 3px 0 var(--color-primary-active),
0 4px 10px var(--glow-color-soft) !important;
transition: all 0.25s var(--ease-out) !important;
position: relative;
overflow: hidden;
}
.btn--primary:hover,
button.primary:hover,
.btn-primary:hover,
input[type="submit"]:hover,
button[type="submit"]:hover {
transform: translateY(-1px);
box-shadow: 0 4px 0 var(--color-primary-active),
0 8px 20px var(--glow-color) !important;
}
.btn--primary:active,
button.primary:active,
.btn-primary:active,
input[type="submit"]:active,
button[type="submit"]:active {
transform: translateY(1px);
box-shadow: 0 1px 0 var(--color-primary-active) !important;
}
/* Ghost / secondary buttons — glass */
.btn--ghost,
.btn--secondary,
.btn-secondary,
.btn-ghost,
button.secondary {
background: var(--glass-bg) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid var(--glass-border) !important;
transition: all 0.25s var(--ease-out) !important;
}
.btn--ghost:hover,
.btn--secondary:hover,
.btn-secondary:hover,
.btn-ghost:hover,
button.secondary:hover {
border-color: var(--color-border-accent) !important;
box-shadow: 0 0 16px var(--glow-color-soft);
}
/* ==========================================================================
INPUTS — Glass with focus glow
========================================================================== */
input[type="text"],
input[type="number"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="tel"],
input[type="date"],
input[type="url"],
textarea,
select,
.search-input,
.filter-input {
background: var(--glass-bg) !important;
border: 1px solid var(--glass-border) !important;
transition: all 0.25s var(--ease-out) !important;
}
input[type="text"]:focus,
input[type="number"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="search"]:focus,
input[type="tel"]:focus,
input[type="date"]:focus,
input[type="url"]:focus,
textarea:focus,
select:focus,
.search-input:focus,
.filter-input:focus {
border-color: var(--color-border-focus) !important;
box-shadow: 0 0 0 3px var(--glow-color-soft), 0 0 16px var(--glow-color-soft) !important;
outline: none;
}
/* ==========================================================================
TABLES — Subtle glass rows
========================================================================== */
table thead th {
background: var(--glass-bg) !important;
backdrop-filter: blur(8px);
font-family: var(--font-mono);
font-size: var(--text-caption);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
}
table tbody tr {
transition: all 0.2s ease !important;
}
table tbody tr:hover {
background: var(--glass-highlight) !important;
box-shadow: inset 0 0 0 1px var(--glass-border);
}
/* ==========================================================================
MODALS — Glass overlay + glass content
========================================================================== */
.modal-overlay,
.overlay,
.modal-backdrop {
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.modal,
.modal-content,
.modal-dialog,
.dialog {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border) !important;
box-shadow: 0 24px 48px rgba(0,0,0,0.3) !important;
}
/* ==========================================================================
TABS — Glass active state
========================================================================== */
.tab,
.tab-btn,
.tabs button {
transition: all 0.25s var(--ease-out) !important;
border-radius: var(--radius-md);
}
.tab.active,
.tab-btn.active,
.tabs button.active {
background: var(--color-primary-muted) !important;
box-shadow: 0 0 12px var(--glow-color-soft);
border-color: var(--color-border-accent) !important;
}
/* ==========================================================================
BADGES / TAGS — Subtle glow
========================================================================== */
.badge,
.tag,
.status-badge,
.pill {
backdrop-filter: blur(4px);
transition: all 0.2s ease;
}
/* ==========================================================================
SCROLL REVEAL — Available for any POS page that wants it
========================================================================== */
.nx-reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
}
.nx-reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
.nx-stagger > .nx-reveal:nth-child(1) { transition-delay: 0ms; }
.nx-stagger > .nx-reveal:nth-child(2) { transition-delay: 80ms; }
.nx-stagger > .nx-reveal:nth-child(3) { transition-delay: 160ms; }
.nx-stagger > .nx-reveal:nth-child(4) { transition-delay: 240ms; }
.nx-stagger > .nx-reveal:nth-child(5) { transition-delay: 320ms; }
.nx-stagger > .nx-reveal:nth-child(6) { transition-delay: 400ms; }
/* ==========================================================================
TOAST / NOTIFICATIONS — Glass
========================================================================== */
.toast,
.notification,
.snackbar,
.alert {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border) !important;
}
/* ==========================================================================
DROPDOWN / POPOVER — Glass
========================================================================== */
.dropdown-menu,
.popover,
.autocomplete-list,
.suggestion-list {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border) !important;
box-shadow: 0 8px 32px rgba(0,0,0,0.2) !important;
}
/* ==========================================================================
STATUS BAR (POS) — Glass
========================================================================== */
.status-bar,
.pos-status-bar {
background: var(--glass-bg-strong) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--glass-border) !important;
}
/* ==========================================================================
LOADING SPINNER — Glow animation
========================================================================== */
.spinner,
.loading-spinner {
animation: nx-glow-pulse 1.5s ease-in-out infinite;
}
/* ==========================================================================
ANIMATIONS — Available keyframes
========================================================================== */
@keyframes pos-fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* Apply subtle entry animation to main content area */
.content,
.main-content,
main {
animation: pos-fade-in 0.4s var(--ease-out) both;
}
/* ==========================================================================
DASHED BORDER ACCENTS (Pixel-Perfect style)
========================================================================== */
.section-divider,
hr {
border: none;
border-top: 1px dashed var(--glass-border);
margin: var(--space-4) 0;
}
/* ==========================================================================
TABLET RESPONSIVE — Adaptive layout for 768px-1024px screens
Applied globally to all POS pages via pos-glass.css.
Targets iPad (768×1024), Android tablets (800×1280), and similar.
========================================================================== */
/* ── Tablet portrait (768-1023px) — sidebar collapses, grids reflow ── */
@media (max-width: 1023px) {
/* Sidebar collapses to an overlay drawer */
.sidebar,
.pos-sidebar {
position: fixed !important;
top: 0 !important;
left: 0 !important;
bottom: 0 !important;
z-index: var(--z-modal) !important;
transform: translateX(-100%) !important;
transition: transform 0.3s var(--ease-out) !important;
width: 260px !important;
}
.sidebar.open,
.pos-sidebar.open {
transform: translateX(0) !important;
box-shadow: 0 0 40px rgba(0,0,0,0.3) !important;
}
.sidebar-overlay {
display: none !important;
position: fixed !important;
inset: 0 !important;
z-index: calc(var(--z-modal) - 1) !important;
background: rgba(0,0,0,0.5) !important;
}
.sidebar-overlay.open {
display: block !important;
}
/* App shell: full width when sidebar is hidden */
.app-shell {
flex-direction: column !important;
}
.app-shell > main,
.app-shell > .main-content,
.app-shell > .content,
.main-content,
.content {
margin-left: 0 !important;
width: 100% !important;
}
/* Show hamburger button */
.hamburger-btn {
display: flex !important;
}
/* Touch-friendly targets — minimum 44px tap area */
button,
.btn,
.nav-card,
.tab-btn,
.tab,
.part-card,
.search-result-item,
table tbody tr,
.kpi-card {
min-height: 44px;
}
/* Larger text for readability on tablets */
.kpi-card__value {
font-size: 1.5rem !important;
}
/* Grid reflow: 2 columns instead of 3-4 */
.kpi-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.nav-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
/* Tables: horizontal scroll wrapper on narrow screens */
.table-wrap,
.table-card {
overflow-x: auto !important;
-webkit-overflow-scrolling: touch;
}
/* POS-specific: if the POS has a side panel (cart), stack vertically */
.pos-layout {
flex-direction: column !important;
}
.pos-layout .pos-cart,
.pos-layout .cart-panel {
width: 100% !important;
max-width: 100% !important;
height: auto !important;
max-height: 40vh !important;
}
/* Content headers: tighter padding */
.content-header,
.header,
.page-header {
padding: var(--space-3) var(--space-4) !important;
}
/* Search bar: full width */
.search-bar,
.search-wrapper {
width: 100% !important;
max-width: 100% !important;
}
/* Mode toggle: slightly larger buttons for touch */
.mode-toggle button {
padding: 6px 14px !important;
font-size: 12px !important;
}
/* Vehicle selector dropdowns: stack on smaller tablets */
.vehicle-selector__inner,
.vehicle-selector .vs-group {
flex-wrap: wrap !important;
}
.vehicle-selector .vs-arrow {
display: none !important;
}
.vehicle-selector .vs-select {
min-width: 130px !important;
}
}
/* ── Phone portrait (< 768px) — single column, max simplification ── */
@media (max-width: 767px) {
.sidebar {
width: 85vw !important;
max-width: 300px !important;
}
.kpi-grid,
.nav-grid,
.results-grid {
grid-template-columns: 1fr !important;
}
.kpi-card__value {
font-size: 1.3rem !important;
}
/* Stack the mode toggle buttons vertically if tight */
.mode-toggle {
flex-wrap: wrap !important;
}
/* Hide non-essential UI to save space */
.header__store-badge,
.vs-vin-divider {
display: none !important;
}
/* Full-width modals */
.modal-content {
max-width: 95vw !important;
margin: var(--space-3) !important;
padding: var(--space-4) !important;
}
/* Tables: force readable font size */
table {
font-size: 12px !important;
}
table th,
table td {
padding: var(--space-2) var(--space-2) !important;
}
}
/* ── Landscape tablet (height < 600px with wide screen) ── */
@media (max-height: 600px) and (min-width: 768px) {
/* Reduce vertical padding for landscape tablet use */
.kpi-grid {
gap: var(--space-2) !important;
}
.dashboard,
.main-content,
.content {
padding: var(--space-3) !important;
}
}
/* ── Touch device hints ── */
@media (hover: none) and (pointer: coarse) {
/* Remove hover-only effects on touch devices — they cause sticky states */
.kpi-card:hover,
.nav-card:hover,
.part-card:hover,
.table-card:hover,
.card:hover {
transform: none !important;
}
/* Larger touch targets for interactive elements */
.sidebar__nav a,
.sidebar__nav-item,
.sidebar .nav-item {
padding: 12px 16px !important;
min-height: 48px !important;
display: flex !important;
align-items: center !important;
}
/* Scroll momentum on iOS */
.table-wrap,
.main-content,
.content,
.parts-grid,
.nav-grid {
-webkit-overflow-scrolling: touch;
}
/* Disable text selection on buttons (prevents accidental blue highlight on long tap) */
button,
.btn,
.nav-card,
.tab-btn {
-webkit-user-select: none;
user-select: none;
}
}
/* ==========================================================================
PRINT — Disable glass effects for printing
========================================================================== */
@media print {
.sidebar,
.theme-bar,
.kpi-card,
.table-card,
.card,
.modal,
.modal-content,
table thead th,
input,
select,
textarea {
background: #fff !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
border-color: #ccc !important;
color: #000 !important;
}
}

View File

@@ -558,6 +558,69 @@
}
/* ==========================================================================
GLASSMORPHISM TOKENS
========================================================================== */
[data-theme="industrial"] {
--glass-bg: rgba(26, 26, 26, 0.70);
--glass-bg-strong: rgba(26, 26, 26, 0.85);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: 16px;
--glass-highlight: rgba(245, 166, 35, 0.06);
--glow-color: rgba(245, 166, 35, 0.40);
--glow-color-soft: rgba(245, 166, 35, 0.15);
--glow-color-strong: rgba(245, 166, 35, 0.60);
--gradient-accent: linear-gradient(135deg, #F5A623 0%, #e8951a 50%, #d4850f 100%);
--gradient-text: linear-gradient(135deg, #F5A623 0%, #FFD080 50%, #F5A623 100%);
--canvas-grid-color: rgba(255, 255, 255, 0.06);
--canvas-star-color: rgba(245, 166, 35, 0.30);
--canvas-glow-color: rgba(245, 166, 35, 0.08);
}
[data-theme="modern"] {
--glass-bg: rgba(248, 249, 255, 0.70);
--glass-bg-strong: rgba(248, 249, 255, 0.85);
--glass-border: rgba(26, 26, 46, 0.08);
--glass-blur: 16px;
--glass-highlight: rgba(255, 107, 53, 0.04);
--glow-color: rgba(255, 107, 53, 0.35);
--glow-color-soft: rgba(255, 107, 53, 0.12);
--glow-color-strong: rgba(255, 107, 53, 0.55);
--gradient-accent: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FF6B35 100%);
--gradient-text: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #e85520 100%);
--canvas-grid-color: rgba(26, 26, 46, 0.05);
--canvas-star-color: rgba(255, 107, 53, 0.20);
--canvas-glow-color: rgba(255, 107, 53, 0.06);
}
/* ==========================================================================
ANIMATION KEYFRAMES
========================================================================== */
@keyframes nx-fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes nx-glow-pulse {
0%, 100% { box-shadow: 0 0 20px var(--glow-color-soft); }
50% { box-shadow: 0 0 40px var(--glow-color); }
}
@keyframes nx-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* ==========================================================================
END OF TOKENS FILE
nexus-autoparts-design/tokens/tokens.css

View File

@@ -390,7 +390,37 @@ const Accounting = (() => {
// ---- Exportar placeholder ----
function exportarContabilidad() {
alert('Exportar: proximamente');
// Find the first visible table in the active accounting tab and export as CSV
var tables = document.querySelectorAll('table');
var table = null;
for (var i = 0; i < tables.length; i++) {
if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) {
table = tables[i];
break;
}
}
if (!table) {
alert('No hay datos para exportar en la vista actual.');
return;
}
var rows = [];
var ths = table.querySelectorAll('thead th');
if (ths.length) {
rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
}
table.querySelectorAll('tbody tr').forEach(function(tr) {
var cells = tr.querySelectorAll('td');
rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
});
if (rows.length <= 1) { alert('Sin datos para exportar.'); return; }
var csv = rows.join('\n');
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'contabilidad_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
a.click();
URL.revokeObjectURL(url);
}
// ---- Nueva Poliza modal ----

View File

@@ -49,15 +49,77 @@
// ─── Navigation State ───
var nav = {
level: 'brands', // brands|models|years|engines|categories|groups|parts
level: 'brands', // brands|models|years|engines|categories|groups|part_types|parts
brand: null, // {id, name}
model: null, // {id, name}
year: null, // {id, year}
engine: null, // {id_mye, name}
// OEM mode (TecDoc) navigation state — integer IDs
category: null, // {id, name}
group: null, // {id, name}
partType: null, // {slug, name} ← 3rd-level subcategory (Nexpart-style)
// Local mode (Nexpart) navigation state — string slugs.
// These live in parallel with category/group/partType so transitioning
// between modes doesn't trash the other branch's state.
nxGroup: null, // {slug, name} ← top-level Nexpart group (14 total)
nxSubgroup: null, // {slug, name} ← Nexpart subgroup
nxPartType: null, // {slug, name} ← Nexpart part type (3rd level)
};
// ─── Catalog mode (OEM / Local) ───
var catalogMode = (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem');
function updateModeToggleUI() {
var btns = document.querySelectorAll('#modeToggle button');
btns.forEach(function (b) {
if (b.getAttribute('data-mode') === catalogMode) {
b.classList.add('is-active');
} else {
b.classList.remove('is-active');
}
});
}
function setCatalogMode(mode) {
if (mode !== 'oem' && mode !== 'local' && mode !== 'supplies') return;
if (mode === catalogMode) return;
catalogMode = mode;
localStorage.setItem('catalog_mode', mode);
updateModeToggleUI();
// Clear category-and-below state regardless of mode
nav.category = nav.group = nav.partType = null;
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
currentPage = 1;
if (mode === 'supplies') {
// Supplies mode skips the vehicle chain entirely.
// Clear the vehicle state for visual clarity and go directly
// to the Shop Supplies top-level group list.
try { vsClearAll(); } catch (e) {}
nav.brand = nav.model = nav.year = nav.engine = null;
nav.level = 'categories';
loadShopSuppliesGroups();
return;
}
// OEM/Local: smart reset — if the user already picked a vehicle,
// stay at the categories level. Otherwise reset to brand selection.
var hasVehicle = !!(nav.engine && nav.engine.id_mye);
if (hasVehicle) {
nav.level = 'categories';
loadCategoriesForMode();
return;
}
try { vsClearAll(); } catch (e) {}
nav.level = 'brands';
nav.brand = nav.model = nav.year = nav.engine = null;
loadBrands();
}
var currentPage = 1;
var currentDetailPart = null;
var detailQty = 1;
@@ -82,6 +144,10 @@
nav.engine = e.state.engine;
nav.category = e.state.category;
nav.group = e.state.group;
nav.partType = e.state.partType || null;
nav.nxGroup = e.state.nxGroup || null;
nav.nxSubgroup = e.state.nxSubgroup || null;
nav.nxPartType = e.state.nxPartType || null;
currentPage = e.state.page || 1;
// Reload the correct level
@@ -89,8 +155,16 @@
else if (nav.level === 'models') loadModels();
else if (nav.level === 'years') loadYears();
else if (nav.level === 'engines') loadEngines();
else if (nav.level === 'categories') loadCategories();
else if (nav.level === 'groups') loadGroups();
// When restoring from history, dispatch between OEM and Nexpart
// based on which branch of state is populated — this survives
// toggle changes made mid-session.
else if (nav.level === 'categories') loadCategoriesForMode();
else if (nav.level === 'groups') {
if (nav.nxGroup) loadNexpartSubgroups(); else loadGroups();
}
else if (nav.level === 'part_types') {
if (nav.nxSubgroup) loadNexpartPartTypes(); else loadPartTypes();
}
else if (nav.level === 'parts') loadParts(currentPage);
else loadBrands();
@@ -151,8 +225,19 @@
if (nav.model) parts.push({ label: nav.model.name, action: 'loadYears' });
if (nav.year) parts.push({ label: String(nav.year.year), action: 'loadEngines' });
if (nav.engine) parts.push({ label: nav.engine.name, action: 'loadCategories' });
if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
if (nav.group) parts.push({ label: nav.group.name, action: null });
// The category/group/part_type trio is rendered from EITHER the Nexpart
// branch (nxGroup/nxSubgroup/nxPartType) or the OEM branch (category/
// group/partType), depending on which is populated. Only one branch
// should be active at a time after a navigation reset.
if (nav.nxGroup) parts.push({ label: nav.nxGroup.name, action: 'loadNxSubgroups' });
else if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
if (nav.nxSubgroup) parts.push({ label: nav.nxSubgroup.name, action: 'loadNxPartTypes' });
else if (nav.group) parts.push({ label: nav.group.name, action: 'loadPartTypes' });
if (nav.nxPartType) parts.push({ label: nav.nxPartType.name, action: null });
else if (nav.partType) parts.push({ label: nav.partType.name, action: null });
var html = '';
for (var i = 0; i < parts.length; i++) {
@@ -173,8 +258,12 @@
else if (action === 'loadModels') { resetNavFrom('models'); loadModels(); }
else if (action === 'loadYears') { resetNavFrom('years'); loadYears(); }
else if (action === 'loadEngines') { resetNavFrom('engines'); loadEngines(); }
else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategories(); }
else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategoriesForMode(); }
else if (action === 'loadGroups') { resetNavFrom('groups'); loadGroups(); }
else if (action === 'loadPartTypes') { resetNavFrom('part_types'); loadPartTypes(); }
// Nexpart-branch breadcrumb actions
else if (action === 'loadNxSubgroups') { resetNavFrom('groups'); loadNexpartSubgroups(); }
else if (action === 'loadNxPartTypes') { resetNavFrom('part_types'); loadNexpartPartTypes(); }
});
});
}
@@ -182,17 +271,33 @@
function resetNav() {
nav.level = 'brands';
pushNavState();
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = null;
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = nav.partType = null;
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
}
function resetNavFrom(level) {
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'parts'];
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'part_types', 'parts'];
var idx = levels.indexOf(level);
if (idx <= 0) { resetNav(); return; }
nav.level = level;
var keys = [null, 'model', 'year', 'engine', 'category', 'group', null];
// For each level, the corresponding state key(s) to clear.
// In Local mode, 'categories' clears nxGroup, 'groups' clears nxSubgroup, etc.
// We clear BOTH mode-specific keys at each level so a mode switch mid-navigation
// is always clean.
var keys = [
null, // brands (nothing to clear above)
['model'], // models
['year'], // years
['engine'], // engines
['category', 'nxGroup'], // categories ← both OEM + Nexpart
['group', 'nxSubgroup'], // groups ← both OEM + Nexpart
['partType', 'nxPartType'], // part_types ← both OEM + Nexpart
null, // parts
];
for (var i = idx; i < keys.length; i++) {
if (keys[i]) nav[keys[i]] = null;
if (!keys[i]) continue;
var ks = keys[i];
for (var j = 0; j < ks.length; j++) nav[ks[j]] = null;
}
}
@@ -221,7 +326,7 @@
setupLevelFilter(true);
showLoading();
apiFetch(API + '/brands').then(function (data) {
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
if (!data) {
@@ -317,7 +422,7 @@
if (data.data.length === 1) {
var e = data.data[0];
nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') };
loadCategories();
loadCategoriesForMode();
return;
}
@@ -333,7 +438,7 @@
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.engine = { id_mye: parseInt(this.dataset.myeId), name: this.dataset.name };
loadCategories();
loadCategoriesForMode();
});
});
});
@@ -389,32 +494,345 @@
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.group = { id: parseInt(this.dataset.groupId), name: this.dataset.name };
nav.partType = null; // reset deeper levels
loadPartTypes();
});
});
});
}
// ─── Part Types (3rd subcategory level — Nexpart-style) ───
function loadPartTypes() {
nav.level = 'part_types';
nav.partType = null;
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.group.name;
setupLevelFilter(true);
showLoading();
apiFetch(API + '/part-types?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
// No part types? Skip directly to all parts in the group.
loadParts(1);
return;
}
// Single part type? Skip the picker — go straight to parts.
if (data.data.length === 1) {
var only = data.data[0];
nav.partType = { slug: only.slug, name: only.name };
loadParts(1);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (pt) {
var img = pt.sample_image
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" data-pt-slug="' + esc(pt.slug) + '" data-pt-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.partType = { slug: this.dataset.ptSlug, name: this.dataset.ptName };
loadParts(1);
});
});
});
}
function loadParts(page) {
// ═══════════════════════════════════════════════════════════════════
// NEXPART (Local mode) — parallel navigation functions
// ═══════════════════════════════════════════════════════════════════
// These run in parallel to loadCategories / loadGroups / loadPartTypes
// and are only invoked when catalogMode === 'local'. They share the
// same DOM hooks (navGrid, breadcrumb, levelTitle) but fetch from the
// Nexpart-filtered endpoints and store state in nav.nxGroup / nxSubgroup
// / nxPartType instead of nav.category / group / partType.
function loadCategoriesForMode() {
// Dispatcher — called by every place that used to call loadCategories()
if (catalogMode === 'local') {
loadNexpartCategories();
} else {
loadCategories();
}
}
function loadNexpartCategories() {
nav.level = 'categories';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = 'Categorias (Local)';
setupLevelFilter(true);
showLoading();
apiFetch(API + '/categories?mode=local&mye_id=' + nav.engine.id_mye).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin categorias Local', 'Ninguna parte de este vehiculo mapea al catalogo Local.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (c) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(c.slug) + '" data-name="' + esc(c.name) + '">' +
'<div class="nav-card__name">' + esc(c.name) + '</div>' +
'<div class="nav-card__count">' + c.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name };
// Reset deeper Nexpart state so a re-click always goes to
// a clean subgroup list.
nav.nxSubgroup = null;
nav.nxPartType = null;
loadNexpartSubgroups();
});
});
});
}
function loadNexpartSubgroups() {
nav.level = 'groups';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxGroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/groups?mode=local&mye_id=' + nav.engine.id_mye
+ '&category_slug=' + encodeURIComponent(nav.nxGroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin subcategorias', 'No hay subcategorias en ' + nav.nxGroup.name);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (s) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name };
nav.nxPartType = null;
loadNexpartPartTypes();
});
});
});
}
function loadNexpartPartTypes() {
nav.level = 'part_types';
nav.nxPartType = null;
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxSubgroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/part-types?mode=local&mye_id=' + nav.engine.id_mye
+ '&group_slug=' + encodeURIComponent(nav.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin tipos de parte', 'No hay tipos de parte en ' + nav.nxSubgroup.name);
return;
}
// Single part type? Auto-drill-down to parts (UX shortcut).
if (data.data.length === 1) {
var only = data.data[0];
nav.nxPartType = { slug: only.slug, name: only.name };
loadParts(1);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (pt) {
var img = pt.sample_image
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name };
loadParts(1);
});
});
});
}
// ═══════════════════════════════════════════════════════════════════
// SHOP SUPPLIES (Supplies mode) — vehicle-independent
// ═══════════════════════════════════════════════════════════════════
// Uses nav.nxGroup / nav.nxSubgroup / nav.nxPartType for state (reuses
// the Nexpart slot because Supplies is a subset of the Nexpart taxonomy)
// but calls a different set of endpoints (/shop-supplies/*) that don't
// require an mye_id.
function loadShopSuppliesGroups() {
nav.level = 'categories';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = 'Shop Supplies (sin vehiculo)';
setupLevelFilter(true);
showLoading();
apiFetch(API + '/shop-supplies/groups').then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin supplies', 'No hay grupos de Shop Supplies disponibles.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (g) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(g.slug) + '" data-name="' + esc(g.name) + '">' +
'<div class="nav-card__name">' + esc(g.name) + '</div>' +
'<div class="nav-card__count">' + g.subgroup_count + ' subgrupos</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name };
nav.nxSubgroup = null;
nav.nxPartType = null;
loadShopSuppliesSubgroups();
});
});
});
}
function loadShopSuppliesSubgroups() {
nav.level = 'groups';
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxGroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/shop-supplies/subgroups?group_slug=' + encodeURIComponent(nav.nxGroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin subgrupos', 'No hay subgrupos con partes disponibles.');
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (s) {
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name };
nav.nxPartType = null;
loadShopSuppliesPartTypes();
});
});
});
}
function loadShopSuppliesPartTypes() {
nav.level = 'part_types';
nav.nxPartType = null;
pushNavState();
updateBreadcrumb();
levelTitle.textContent = nav.nxSubgroup.name;
setupLevelFilter(true);
showLoading();
var url = API + '/shop-supplies/part-types'
+ '?group_slug=' + encodeURIComponent(nav.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug);
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin tipos', 'No hay tipos de parte en este subgrupo.');
return;
}
// Single part type? Skip the picker.
if (data.data.length === 1) {
var only = data.data[0];
nav.nxPartType = { slug: only.slug, name: only.name };
loadShopSuppliesParts(1);
return;
}
navGrid.className = 'nav-grid';
navGrid.innerHTML = data.data.map(function (pt) {
var img = pt.sample_image
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).join('');
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
card.addEventListener('click', function () {
nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name };
loadShopSuppliesParts(1);
});
});
});
}
function loadShopSuppliesParts(page) {
nav.level = 'parts';
pushNavState();
currentPage = page || 1;
updateBreadcrumb();
levelTitle.textContent = nav.group.name;
levelTitle.textContent = nav.nxPartType.name;
setupLevelFilter(false);
showLoading();
navGrid.innerHTML = '';
apiFetch(API + '/parts?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id + '&page=' + currentPage + '&per_page=30').then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
var url = API + '/shop-supplies/parts'
+ '?group_slug=' + encodeURIComponent(nav.nxGroup.slug)
+ '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug)
+ '&part_type_slug=' + encodeURIComponent(nav.nxPartType.slug)
+ '&page=' + currentPage + '&per_page=30';
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) {
showEmpty('Sin partes', 'No hay partes en este tipo.');
return;
}
// Reuse the same aftermarket-styled rendering as Local mode.
partsGrid.style.display = '';
partsGrid.innerHTML = data.data.map(function (p) {
var stockBadge;
if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.bodega_count > 0) {
if (p.in_stock_network || p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
@@ -424,10 +842,123 @@
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
return '<article class="part-card" role="listitem" data-part-id="' + p.id_part + '">' +
var tierClass = p.priority_tier === 1 ? ' part-card--tier1' : (p.priority_tier === 2 ? ' part-card--tier2' : '');
var manuBadge = '';
if (p.manufacturer) {
var tierStar = p.priority_tier === 1 ? '<span class="manu-tier">★</span>' : '';
manuBadge = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' + tierStar + '</div>';
}
var skuLine = p.part_number
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number || '') + '</span>'
: esc(p.oem_part_number || '');
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
manuBadge +
'<div class="part-card__oem">' + skuLine + '</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'</div>' +
'<div class="part-card__footer">' +
'<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>' +
stockBadge +
'</div>' +
'</article>';
}).join('');
partsGrid.querySelectorAll('.part-card').forEach(function (card) {
card.addEventListener('click', function () {
openPartDetail(parseInt(this.dataset.partId));
});
});
if (data.pagination) renderPagination(data.pagination);
});
}
function loadParts(page) {
nav.level = 'parts';
pushNavState();
currentPage = page || 1;
updateBreadcrumb();
// Title: Nexpart part type > TecDoc part type > TecDoc group
if (nav.nxPartType) {
levelTitle.textContent = nav.nxPartType.name;
} else if (nav.partType) {
levelTitle.textContent = nav.partType.name;
} else if (nav.group) {
levelTitle.textContent = nav.group.name;
} else {
levelTitle.textContent = 'Partes';
}
setupLevelFilter(false);
showLoading();
navGrid.innerHTML = '';
// Build the URL based on which navigation branch the user took.
// Nexpart branch uses slug-based params; OEM branch uses integer ids.
var url;
if (nav.nxGroup && nav.nxSubgroup && nav.nxPartType) {
url = API + '/parts?mode=local'
+ '&mye_id=' + nav.engine.id_mye
+ '&page=' + currentPage + '&per_page=30'
+ '&nexpart_group=' + encodeURIComponent(nav.nxGroup.slug)
+ '&nexpart_subgroup=' + encodeURIComponent(nav.nxSubgroup.slug)
+ '&nexpart_part_type=' + encodeURIComponent(nav.nxPartType.slug);
} else {
var ptParam = nav.partType ? '&part_type=' + encodeURIComponent(nav.partType.slug) : '';
url = API + '/parts?mye_id=' + nav.engine.id_mye
+ '&group_id=' + nav.group.id
+ '&page=' + currentPage + '&per_page=30'
+ '&mode=' + catalogMode
+ ptParam;
}
apiFetch(url).then(function (data) {
hideLoading();
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
var isLocal = (catalogMode === 'local');
partsGrid.style.display = '';
partsGrid.innerHTML = data.data.map(function (p) {
// Stock badge — prefer tenant stock, then warehouse network, else fallback
var stockBadge;
if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.in_stock_network || p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
}
var imgHtml = p.image_url
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
// Local-mode extras: manufacturer badge + priority tier indicator
var manuBadge = '';
var tierClass = '';
if (isLocal && p.manufacturer) {
var tierLabel = '';
if (p.priority_tier === 1) { tierClass = ' part-card--tier1'; tierLabel = '★'; }
else if (p.priority_tier === 2) { tierClass = ' part-card--tier2'; tierLabel = ''; }
manuBadge = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' +
(tierLabel ? '<span class="manu-tier">' + tierLabel + '</span>' : '') + '</div>';
}
// SKU to show: aftermarket part_number in local mode, OEM number otherwise
var skuLine = isLocal && p.part_number
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number) + '</span>'
: esc(p.oem_part_number);
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
manuBadge +
'<div class="part-card__oem">' + skuLine + '</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'</div>' +
'<div class="part-card__footer">' +
@@ -618,11 +1149,148 @@
// ─── SMART SEARCH ───
var searchTimeout = null;
// ═══════════════════════════════════════════════════════════════════
// SMART SEARCH — auto-detect VIN / plate / part number / keyword
// ═══════════════════════════════════════════════════════════════════
// Returns: 'vin' | 'plate' | 'part_number' | 'keyword'
function detectQueryType(raw) {
if (!raw) return 'keyword';
var q = raw.trim();
// Strip common separators for detection (VINs/parts rarely contain spaces)
var compact = q.replace(/[\s\-]/g, '').toUpperCase();
// VIN: exactly 17 chars alphanumeric, no I/O/Q
if (/^[A-HJ-NPR-Z0-9]{17}$/.test(compact)) return 'vin';
// Mexican license plate: 3 letters + 3-4 digits (with/without hyphen)
if (/^[A-Z]{3}[-\s]?\d{3,4}$/.test(q.toUpperCase())) return 'plate';
// Part-number heuristic. Rules designed to avoid false positives on
// natural-language Spanish/English queries:
// 1. Original query must NOT contain lowercase letters. Real part
// numbers are always uppercase ("4G0-857-951-A"); keywords aren't.
// 2. No natural-language words allowed (para, de, con, for, the, etc.)
// 3. Either has a dash/slash separator, or is a solid alphanumeric blob.
var hasLowercase = /[a-z]/.test(q);
if (hasLowercase) return 'keyword';
// Block queries that contain a year-like 4-digit number alongside
// other tokens — those are "PART 2018" style vehicle refs, not parts.
var tokens = q.split(/\s+/);
var hasYear = tokens.some(function (t) { return /^(19|20)\d{2}$/.test(t); });
if (hasYear && tokens.length > 1) return 'keyword';
var qUpper = q.toUpperCase();
// Dashed/slashed part number: "4G0-857-951-A", "BP-1234"
if (/^[A-Z0-9]{2,}[\-\/][A-Z0-9]{2,}([\-\/][A-Z0-9]+)*$/.test(qUpper) && compact.length >= 6) {
return 'part_number';
}
// Space-separated part number (rare but real, e.g. BOSCH "0 986 4B7 013")
if (tokens.length >= 2 && tokens.every(function (t) { return /^[A-Z0-9]{1,}$/.test(t); }) && compact.length >= 6) {
return 'part_number';
}
// Solid alphanumeric blob 8+ chars with both letters+digits
if (/^[A-Z0-9]{8,}$/.test(compact) && /[A-Z]/.test(compact) && /\d/.test(compact)) {
return 'part_number';
}
return 'keyword';
}
// Hint badge shown next to the search input. Injected lazily so we don't
// need to touch the HTML.
var searchHint = null;
function ensureSearchHint() {
if (searchHint) return searchHint;
searchHint = document.createElement('div');
searchHint.id = 'searchHint';
searchHint.style.cssText =
'position:absolute;top:100%;left:0;margin-top:4px;padding:3px 10px;' +
'background:var(--color-primary-muted);color:var(--color-text-accent);' +
'font-size:var(--text-caption);font-weight:var(--font-weight-semibold);' +
'border:1px dashed var(--color-border-accent);border-radius:var(--radius-sm);' +
'white-space:nowrap;pointer-events:none;z-index:10;display:none;';
searchInput.parentElement.appendChild(searchHint);
return searchHint;
}
function updateSearchHint(type) {
var hint = ensureSearchHint();
var labels = {
vin: '🚗 VIN detectado — decodificando',
plate: '🔖 Placa detectada — consultando registro',
part_number: '🔩 Numero de parte detectado',
keyword: null,
};
var label = labels[type];
if (!label) {
hint.style.display = 'none';
} else {
hint.textContent = label;
hint.style.display = '';
}
}
// Smart dispatcher — decides which endpoint to call based on input type.
function runSmartSearch(q) {
var type = detectQueryType(q);
if (type === 'vin') {
// Use the existing VIN decoder flow
try { decodeVinWithValue(q); } catch (e) { runSearch(q); }
return;
}
if (type === 'plate') {
// Use the existing plate lookup flow — assume default state MX
try { lookupPlateWithValue(q); } catch (e) { runSearch(q); }
return;
}
// For part_number and keyword, both go through the existing /search
// endpoint (which supports full-text + OEM number search).
runSearch(q);
}
// Thin wrappers around existing VIN/plate handlers — they usually read
// from their own input fields; these set the field and trigger.
function decodeVinWithValue(vin) {
var vinInput = document.getElementById('vinInput');
if (vinInput) {
vinInput.value = vin;
if (typeof decodeVin === 'function') decodeVin();
else runSearch(vin);
} else {
runSearch(vin); // fallback
}
}
function lookupPlateWithValue(plate) {
var plateInput = document.getElementById('plateInput');
if (plateInput) {
plateInput.value = plate.toUpperCase();
if (typeof lookupPlate === 'function') lookupPlate();
else runSearch(plate);
} else {
runSearch(plate); // fallback
}
}
searchInput.addEventListener('input', function () {
clearTimeout(searchTimeout);
var q = this.value.trim();
// Live type detection for the hint (runs on every keystroke)
updateSearchHint(q.length >= 3 ? detectQueryType(q) : null);
if (q.length < 2) { searchDropdown.classList.remove('is-visible'); return; }
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
// For keyword queries, keep the debounced dropdown preview.
// For VIN/plate/part-number, wait for Enter — they're one-shot lookups.
var type = detectQueryType(q);
if (type === 'keyword') {
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
}
});
searchInput.addEventListener('keydown', function (e) {
@@ -630,10 +1298,11 @@
e.preventDefault();
clearTimeout(searchTimeout);
var q = this.value.trim();
if (q.length >= 2) runSearch(q);
if (q.length >= 2) runSmartSearch(q);
}
if (e.key === 'Escape') {
searchDropdown.classList.remove('is-visible');
updateSearchHint(null);
}
});
@@ -906,7 +1575,7 @@
// Load brands filtered by year
vsBrand.disabled = false;
apiFetch(API + '/brands?year_id=' + yearId).then(function (data) {
apiFetch(API + '/brands?year_id=' + yearId + '&mode=' + catalogMode).then(function (data) {
var brands = data.data || data;
if (!brands) return;
vsBrand.innerHTML = '<option value="">Marca...</option>';
@@ -980,7 +1649,7 @@
nav.level = 'categories';
pushNavState();
loadCategories();
loadCategoriesForMode();
// Scroll to catalog content
setTimeout(function () {
@@ -999,7 +1668,9 @@
vsEngine.disabled = true;
vsClear.style.display = 'none';
nav.level = 'brands'; nav.brand = null; nav.model = null; nav.year = null; nav.engine = null; nav.category = null; nav.group = null; currentPage = 1;
nav.level = 'brands'; nav.brand = null; nav.model = null; nav.year = null; nav.engine = null; nav.category = null; nav.group = null; nav.partType = null;
nav.nxGroup = null; nav.nxSubgroup = null; nav.nxPartType = null;
currentPage = 1;
pushNavState();
loadBrands();
}
@@ -1231,10 +1902,12 @@
decodeVin: decodeVin,
togglePlate: togglePlate,
lookupPlate: lookupPlate,
setMode: setCatalogMode,
};
// ─── INIT ───
renderCart();
updateModeToggleUI();
vsLoadYears();
loadBrands();

View File

@@ -256,7 +256,7 @@ const Config = (() => {
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
+ '<td>' + statusBadge + '</td>'
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>'
+ '<td><button class="btn btn--ghost btn--sm" disabled>Editar</button></td>'
+ '<td><button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button></td>'
+ '</tr>';
});
@@ -265,8 +265,21 @@ const Config = (() => {
}
async function saveEmployee(data) {
var res = await fetch(API + '/employees', {
method: 'POST',
// Check if we're editing (modal has editId) or creating
var modal = document.getElementById('employee-modal');
var editId = modal ? modal.dataset.editId : null;
var url = API + '/employees';
var method = 'POST';
if (editId) {
url = API + '/employees/' + editId;
method = 'PUT';
// Clear the edit marker so next use is a fresh create
delete modal.dataset.editId;
}
var res = await fetch(url, {
method: method,
headers: headers(),
body: JSON.stringify(data)
});
@@ -302,6 +315,95 @@ const Config = (() => {
if (el) el.value = v || '';
}
function getVal(id) {
var el = document.getElementById(id);
return el ? el.value.trim() : '';
}
async function editEmployee(empId) {
if (!checkAuth()) return;
// Find the employee in the loaded data by re-fetching
try {
var res = await fetch(API + '/employees', { headers: headers() });
if (!res.ok) throw new Error('Failed to load employees');
var json = await res.json();
var emp = (json.data || []).find(function(e) { return e.id === empId; });
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
// Pre-fill the "new employee" modal with existing data for editing
setVal('new-emp-name', emp.name);
setVal('new-emp-email', emp.email || '');
var roleSelect = document.getElementById('new-emp-role');
if (roleSelect) roleSelect.value = emp.role || 'cashier';
var branchSelect = document.getElementById('new-emp-branch');
if (branchSelect) branchSelect.value = emp.branch_id || '';
setVal('new-emp-discount', emp.max_discount_pct || '');
setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
// Store the ID so saveEmployee knows it's an update
var modal = document.getElementById('employee-modal');
if (modal) {
modal.dataset.editId = empId;
var title = modal.querySelector('.modal-title, h3');
if (title) title.textContent = 'Editar Empleado';
}
openModal('employee-modal');
} catch (e) {
toast('Error: ' + e.message, 'error');
}
}
async function saveTaxParams() {
if (!checkAuth()) return;
var data = {
tax_iva: getVal('tax-iva') || '16',
tax_ieps: getVal('tax-ieps') || '0',
invoice_serie: getVal('tax-serie') || 'FA',
invoice_folio: getVal('tax-folio') || '1',
default_currency: document.getElementById('tax-moneda') ? document.getElementById('tax-moneda').value : 'MXN',
default_payment_method: document.getElementById('tax-forma-pago') ? document.getElementById('tax-forma-pago').value : '01',
};
try {
// Use the business PUT endpoint with tax_ prefixed keys
var res = await fetch(API + '/business', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Error al guardar');
toast('Parámetros de impuestos guardados', 'ok');
} catch (e) {
toast(e.message, 'error');
}
}
async function saveBusiness() {
if (!checkAuth()) return;
var data = {
razon_social: getVal('biz-razon-social'),
nombre: getVal('biz-nombre'),
rfc: getVal('biz-rfc'),
regimen_fiscal: getVal('biz-regimen'),
direccion: getVal('biz-direccion'),
telefono: getVal('biz-telefono'),
email: getVal('biz-email'),
};
try {
var res = await fetch(API + '/business', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data),
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Error al guardar');
}
toast('Datos de empresa guardados', 'ok');
} catch (e) {
toast(e.message, 'error');
}
}
// -------------------------------------------------------------------------
// Event bindings
// -------------------------------------------------------------------------
@@ -525,7 +627,8 @@ const Config = (() => {
return {
init, setTheme, selectThemeOption,
loadBranches, loadEmployees, saveBranch, saveEmployee,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,
openModal, closeModal
};

404
pos/static/js/pos-utils.js Normal file
View File

@@ -0,0 +1,404 @@
/**
* pos-utils.js — Shared utility functions for all POS pages.
*
* Provides common operations that multiple pages need:
* - CSV export of any visible table
* - Print page (PDF via browser print dialog)
* - Toast notifications (if page doesn't have its own)
*
* Load this script in every POS template BEFORE page-specific JS.
*/
(function() {
'use strict';
// ── CSV Export ──────────────────────────────────────────────────
// Finds the first visible <table> on the page and downloads it as CSV.
// Works on inventory, customers, invoicing, reports, accounting.
window.exportVisibleTableCSV = function(prefix) {
prefix = prefix || 'datos';
var tables = document.querySelectorAll('table');
var table = null;
// Find first visible table with data rows
for (var i = 0; i < tables.length; i++) {
if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) {
table = tables[i];
break;
}
}
if (!table) {
showToast('No hay tabla de datos para exportar en esta vista.', 'warn');
return;
}
var rows = [];
// Header row
var ths = table.querySelectorAll('thead th');
if (ths.length) {
rows.push(Array.from(ths).map(function(th) {
return '"' + th.textContent.trim().replace(/"/g, '""') + '"';
}).join(','));
}
// Data rows
table.querySelectorAll('tbody tr').forEach(function(tr) {
var cells = tr.querySelectorAll('td');
rows.push(Array.from(cells).map(function(td) {
return '"' + td.textContent.trim().replace(/"/g, '""') + '"';
}).join(','));
});
if (rows.length <= 1) {
showToast('La tabla está vacía — no hay datos para exportar.', 'warn');
return;
}
var csv = rows.join('\n');
// BOM prefix so Excel opens UTF-8 correctly
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = prefix + '_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('CSV descargado: ' + a.download, 'ok');
};
// ── Print (PDF) ────────────────────────────────────────────────
window.printPage = function() {
window.print();
};
// ── Toast (simple, non-blocking notification) ──────────────────
// Only creates its own toast if the page doesn't already have one.
window.showToast = function(msg, type) {
type = type || 'info';
var container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.style.cssText = 'position:fixed;top:16px;right:16px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
document.body.appendChild(container);
}
var colors = {
ok: 'background:#1a7a3a;color:#fff;',
error: 'background:#c0392b;color:#fff;',
warn: 'background:#d4a017;color:#000;',
info: 'background:var(--color-surface-3,#333);color:var(--color-text-primary,#fff);',
};
var toast = document.createElement('div');
toast.style.cssText = (colors[type] || colors.info) +
'padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;' +
'box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;' +
'animation:slideInRight 0.3s ease;max-width:400px;';
toast.textContent = msg;
container.appendChild(toast);
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(function() { toast.remove(); }, 300);
}, 3000);
};
// ── "Próximamente" placeholder for features not yet built ──────
window.featureProximamente = function(nombre) {
showToast((nombre || 'Esta función') + ' estará disponible próximamente.', 'info');
};
// ── Table Filter Panel ────────────────────────────────────────
// Creates a dropdown filter panel that filters visible table rows
// client-side. Call toggleFilterPanel(buttonEl, config) where config
// is an array of {label, column, values} describing each filter.
//
// Usage (from onclick):
// toggleFilterPanel(this, [
// {label: 'Marca', column: 2, values: ['BOSCH','MONROE','Todas']},
// {label: 'Status', column: 4, values: ['Activo','Inactivo','Todos']},
// ])
var _activeFilterPanel = null;
window.toggleFilterPanel = function(btnEl, filters) {
// Close existing panel if open
if (_activeFilterPanel) {
_activeFilterPanel.remove();
_activeFilterPanel = null;
return;
}
var panel = document.createElement('div');
panel.className = 'filter-panel';
panel.style.cssText = 'position:absolute;top:100%;right:0;z-index:1000;' +
'background:var(--glass-bg-strong,#1a1a1a);backdrop-filter:blur(16px);' +
'border:1px solid var(--glass-border,#333);border-radius:var(--radius-lg,12px);' +
'padding:16px;min-width:260px;box-shadow:0 8px 32px rgba(0,0,0,0.3);' +
'display:flex;flex-direction:column;gap:12px;';
var title = document.createElement('div');
title.style.cssText = 'font-weight:700;font-size:14px;display:flex;justify-content:space-between;align-items:center;';
title.innerHTML = 'Filtros <button onclick="closeFilterPanel()" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:18px;">✕</button>';
panel.appendChild(title);
filters.forEach(function(f) {
var group = document.createElement('div');
var label = document.createElement('label');
label.style.cssText = 'display:block;font-size:12px;color:var(--color-text-muted);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em;';
label.textContent = f.label;
group.appendChild(label);
var select = document.createElement('select');
select.style.cssText = 'width:100%;padding:8px 10px;background:var(--glass-bg,#222);' +
'border:1px solid var(--glass-border,#444);border-radius:6px;' +
'color:var(--color-text-primary,#fff);font-size:13px;';
select.dataset.filterColumn = f.column;
// "Todos" option always first
var allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = f.allLabel || 'Todos';
select.appendChild(allOpt);
(f.values || []).forEach(function(v) {
if (!v) return;
var opt = document.createElement('option');
opt.value = v;
opt.textContent = v;
select.appendChild(opt);
});
select.addEventListener('change', function() { applyFilters(panel); });
group.appendChild(select);
panel.appendChild(group);
});
// Clear all button
var clearBtn = document.createElement('button');
clearBtn.style.cssText = 'padding:8px;background:transparent;border:1px dashed var(--glass-border,#444);' +
'border-radius:6px;color:var(--color-text-muted);cursor:pointer;font-size:12px;';
clearBtn.textContent = 'Limpiar filtros';
clearBtn.addEventListener('click', function() {
panel.querySelectorAll('select').forEach(function(s) { s.value = ''; });
applyFilters(panel);
});
panel.appendChild(clearBtn);
// Position relative to the button
var wrapper = btnEl.parentElement;
if (wrapper) wrapper.style.position = 'relative';
(wrapper || document.body).appendChild(panel);
_activeFilterPanel = panel;
// Close on outside click
setTimeout(function() {
document.addEventListener('click', function handler(e) {
if (!panel.contains(e.target) && e.target !== btnEl) {
closeFilterPanel();
document.removeEventListener('click', handler);
}
});
}, 100);
};
window.closeFilterPanel = function() {
if (_activeFilterPanel) {
_activeFilterPanel.remove();
_activeFilterPanel = null;
}
};
function applyFilters(panel) {
var selects = panel.querySelectorAll('select[data-filter-column]');
// Find the nearest visible table
var tables = document.querySelectorAll('table');
var table = null;
for (var i = 0; i < tables.length; i++) {
if (tables[i].offsetParent !== null) { table = tables[i]; break; }
}
if (!table) return;
var rows = table.querySelectorAll('tbody tr');
rows.forEach(function(tr) {
var show = true;
selects.forEach(function(sel) {
var col = parseInt(sel.dataset.filterColumn);
var val = sel.value.toLowerCase();
if (!val) return; // "Todos" — no filter
var cells = tr.querySelectorAll('td');
if (cells[col]) {
var cellText = cells[col].textContent.trim().toLowerCase();
if (cellText.indexOf(val.toLowerCase()) === -1) show = false;
}
});
tr.style.display = show ? '' : 'none';
});
// Update count badge if exists
var visibleCount = 0;
rows.forEach(function(tr) { if (tr.style.display !== 'none') visibleCount++; });
var badge = document.querySelector('.filter-count-badge');
if (badge) badge.textContent = visibleCount + ' resultados';
}
// ── Auto-extract unique values from a table column ──────────
// Useful for building filter options dynamically from data.
window.getUniqueColumnValues = function(tableEl, colIndex, maxValues) {
maxValues = maxValues || 30;
var values = {};
if (!tableEl) return [];
tableEl.querySelectorAll('tbody tr').forEach(function(tr) {
var cells = tr.querySelectorAll('td');
if (cells[colIndex]) {
var v = cells[colIndex].textContent.trim();
if (v && v !== '-' && v !== '') values[v] = (values[v] || 0) + 1;
}
});
// Sort by frequency (most common first)
return Object.keys(values)
.sort(function(a, b) { return values[b] - values[a]; })
.slice(0, maxValues);
};
// ── Auto-print polling for WhatsApp quotations ───────────────
// Polls /quotations/print-queue every 15s. When a confirmed WA quote
// is found, it fetches the ESC/POS bytes and sends to the connected
// thermal printer. Falls back to browser print if no thermal is connected.
var _autoPrintTimer = null;
var _autoPrintEnabled = false;
window.startAutoPrint = function() {
if (_autoPrintTimer) return;
_autoPrintEnabled = true;
var token = localStorage.getItem('pos_token');
if (!token) return;
_autoPrintTimer = setInterval(function() {
fetch('/pos/api/quotations/print-queue', {
headers: { 'Authorization': 'Bearer ' + token }
})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.data || !d.data.length) return;
d.data.forEach(function(q) {
console.log('[auto-print] Cotización #' + q.id + ' confirmada por WhatsApp — imprimiendo...');
showToast('🖨️ Imprimiendo cotización #' + q.id + ' (WhatsApp)', 'ok');
autoPrintQuote(q.id, token);
});
})
.catch(function() {}); // silent on errors
}, 15000); // every 15 seconds
console.log('[auto-print] Enabled — polling every 15s');
};
window.stopAutoPrint = function() {
if (_autoPrintTimer) {
clearInterval(_autoPrintTimer);
_autoPrintTimer = null;
}
_autoPrintEnabled = false;
};
function autoPrintQuote(quoteId, token) {
// Try thermal printer first (via NexusPrinter if loaded)
if (typeof NexusPrinter !== 'undefined' && NexusPrinter.isConnected && NexusPrinter.isConnected()) {
fetch('/pos/api/quotations/' + quoteId + '/print', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ printer_type: 'escpos_raw', width: 80 }),
})
.then(function(r) { return r.arrayBuffer(); })
.then(function(buf) {
NexusPrinter.sendRaw(new Uint8Array(buf));
markPrinted(quoteId, token);
})
.catch(function(e) {
console.error('[auto-print] Thermal print failed:', e);
browserPrintQuote(quoteId, token);
});
} else {
browserPrintQuote(quoteId, token);
}
}
function browserPrintQuote(quoteId, token) {
// Fallback: open a print-friendly window
fetch('/pos/api/quotations/' + quoteId + '/print', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ printer_type: 'browser' }),
})
.then(function(r) { return r.json(); })
.then(function(q) {
var html = '<html><head><title>Cotización #' + q.id + '</title>';
html += '<style>body{font-family:monospace;font-size:12px;width:80mm;margin:0 auto;padding:10px;}';
html += 'h1{font-size:18px;text-align:center;margin:0;}';
html += '.center{text-align:center;}.right{text-align:right;}';
html += 'hr{border:none;border-top:1px dashed #000;}';
html += 'table{width:100%;border-collapse:collapse;}td{padding:2px 4px;}</style></head><body>';
html += '<h1>COTIZACIÓN</h1>';
html += '<p class="center">COT-' + q.id + '</p>';
html += '<p>Fecha: ' + (q.created_at || '').substring(0, 10) + '</p>';
if (q.customer_name) html += '<p>Cliente: ' + q.customer_name + '</p>';
if (q.wa_phone) html += '<p>WhatsApp: ' + q.wa_phone + '</p>';
html += '<hr><table>';
(q.items || []).forEach(function(it) {
html += '<tr><td>' + it.quantity + 'x ' + it.name + '</td><td class="right">$' + it.subtotal.toFixed(2) + '</td></tr>';
if (it.part_number) html += '<tr><td colspan="2" style="font-size:10px;color:#666;"> #' + it.part_number + '</td></tr>';
});
html += '</table><hr>';
html += '<p class="right">Subtotal: $' + q.subtotal.toFixed(2) + '</p>';
html += '<p class="right">IVA: $' + q.tax_total.toFixed(2) + '</p>';
html += '<p class="right" style="font-size:16px;font-weight:bold;">TOTAL: $' + q.total.toFixed(2) + '</p>';
html += '<hr><p class="center" style="font-size:10px;">Esta cotización no es comprobante fiscal<br>Precios sujetos a disponibilidad</p>';
html += '</body></html>';
var w = window.open('', '_blank', 'width=400,height=600');
w.document.write(html);
w.document.close();
setTimeout(function() { w.print(); }, 500);
markPrinted(quoteId, token);
})
.catch(function(e) {
console.error('[auto-print] Browser print failed:', e);
});
}
function markPrinted(quoteId, token) {
fetch('/pos/api/quotations/' + quoteId + '/mark-printed', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
}).catch(function() {});
}
// Auto-start polling on pages that are likely to have a printer
// (POS sale page and quotations page)
if (window.location.pathname.indexOf('/pos/sale') !== -1 ||
window.location.pathname.indexOf('/pos/quotation') !== -1 ||
window.location.pathname.indexOf('/pos/dashboard') !== -1) {
var _initToken = localStorage.getItem('pos_token');
if (_initToken) {
setTimeout(function() { startAutoPrint(); }, 3000);
}
}
// Inject styles
if (!document.getElementById('pos-utils-styles')) {
var style = document.createElement('style');
style.id = 'pos-utils-styles';
style.textContent = '@keyframes slideInRight{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}' +
'.filter-panel select:focus{outline:none;border-color:var(--color-primary,#F5A623);box-shadow:0 0 0 2px var(--glow-color-soft,rgba(245,166,35,0.15));}';
document.head.appendChild(style);
}
})();

View File

@@ -715,3 +715,39 @@ const Reports = (() => {
loadVentas, loadInventario, loadClientes, loadFinancieros, fmt
};
})();
// ── Global: Export visible table as CSV (Excel-compatible) ──
function exportReportCSV() {
var tables = document.querySelectorAll('table');
// Find the first visible table
var table = null;
for (var i = 0; i < tables.length; i++) {
var t = tables[i];
if (t.offsetParent !== null && t.querySelector('tbody tr')) {
table = t;
break;
}
}
if (!table) {
alert('No hay tabla de datos para exportar en esta vista.');
return;
}
var rows = [];
var ths = table.querySelectorAll('thead th');
if (ths.length) {
rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
}
table.querySelectorAll('tbody tr').forEach(function(tr) {
var cells = tr.querySelectorAll('td');
rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
});
if (rows.length <= 1) { alert('La tabla esta vacia.'); return; }
var csv = rows.join('\n');
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'reporte_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
a.click();
URL.revokeObjectURL(url);
}

View File

@@ -27,6 +27,8 @@
]},
{ label: _t('nav_management'), items: [
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
{ name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' },
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
@@ -163,4 +165,61 @@
var main = document.querySelector('main, .main-content, #mainContent, .main, .page-content');
if (main) main.classList.add('pos-main-offset');
// ── Tablet/mobile: sidebar toggle + overlay ─────────────────────
// Creates a hamburger button + overlay for screens < 1024px.
// The CSS in pos-glass.css hides the sidebar by default on tablets
// and shows it as a slide-in drawer when .open is added.
var sidebar = document.querySelector('.pos-sidebar, .sidebar, #sidebar');
var overlay = document.getElementById('sidebar-overlay');
// Create overlay if it doesn't exist
if (!overlay && sidebar) {
overlay = document.createElement('div');
overlay.id = 'sidebar-overlay';
overlay.className = 'sidebar-overlay';
overlay.addEventListener('click', function () { closeSidebar(); });
sidebar.parentNode.insertBefore(overlay, sidebar);
}
// Create hamburger button if it doesn't exist
var hamburger = document.getElementById('hamburger-btn');
if (!hamburger) {
hamburger = document.createElement('button');
hamburger.id = 'hamburger-btn';
hamburger.className = 'hamburger-btn';
hamburger.setAttribute('aria-label', 'Menú');
hamburger.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
hamburger.style.cssText = 'display:none;position:fixed;top:10px;left:10px;z-index:' +
(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--z-modal') || 1050) + 2) +
';background:var(--glass-bg-strong);backdrop-filter:blur(12px);border:1px solid var(--glass-border);' +
'border-radius:var(--radius-md);padding:8px;cursor:pointer;color:var(--color-text-primary);' +
'box-shadow:0 2px 8px rgba(0,0,0,0.2);';
hamburger.addEventListener('click', function () { toggleSidebar(); });
document.body.appendChild(hamburger);
}
function toggleSidebar() {
if (!sidebar) return;
var isOpen = sidebar.classList.contains('open');
sidebar.classList.toggle('open', !isOpen);
if (overlay) overlay.classList.toggle('open', !isOpen);
document.body.style.overflow = isOpen ? '' : 'hidden';
}
function closeSidebar() {
if (sidebar) sidebar.classList.remove('open');
if (overlay) overlay.classList.remove('open');
document.body.style.overflow = '';
}
// Auto-close sidebar on window resize to desktop
window.addEventListener('resize', function () {
if (window.innerWidth >= 1024) closeSidebar();
});
// Expose globally so inline onclick handlers and page-specific JS can call them
window.toggleSidebar = toggleSidebar;
window.closeSidebar = closeSidebar;
})();

View File

@@ -103,6 +103,9 @@
messengerArea.style.display = 'flex';
disconnectBtn.style.display = '';
connectBtn.style.display = 'none';
// Load conversations + start polling on page load / reconnect
loadConversations();
startPolling();
} else if (state === 'connecting') {
statusDot.className = 'status-dot status-dot--warn';
statusText.textContent = 'Escaneando QR...';
@@ -221,18 +224,43 @@
var html = '';
convs.forEach(function (c) {
var isActive = c.phone === activePhone;
var dirIcon = c.last_direction === 'outgoing' ? '&rarr; ' : '';
var dirIcon = c.last_direction === 'outgoing' ? ' ' : '';
// Show contact name if available, otherwise try to format the phone.
// LID numbers (15+ digits, no country code pattern) show as "Contacto"
var displayName = c.contact_name || '';
if (!displayName) {
var isLid = c.phone.length > 13 || !/^(52|1|44|34)/.test(c.phone);
displayName = isLid ? 'Contacto WhatsApp' : fmtPhone(c.phone);
}
html += '<div class="conv-item' + (isActive ? ' is-active' : '') + '" data-phone="' + escHtml(c.phone) + '">'
+ '<div class="conv-item__phone">' + escHtml(fmtPhone(c.phone)) + '</div>'
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message) + '</div>'
+ '<div class="conv-item__phone">' + escHtml(displayName) + '</div>'
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message || '(sin texto)') + '</div>'
+ '<div class="conv-item__time">' + fmtTime(c.last_at) + '</div>'
+ '<button class="conv-item__delete" data-del-phone="' + escHtml(c.phone) + '" title="Borrar conversacion">&times;</button>'
+ '</div>';
});
// "Borrar todo" button at the bottom
html += '<div style="padding:8px;text-align:center;">'
+ '<button class="conv-delete-all" style="background:none;border:1px dashed var(--color-border,#444);color:var(--color-text-muted);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px;" onclick="deleteAllConversations()">Borrar todas las conversaciones</button>'
+ '</div>';
convList.innerHTML = html;
convList.querySelectorAll('.conv-item').forEach(function (el) {
el.addEventListener('click', function () {
openConversation(el.getAttribute('data-phone'));
el.addEventListener('click', function (e) {
if (e.target.classList.contains('conv-item__delete')) return;
var name = el.querySelector('.conv-item__phone') ? el.querySelector('.conv-item__phone').textContent : '';
openConversation(el.getAttribute('data-phone'), name);
});
});
// Wire delete buttons
convList.querySelectorAll('.conv-item__delete').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
var phone = btn.getAttribute('data-del-phone');
if (confirm('Borrar conversacion con ' + fmtPhone(phone) + '?')) {
deleteConversation(phone);
}
});
});
}).catch(function () {
@@ -240,11 +268,43 @@
});
}
function deleteConversation(phone) {
api('DELETE', '/conversations/' + encodeURIComponent(phone)).then(function (res) {
if (res.ok) {
if (activePhone === phone) {
activePhone = null;
chatPanel.style.display = 'none';
emptyState.style.display = '';
}
loadConversations();
} else {
alert('Error: ' + (res.error || 'unknown'));
}
});
}
window.deleteAllConversations = function () {
if (!confirm('Borrar TODAS las conversaciones? Esta accion no se puede deshacer.')) return;
api('DELETE', '/conversations').then(function (res) {
if (res.ok) {
activePhone = null;
chatPanel.style.display = 'none';
emptyState.style.display = '';
loadConversations();
}
});
};
// -- Open a conversation ---------------------------------------------------
function openConversation(phone) {
var activeContactName = '';
function openConversation(phone, contactName) {
activePhone = phone;
chatHeader.textContent = fmtPhone(phone);
// Use contact name if available; fall back to formatted phone
var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone);
activeContactName = contactName || '';
chatHeader.textContent = activeContactName || (isLid ? 'Contacto WhatsApp' : fmtPhone(phone));
emptyState.style.display = 'none';
chatPanel.style.display = 'flex';
@@ -267,13 +327,13 @@
var html = '';
msgs.forEach(function (m) {
var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in';
var statusBadge = '';
if (m.direction === 'outgoing' && m.status) {
statusBadge = '<span class="msg-status">' + escHtml(m.status) + '</span>';
}
// Support both 'text' and 'message_text' keys (backend changed)
var text = m.message_text || m.text || '';
// Support both 'created_at' and 'date' keys
var time = m.created_at || m.date || '';
html += '<div class="msg-bubble ' + cls + '">'
+ '<div class="msg-bubble__text">' + escHtml(m.message_text).replace(/\n/g, '<br>') + '</div>'
+ '<div class="msg-bubble__meta">' + fmtTime(m.created_at) + ' ' + statusBadge + '</div>'
+ '<div class="msg-bubble__text">' + escHtml(text).replace(/\n/g, '<br>') + '</div>'
+ '<div class="msg-bubble__meta">' + fmtTime(time) + '</div>'
+ '</div>';
});
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
@@ -328,16 +388,50 @@
if (quoteBtn) {
quoteBtn.addEventListener('click', function () {
if (!activePhone) { alert('Selecciona una conversacion primero'); return; }
var quoteId = prompt('ID de la cotizacion a enviar:');
if (!quoteId) return;
api('POST', '/send-quote/' + quoteId, { phone: activePhone }).then(function (res) {
if (res.error) {
alert('Error: ' + res.error);
} else {
loadMessages(activePhone);
loadConversations();
}
});
// Fetch available quotations and let user pick one
fetch('/pos/api/quotations?per_page=20', { headers: authHeaders() })
.then(function (r) { return r.json(); })
.then(function (d) {
var quotes = (d.data || []).filter(function (q) { return q.status === 'active'; });
if (quotes.length === 0) {
alert('No hay cotizaciones activas. Crea una desde el POS (F4) o via WhatsApp.');
return;
}
var msg = 'Cotizaciones activas:\n';
quotes.forEach(function (q) {
msg += '#' + q.id + ' — $' + q.total.toFixed(2) + ' (' + (q.customer_name || q.source || 'sin cliente') + ')\n';
});
var quoteId = prompt(msg + '\nEscribe el ID de la cotizacion a enviar:');
if (!quoteId) return;
// Fetch the quotation detail and send it formatted
fetch('/pos/api/quotations/' + quoteId, { headers: authHeaders() })
.then(function (r) { return r.json(); })
.then(function (q) {
if (q.error) { alert('Error: ' + q.error); return; }
// Format the quotation as a WhatsApp message
var lines = ['📄 *COTIZACIÓN #' + q.id + '*', ''];
(q.items || []).forEach(function (it, i) {
lines.push((i + 1) + '. ' + it.name);
lines.push(' #' + it.part_number + ' × ' + it.quantity + ' = $' + it.subtotal.toFixed(2));
});
lines.push('─────────────');
lines.push('Subtotal: $' + q.subtotal.toFixed(2));
lines.push('IVA: $' + q.tax_total.toFixed(2));
lines.push('*TOTAL: $' + q.total.toFixed(2) + '*');
var text = lines.join('\n');
api('POST', '/send', { phone: activePhone, message: text }).then(function (res) {
if (res.error) {
alert('Error enviando: ' + res.error);
} else {
loadMessages(activePhone);
loadConversations();
}
});
});
});
});
}

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contabilidad — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -1732,6 +1733,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/accounting.js"></script>
<script src="/pos/static/js/sync-engine.js"></script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catalogo — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
@@ -106,6 +107,41 @@
.header-actions { display: flex; align-items: center; gap: var(--space-3); }
/* ── Catalog mode toggle (OEM / Local) ── */
.mode-toggle {
display: inline-flex;
padding: 3px;
background: var(--glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px dashed var(--glass-border);
border-radius: var(--radius-md);
gap: 2px;
flex-shrink: 0;
}
.mode-toggle button {
background: transparent;
border: none;
color: var(--color-text-muted);
padding: 4px 12px;
border-radius: calc(var(--radius-md) - 3px);
font-family: var(--font-mono);
font-size: var(--text-caption);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
cursor: pointer;
transition: all 0.2s var(--ease-out);
}
.mode-toggle button:hover {
color: var(--color-text-accent);
}
.mode-toggle button.is-active {
background: var(--color-primary-muted);
color: var(--color-text-accent);
box-shadow: 0 0 12px var(--glow-color-soft);
}
/* Search bar */
.search-bar {
display: flex; align-items: center; gap: var(--space-2);
@@ -233,8 +269,39 @@
.part-card__body { padding: var(--space-3) var(--space-4); flex: 1; }
.part-card__oem { font-family: var(--font-mono, monospace); font-size: var(--text-caption); color: var(--color-primary); font-weight: var(--font-weight-semibold); margin-bottom: var(--space-1); }
.part-card__oem-sub { font-family: var(--font-mono, monospace); font-size: 10px; color: var(--color-text-muted); font-weight: var(--font-weight-regular); }
.part-card__name { font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold); color: var(--color-text-primary); line-height: 1.3; }
/* Local mode — manufacturer badge + priority tier */
.part-card__manu {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 8px; margin-bottom: var(--space-1);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
font-size: 10px;
font-weight: var(--font-weight-bold);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-text-secondary);
}
.part-card__manu .manu-tier {
color: var(--color-primary);
font-size: 11px;
}
.part-card--tier1 {
border-color: var(--color-border-accent);
box-shadow: 0 0 12px var(--glow-color-soft);
}
.part-card--tier1 .part-card__manu {
background: var(--color-primary-muted);
border-color: var(--color-border-accent);
color: var(--color-text-accent);
}
.part-card--tier2 .part-card__manu {
border-color: var(--color-border-strong);
}
.part-card__footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border);
@@ -592,6 +659,11 @@
<span class="breadcrumb__current">Catalogo</span>
</nav>
<div class="header-actions" style="position:relative;">
<div class="mode-toggle" id="modeToggle" title="Cambiar entre catalogo OEM (TecDoc), marcas locales y consumibles">
<button data-mode="oem" onclick="CatalogApp.setMode('oem')">OEM</button>
<button data-mode="local" onclick="CatalogApp.setMode('local')">Local</button>
<button data-mode="supplies" onclick="CatalogApp.setMode('supplies')" title="Aceites, quimicos, herramientas — sin vehiculo">Supplies</button>
</div>
<div class="search-bar" id="searchBar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" id="searchInput" placeholder="Buscar por numero de parte o nombre... (F1)" autocomplete="off" />
@@ -751,6 +823,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/kiosk.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/catalog.js"></script>
<script src="/pos/static/js/offline-banner.js"></script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Configuración — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -1332,34 +1333,36 @@
<div class="form-grid">
<div class="form-group">
<label class="form-label">Razón Social</label>
<input class="form-input" id="biz-razon-social" type="text" value="" readonly />
<input class="form-input" id="biz-razon-social" type="text" value="" placeholder="Ej: Refacciones El Toro S.A. de C.V." />
</div>
<div class="form-group">
<label class="form-label">Nombre Comercial</label>
<input class="form-input" id="biz-nombre" type="text" value="" readonly />
<input class="form-input" id="biz-nombre" type="text" value="" placeholder="Ej: Refacciones El Toro" />
</div>
<div class="form-group">
<label class="form-label">RFC</label>
<input class="form-input" id="biz-rfc" type="text" value="" readonly />
<input class="form-input" id="biz-rfc" type="text" value="" placeholder="Ej: RET260101ABC" maxlength="13" style="text-transform:uppercase;" />
</div>
<div class="form-group">
<label class="form-label">Régimen Fiscal</label>
<input class="form-input" id="biz-regimen" type="text" value="" readonly />
<input class="form-input" id="biz-regimen" type="text" value="" placeholder="Ej: 601 - General de Ley" />
</div>
<div class="form-group form-group--full">
<label class="form-label">Dirección Fiscal</label>
<input class="form-input" id="biz-direccion" type="text" value="" readonly />
<input class="form-input" id="biz-direccion" type="text" value="" placeholder="Calle, Numero, Colonia, CP, Ciudad" />
</div>
<div class="form-group">
<label class="form-label">Teléfono</label>
<input class="form-input" id="biz-telefono" type="tel" value="" readonly />
<input class="form-input" id="biz-telefono" type="tel" value="" placeholder="Ej: 664-123-4567" />
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input class="form-input" id="biz-email" type="email" value="" readonly />
<input class="form-input" id="biz-email" type="email" value="" placeholder="Ej: contacto@refacciones.com" />
</div>
</div>
<p class="form-hint" style="margin-top: var(--space-3);">Datos configurados durante el aprovisionamiento del tenant. Contacta soporte para cambios.</p>
<div style="margin-top:var(--space-4);text-align:right;">
<button class="btn btn--primary" onclick="Config.saveBusiness()">Guardar datos de empresa</button>
</div>
</div>
</div>
@@ -1420,60 +1423,13 @@
</div>
</div>
<div class="device-grid">
<div class="device-card">
<div class="device-card__icon">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
</div>
<div class="device-card__body">
<div class="device-card__name">Epson TM-T88VI</div>
<div class="device-card__detail">
<span class="badge badge--ok" style="padding: 0 4px; font-size: 0.625rem;">En línea</span>
Tickets de venta
</div>
<div class="device-card__detail">USB · 192.168.10.50</div>
<div class="device-card__detail">Predeterminada para POS</div>
<div class="device-card__actions">
<button class="btn btn--ghost btn--sm">Configurar</button>
<button class="btn btn--ghost btn--sm">Test</button>
</div>
</div>
</div>
<div class="device-card">
<div class="device-card__icon">
<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
</div>
<div class="device-card__body">
<div class="device-card__name">Zebra GK420d</div>
<div class="device-card__detail">
<span class="badge badge--ok" style="padding: 0 4px; font-size: 0.625rem;">En línea</span>
Etiquetas de código de barras
</div>
<div class="device-card__detail">USB · 192.168.10.51</div>
<div class="device-card__detail">Predeterminada para inventario</div>
<div class="device-card__actions">
<button class="btn btn--ghost btn--sm">Configurar</button>
<button class="btn btn--ghost btn--sm">Test</button>
</div>
</div>
</div>
<div class="device-card">
<div class="device-card__icon" style="background: rgba(115,115,115,.12);">
<svg viewBox="0 0 24 24" style="stroke: var(--color-text-muted);"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
</div>
<div class="device-card__body">
<div class="device-card__name">HP LaserJet Pro M404</div>
<div class="device-card__detail">
<span class="badge badge--inactive" style="padding: 0 4px; font-size: 0.625rem;">Fuera de línea</span>
Facturas y reportes
</div>
<div class="device-card__detail">Red · 192.168.10.52</div>
<div class="device-card__actions">
<button class="btn btn--ghost btn--sm">Configurar</button>
<button class="btn btn--ghost btn--sm">Reintentar</button>
</div>
<div class="device-grid" id="printerGrid">
<div class="device-card" style="border-style:dashed;text-align:center;color:var(--color-text-muted);padding:var(--space-8);">
<div style="font-size:2rem;margin-bottom:var(--space-3);">🖨️</div>
<div>Sin impresoras configuradas</div>
<div style="font-size:var(--text-caption);margin-top:var(--space-2);">
La configuracion de impresoras se hace desde el navegador.<br>
Ve a <strong>chrome://devices</strong> o usa <strong>Ctrl+P</strong> para imprimir.
</div>
</div>
</div>
@@ -1595,41 +1551,41 @@
<div class="form-grid">
<div class="form-group">
<label class="form-label">Tasa IVA (%)</label>
<input class="form-input" type="number" value="16" />
<input class="form-input" id="tax-iva" type="number" value="16" step="1" min="0" max="100" />
</div>
<div class="form-group">
<label class="form-label">Tasa IEPS (%)</label>
<input class="form-input" type="number" value="0" />
<input class="form-input" id="tax-ieps" type="number" value="0" step="1" min="0" />
<span class="form-hint">Dejar en 0 si no aplica</span>
</div>
<div class="form-group">
<label class="form-label">Serie de Facturación</label>
<input class="form-input" type="text" value="FA" />
<input class="form-input" id="tax-serie" type="text" value="FA" maxlength="10" style="text-transform:uppercase;" />
</div>
<div class="form-group">
<label class="form-label">Folio Actual</label>
<input class="form-input" type="number" value="893" />
<input class="form-input" id="tax-folio" type="number" value="1" min="1" />
</div>
<div class="form-group">
<label class="form-label">Moneda Predeterminada</label>
<select class="form-select">
<option selected>MXN — Peso Mexicano</option>
<option>USD — Dólar Americano</option>
<select class="form-select" id="tax-moneda">
<option value="MXN">MXN — Peso Mexicano</option>
<option value="USD">USD — Dólar Americano</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Forma de Pago Default</label>
<select class="form-select">
<option selected>01 — Efectivo</option>
<option>03 — Transferencia Electrónica</option>
<option>04 — Tarjeta de Crédito</option>
<option>28 — Tarjeta de Débito</option>
<option>99 — Por Definir</option>
<select class="form-select" id="tax-forma-pago">
<option value="01">01 — Efectivo</option>
<option value="03">03 — Transferencia Electrónica</option>
<option value="04">04 — Tarjeta de Crédito</option>
<option value="28">28 — Tarjeta de Débito</option>
<option value="99">99 — Por Definir</option>
</select>
</div>
</div>
<div class="btn-group">
<button class="btn btn--primary btn--sm">Guardar Parámetros</button>
<button class="btn btn--primary btn--sm" onclick="Config.saveTaxParams()">Guardar Parámetros</button>
</div>
</div>
</div>
@@ -1926,6 +1882,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/kiosk.js"></script>
<script src="/pos/static/js/config.js"></script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Autoparts — Clientes</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -1721,13 +1722,41 @@
<div class="page-header__subtitle">Directorio, crédito y historial de compras</div>
</div>
<div class="page-header__actions">
<button class="btn btn-ghost">
<button class="btn btn-ghost" onclick="openCustomerFilters(this)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 5h12M4 8h8M6 11h4"/></svg>
Filtros
</button>
<button class="btn btn-ghost">
<script>
function openCustomerFilters(btn) {
var table = document.querySelector('table');
if (!table) { showToast('Carga la lista de clientes primero', 'warn'); return; }
// Auto-detect columns: look at headers to find the right indexes
var ths = table.querySelectorAll('thead th');
var colMap = {};
ths.forEach(function(th, i) {
var t = th.textContent.trim().toLowerCase();
if (t.indexOf('tipo') !== -1 || t.indexOf('tier') !== -1) colMap.tipo = i;
if (t.indexOf('ciudad') !== -1 || t.indexOf('city') !== -1) colMap.ciudad = i;
if (t.indexOf('crédito') !== -1 || t.indexOf('credito') !== -1 || t.indexOf('credit') !== -1) colMap.credito = i;
if (t.indexOf('status') !== -1 || t.indexOf('estado') !== -1) colMap.status = i;
});
var filters = [];
if (colMap.tipo !== undefined) filters.push({label:'Tipo', column: colMap.tipo, values: getUniqueColumnValues(table, colMap.tipo)});
if (colMap.credito !== undefined) filters.push({label:'Crédito', column: colMap.credito, values: getUniqueColumnValues(table, colMap.credito)});
if (colMap.ciudad !== undefined) filters.push({label:'Ciudad', column: colMap.ciudad, values: getUniqueColumnValues(table, colMap.ciudad)});
if (colMap.status !== undefined) filters.push({label:'Estado', column: colMap.status, values: getUniqueColumnValues(table, colMap.status)});
if (filters.length === 0) {
// Fallback: use first 3 columns
for (var i = 1; i < Math.min(4, ths.length); i++) {
filters.push({label: ths[i].textContent.trim(), column: i, values: getUniqueColumnValues(table, i)});
}
}
toggleFilterPanel(btn, filters);
}
</script>
<button class="btn btn-ghost" onclick="exportVisibleTableCSV('clientes')">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 10v3a1 1 0 01-1 1H3a1 1 0 01-1-1v-3M8 1v9M4 6l4 4 4-4"/></svg>
Exportar
Exportar CSV
</button>
<button class="btn btn-primary" onclick="openNewCustomerModal()">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2"><line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/></svg>
@@ -2149,6 +2178,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/customers.js"></script>
<script src="/pos/static/js/offline-banner.js"></script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Autoparts — Dashboard</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -1687,6 +1688,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/dashboard.js"></script>
<script src="/pos/static/js/sync-engine.js"></script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Diagramas — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
@@ -606,6 +607,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/kiosk.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/diagrams.js"></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flotillas — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -964,7 +965,8 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/fleet.js"></script>
<script src="/pos/static/js/offline-banner.js"></script>
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inventario — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -1468,9 +1469,9 @@
<h1 class="page-header__title">Inventario</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost" onclick="alert('Exportar: próximamente')">
<button class="btn btn--ghost" onclick="exportVisibleTableCSV('inventario')">
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Exportar
Exportar CSV
</button>
<button class="btn btn--ghost" onclick="loadItems(1,'')">
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg>
@@ -1585,14 +1586,24 @@
<option>OK</option><option>Bajo</option><option>Sobrestock</option>
</select>
<div class="toolbar__spacer"></div>
<button class="btn btn--ghost btn--sm">
<button class="btn btn--ghost btn--sm" onclick="openInventoryFilters(this)">
<svg viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
Filtros
</button>
<button class="btn btn--ghost btn--sm">
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
Columnas
</button>
<script>
function openInventoryFilters(btn) {
var table = document.querySelector('table');
if (!table) { showToast('Carga el inventario primero', 'warn'); return; }
var brands = getUniqueColumnValues(table, 3); // brand column
var categories = getUniqueColumnValues(table, 4); // category column
var statuses = getUniqueColumnValues(table, 5); // stock status column
toggleFilterPanel(btn, [
{label: 'Marca', column: 3, values: brands},
{label: 'Categoría', column: 4, values: categories},
{label: 'Estado Stock', column: 5, values: statuses},
]);
}
</script>
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Nuevo Producto
@@ -2097,6 +2108,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/inventory.js"></script>
<script src="/pos/static/js/offline-banner.js"></script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Facturación CFDI — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -1516,15 +1517,43 @@
<h1 class="page-header__title">Facturación CFDI</h1>
</div>
<div class="page-header__actions">
<button class="btn btn--ghost">
<button class="btn btn--ghost" onclick="openInvoiceFilters(this)">
<svg viewBox="0 0 24 24"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
Filtros
</button>
<button class="btn btn--ghost" onclick="exportVisibleTableCSV('facturacion')">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Exportar
Exportar CSV
</button>
<button class="btn btn--secondary" onclick="window.notaCreditoPlaceholder()">
<script>
function openInvoiceFilters(btn) {
var table = document.querySelector('table');
if (!table) { showToast('Carga las facturas primero', 'warn'); return; }
var ths = table.querySelectorAll('thead th');
var colMap = {};
ths.forEach(function(th, i) {
var t = th.textContent.trim().toLowerCase();
if (t.indexOf('status') !== -1 || t.indexOf('estado') !== -1) colMap.status = i;
if (t.indexOf('cliente') !== -1 || t.indexOf('receptor') !== -1) colMap.cliente = i;
if (t.indexOf('tipo') !== -1) colMap.tipo = i;
});
var filters = [];
if (colMap.status !== undefined) filters.push({label:'Estado', column: colMap.status, values: getUniqueColumnValues(table, colMap.status)});
if (colMap.tipo !== undefined) filters.push({label:'Tipo', column: colMap.tipo, values: getUniqueColumnValues(table, colMap.tipo)});
if (colMap.cliente !== undefined) filters.push({label:'Cliente', column: colMap.cliente, values: getUniqueColumnValues(table, colMap.cliente, 15)});
if (filters.length === 0) {
for (var i = 1; i < Math.min(3, ths.length); i++) {
filters.push({label: ths[i].textContent.trim(), column: i, values: getUniqueColumnValues(table, i)});
}
}
toggleFilterPanel(btn, filters);
}
</script>
<button class="btn btn--secondary" onclick="showToast('Nota de Crédito requiere integración SAT — disponible en siguiente actualización', 'info')">
<svg viewBox="0 0 24 24">
<path d="M9 14l-4-4 4-4"/>
<path d="M5 10h11a4 4 0 0 1 0 8h-1"/>
@@ -2359,6 +2388,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/invoicing.js"></script>
<script src="/pos/static/js/sync-engine.js"></script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Autoparts — Iniciar Sesión</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
<style>

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nexus Autoparts — Punto de Venta</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="stylesheet" href="/pos/static/css/chat.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -1481,6 +1482,7 @@
================================================================ -->
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/kiosk.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/push.js"></script>
<script src="/pos/static/js/printer.js"></script>

View File

@@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="es" data-theme="industrial">
<head>
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cotizaciones — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-body); background: var(--color-bg-base); color: var(--color-text-primary); min-height: 100vh; }
.page { max-width: 1200px; margin: 0 auto; padding: var(--space-6); margin-left: 240px; }
@media (max-width: 1023px) { .page { margin-left: 0; } }
.page-title { font-family: var(--font-heading); font-size: var(--text-h3); margin-bottom: var(--space-6); }
.quote-table { width: 100%; border-collapse: collapse; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--radius-lg); overflow: hidden; }
.quote-table th, .quote-table td { padding: var(--space-3) var(--space-4); text-align: left; border-bottom: 1px solid var(--glass-border); }
.quote-table th { background: var(--glass-bg-strong); font-family: var(--font-mono); font-size: var(--text-caption); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); }
.quote-table tbody tr { cursor: pointer; transition: background 0.15s; }
.quote-table tbody tr:hover { background: var(--glass-highlight); }
.badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; }
.badge--active { background: rgba(63,185,80,0.15); color: #3FB950; }
.badge--converted { background: rgba(0,212,255,0.15); color: #00D4FF; }
.badge--cancelled { background: rgba(248,81,73,0.15); color: #F85149; }
.badge--expired { background: rgba(130,130,130,0.2); color: #888; }
.badge--wa { background: rgba(37,211,102,0.15); color: #25D366; }
.badge--pos { background: var(--color-primary-muted); color: var(--color-text-accent); }
.modal-overlay { display:none; position:fixed; inset:0; z-index:1000; background:var(--overlay-backdrop); backdrop-filter:blur(4px); align-items:flex-start; justify-content:center; padding:var(--space-8) var(--space-4); overflow-y:auto; }
.modal-overlay.open { display:flex; }
.modal-content { background:var(--glass-bg-strong); backdrop-filter:blur(24px); border:1px solid var(--glass-border); border-radius:var(--radius-lg); max-width:650px; width:100%; padding:var(--space-6); position:relative; }
.modal-close { position:absolute; top:var(--space-3); right:var(--space-3); background:none; border:none; color:var(--color-text-muted); font-size:1.4rem; cursor:pointer; }
.detail-table { width:100%; border-collapse:collapse; margin:var(--space-4) 0; }
.detail-table th, .detail-table td { padding:var(--space-2) var(--space-3); text-align:left; border-bottom:1px solid var(--glass-border); font-size:var(--text-body-sm); }
.detail-table th { color:var(--color-text-muted); font-size:var(--text-caption); text-transform:uppercase; }
.empty { text-align:center; padding:var(--space-12); color:var(--color-text-muted); }
</style>
</head>
<body>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<div class="page">
<h1 class="page-title">Cotizaciones</h1>
<div id="quoteList">Cargando...</div>
</div>
<div class="modal-overlay" id="quoteModal">
<div class="modal-content">
<button class="modal-close" onclick="document.getElementById('quoteModal').classList.remove('open')">&times;</button>
<div id="quoteDetail">Cargando...</div>
</div>
</div>
<script>
(function() {
var token = localStorage.getItem('pos_token');
if (!token) { window.location.href = '/pos/login'; return; }
var API = '/pos/api';
function headers() { return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; }
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function fmt(n) { return (n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
function loadQuotes() {
fetch(API + '/quotations?per_page=50', { headers: headers() })
.then(function(r) { return r.json(); })
.then(function(d) {
var quotes = d.data || [];
if (!quotes.length) {
document.getElementById('quoteList').innerHTML = '<div class="empty"><h3>Sin cotizaciones</h3><p>Las cotizaciones creadas desde el POS (F4) o desde WhatsApp aparecen aqui.</p></div>';
return;
}
var html = '<table class="quote-table"><thead><tr>';
html += '<th>#</th><th>Origen</th><th>Cliente</th><th>Total</th><th>Estado</th><th>Fecha</th><th></th>';
html += '</tr></thead><tbody>';
quotes.forEach(function(q) {
var srcBadge = q.source === 'whatsapp'
? '<span class="badge badge--wa">📱 WA</span>'
: '<span class="badge badge--pos">🖥️ POS</span>';
var statusBadge = '<span class="badge badge--' + q.status + '">' + q.status + '</span>';
var client = q.customer_name || (q.wa_phone ? '📱 ' + q.wa_phone : 'Sin cliente');
var dateStr = q.created_at ? new Date(q.created_at).toLocaleDateString('es-MX') : '';
html += '<tr>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;"><strong>#' + q.id + '</strong></td>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + srcBadge + '</td>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + esc(client) + '</td>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;font-family:var(--font-mono);font-weight:700;">$' + fmt(q.total) + '</td>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;">' + statusBadge + '</td>';
html += '<td onclick="openQuote(' + q.id + ')" style="cursor:pointer;color:var(--color-text-muted);">' + dateStr + '</td>';
html += '<td><button onclick="deleteQuote(' + q.id + ', event)" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:16px;padding:4px 8px;border-radius:4px;" onmouseover="this.style.color=\'#F85149\';this.style.background=\'rgba(248,81,73,0.1)\'" onmouseout="this.style.color=\'var(--color-text-muted)\';this.style.background=\'none\'">🗑️</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
document.getElementById('quoteList').innerHTML = html;
})
.catch(function() {
document.getElementById('quoteList').innerHTML = '<div class="empty">Error cargando cotizaciones</div>';
});
}
window.openQuote = function(id) {
var modal = document.getElementById('quoteModal');
modal.classList.add('open');
document.getElementById('quoteDetail').innerHTML = 'Cargando...';
fetch(API + '/quotations/' + id, { headers: headers() })
.then(function(r) { return r.json(); })
.then(function(q) {
if (q.error) { document.getElementById('quoteDetail').innerHTML = 'Error: ' + esc(q.error); return; }
var src = (q.notes || '').startsWith('WA:') ? 'WhatsApp' : 'POS';
var waPhone = src === 'WhatsApp' ? q.notes.replace('WA:', '') : null;
var html = '<h3 style="font-family:var(--font-heading);margin-bottom:var(--space-4);">Cotización #' + q.id + '</h3>';
html += '<div style="display:flex;gap:var(--space-6);margin-bottom:var(--space-4);font-size:var(--text-body-sm);">';
html += '<div><span style="color:var(--color-text-muted);">Origen:</span> ' + src + '</div>';
if (waPhone) html += '<div><span style="color:var(--color-text-muted);">WhatsApp:</span> +' + esc(waPhone) + '</div>';
if (q.customer_name) html += '<div><span style="color:var(--color-text-muted);">Cliente:</span> ' + esc(q.customer_name) + '</div>';
html += '<div><span style="color:var(--color-text-muted);">Estado:</span> <span class="badge badge--' + q.status + '">' + q.status + '</span></div>';
html += '<div><span style="color:var(--color-text-muted);">Vigencia:</span> ' + (q.valid_until || '—') + '</div>';
html += '</div>';
html += '<table class="detail-table"><thead><tr><th>#Parte</th><th>Nombre</th><th>Cant</th><th>Precio</th><th>Subtotal</th></tr></thead><tbody>';
(q.items || []).forEach(function(it) {
html += '<tr>';
html += '<td style="font-family:var(--font-mono);">' + esc(it.part_number) + '</td>';
html += '<td>' + esc(it.name) + '</td>';
html += '<td>' + it.quantity + '</td>';
html += '<td>$' + fmt(it.unit_price) + '</td>';
html += '<td style="font-weight:700;">$' + fmt(it.subtotal) + '</td>';
html += '</tr>';
});
html += '</tbody></table>';
html += '<div style="text-align:right;margin-top:var(--space-4);font-size:var(--text-body);">';
html += '<div>Subtotal: $' + fmt(q.subtotal) + '</div>';
html += '<div>IVA: $' + fmt(q.tax_total) + '</div>';
html += '<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-text-accent);">Total: $' + fmt(q.total) + '</div>';
html += '</div>';
html += '<div style="margin-top:var(--space-5);display:flex;gap:var(--space-3);justify-content:flex-end;">';
html += '<button class="btn btn--ghost" onclick="deleteQuote(' + q.id + ')" style="color:#F85149;">Eliminar</button>';
html += '<button class="btn btn--ghost" onclick="exportVisibleTableCSV(\'cotizacion_' + q.id + '\')">Exportar CSV</button>';
html += '<button class="btn btn--ghost" onclick="window.print()">Imprimir</button>';
html += '</div>';
document.getElementById('quoteDetail').innerHTML = html;
});
};
window.deleteQuote = function(id, event) {
if (event) event.stopPropagation();
if (!confirm('¿Eliminar cotización #' + id + '? Esta acción no se puede deshacer.')) return;
fetch(API + '/quotations/' + id, { method: 'DELETE', headers: headers() })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) {
document.getElementById('quoteModal').classList.remove('open');
loadQuotes();
if (typeof showToast === 'function') showToast('Cotización #' + id + ' eliminada', 'ok');
} else {
alert('Error: ' + (d.error || 'desconocido'));
}
});
};
// Close modal on outside click
document.getElementById('quoteModal').addEventListener('click', function(e) {
if (e.target === this) this.classList.remove('open');
});
loadQuotes();
})();
</script>
</body>
</html>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reportes — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -1690,17 +1691,17 @@
<h1 class="content-header__title">Reportes</h1>
</div>
<div class="content-header__actions">
<button class="btn btn-ghost">
<button class="btn btn-ghost" onclick="exportReportCSV()">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
<rect x="1" y="1" width="12" height="12" rx="1"/><line x1="4" y1="5" x2="10" y2="5"/><line x1="4" y1="8" x2="8" y2="8"/>
</svg>
Exportar Excel
Exportar Excel (CSV)
</button>
<button class="btn btn-primary">
<button class="btn btn-primary" onclick="window.print()">
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14">
<rect x="2" y="1" width="10" height="12" rx="1"/><line x1="5" y1="5" x2="9" y2="5"/><line x1="5" y1="7" x2="9" y2="7"/><line x1="5" y1="9" x2="7" y2="9"/>
</svg>
Exportar PDF
Exportar PDF (Imprimir)
</button>
</div>
</div>
@@ -1848,6 +1849,7 @@
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/app-init.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/reports.js"></script>
<script src="/pos/static/js/sync-engine.js"></script>

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatsApp — Nexus Autoparts POS</title>
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
<meta name="theme-color" content="#F5A623" />
@@ -200,6 +201,24 @@
margin-top: 2px;
}
.conv-item__delete {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 16px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
padding: 2px 6px;
border-radius: 4px;
}
.conv-item:hover .conv-item__delete { opacity: 1; }
.conv-item__delete:hover { color: #F85149; background: rgba(248,81,73,0.1); }
.conv-item { position: relative; }
.conv-empty {
padding: var(--space-6);
text-align: center;
@@ -611,7 +630,8 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
<!-- Sidebar -->
<script src="/pos/static/js/i18n.js"></script>
<script src="/pos/static/js/whatsapp.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
<script src="/pos/static/js/pos-utils.js"></script>
<script src="/pos/static/js/sidebar.js"></script>
</body>
</html>