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:
@@ -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')
|
||||
|
||||
@@ -64,10 +64,12 @@ def _master_only(fn):
|
||||
@catalog_bp.route('/brands', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def brands():
|
||||
from services.catalog_modes import normalize_mode
|
||||
year_id = request.args.get('year_id', type=int)
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
def _do(master):
|
||||
data = catalog_service.get_brands(master, year_id=year_id)
|
||||
return jsonify({'data': data})
|
||||
data = catalog_service.get_brands(master, year_id=year_id, mode=mode)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
|
||||
|
||||
@@ -125,41 +127,191 @@ def engines():
|
||||
@catalog_bp.route('/categories', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def categories():
|
||||
"""Categories for a vehicle.
|
||||
|
||||
OEM mode: TecDoc part_categories (id_part_category, name).
|
||||
Local mode: 14 Nexpart top-level groups, filtered by what's available
|
||||
for this vehicle. Returns 'slug' (string) instead of integer id.
|
||||
"""
|
||||
from services.catalog_modes import normalize_mode
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_categories(master, mye_id)
|
||||
return jsonify({'data': data})
|
||||
if mode == 'local':
|
||||
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
|
||||
else:
|
||||
data = catalog_service.get_categories(master, mye_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/groups', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def groups():
|
||||
"""Subgroups for a vehicle + parent category.
|
||||
|
||||
OEM mode: TecDoc part_groups within a TecDoc part_category (integer ids).
|
||||
Local mode: Nexpart subgroups within a Nexpart group (string slugs).
|
||||
"""
|
||||
from services.catalog_modes import normalize_mode
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
category_id = request.args.get('category_id', type=int)
|
||||
if not mye_id or not category_id:
|
||||
return jsonify({'error': 'mye_id and category_id required'}), 400
|
||||
category_slug = request.args.get('category_slug')
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_groups(master, mye_id, category_id)
|
||||
return jsonify({'data': data})
|
||||
if mode == 'local':
|
||||
if not category_slug:
|
||||
return jsonify({'error': 'category_slug required for local mode'}), 400
|
||||
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug)
|
||||
else:
|
||||
if not category_id:
|
||||
return jsonify({'error': 'category_id required for oem mode'}), 400
|
||||
data = catalog_service.get_groups(master, mye_id, category_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
|
||||
|
||||
# ─── Parts with stock enrichment (master + tenant) ───
|
||||
|
||||
@catalog_bp.route('/part-types', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def part_types():
|
||||
"""Distinct part types (3rd subcategory level) for a vehicle + group/subgroup.
|
||||
|
||||
OEM mode: distinct name_part values within a TecDoc part_group_id.
|
||||
Local mode: Nexpart Part Types within a Nexpart group + subgroup.
|
||||
"""
|
||||
from services.catalog_modes import normalize_mode
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
group_id = request.args.get('group_id', type=int)
|
||||
group_slug = request.args.get('group_slug') # parent Nexpart group
|
||||
subgroup_slug = request.args.get('subgroup_slug') # current Nexpart subgroup
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
def _do(master):
|
||||
if mode == 'local':
|
||||
if not group_slug or not subgroup_slug:
|
||||
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
|
||||
data = catalog_service.get_nexpart_part_types_for_vehicle(
|
||||
master, mye_id, group_slug, subgroup_slug
|
||||
)
|
||||
else:
|
||||
if not group_id:
|
||||
return jsonify({'error': 'group_id required for oem mode'}), 400
|
||||
data = catalog_service.get_part_types(master, mye_id, group_id)
|
||||
return jsonify({'data': data, 'mode': mode})
|
||||
return _master_only(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def shop_supplies_groups():
|
||||
"""Vehicle-independent groups (Chemicals + Tires/Tools)."""
|
||||
def _do(master):
|
||||
data = catalog_service.get_shop_supplies_groups()
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/shop-supplies/subgroups', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def shop_supplies_subgroups():
|
||||
group_slug = request.args.get('group_slug')
|
||||
if not group_slug:
|
||||
return jsonify({'error': 'group_slug required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_shop_supplies_subgroups(master, group_slug)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/shop-supplies/part-types', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def shop_supplies_part_types():
|
||||
group_slug = request.args.get('group_slug')
|
||||
subgroup_slug = request.args.get('subgroup_slug')
|
||||
if not group_slug or not subgroup_slug:
|
||||
return jsonify({'error': 'group_slug and subgroup_slug required'}), 400
|
||||
def _do(master):
|
||||
data = catalog_service.get_shop_supplies_part_types(master, group_slug, subgroup_slug)
|
||||
return jsonify({'data': data})
|
||||
return _master_only(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/shop-supplies/parts', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def shop_supplies_parts():
|
||||
group_slug = request.args.get('group_slug')
|
||||
subgroup_slug = request.args.get('subgroup_slug')
|
||||
part_type_slug = request.args.get('part_type_slug')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 30, type=int)
|
||||
if not group_slug or not subgroup_slug or not part_type_slug:
|
||||
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
|
||||
def _do(master, tenant, branch_id):
|
||||
result = catalog_service.get_shop_supplies_parts(
|
||||
master, group_slug, subgroup_slug, part_type_slug,
|
||||
tenant, branch_id, page, per_page,
|
||||
)
|
||||
return jsonify(result)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@catalog_bp.route('/parts', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def parts():
|
||||
"""Parts list for the deepest navigation level.
|
||||
|
||||
Three call shapes (the endpoint chooses based on which params are present):
|
||||
|
||||
A) OEM mode legacy:
|
||||
?mode=oem&mye_id=&group_id=&part_type=...
|
||||
B) Local mode legacy (TecDoc-style):
|
||||
?mode=local&mye_id=&group_id=&part_type=...
|
||||
C) Local mode Nexpart navigation (NEW):
|
||||
?mode=local&mye_id=&nexpart_group=&nexpart_subgroup=&nexpart_part_type=
|
||||
"""
|
||||
from services.catalog_modes import normalize_mode
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
group_id = request.args.get('group_id', type=int)
|
||||
part_type = request.args.get('part_type') # optional 3rd-level (legacy)
|
||||
|
||||
# Nexpart navigation slugs (Local mode only)
|
||||
nexpart_group = request.args.get('nexpart_group')
|
||||
nexpart_subgroup = request.args.get('nexpart_subgroup')
|
||||
nexpart_part_type = request.args.get('nexpart_part_type')
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 30, type=int)
|
||||
if not mye_id or not group_id:
|
||||
return jsonify({'error': 'mye_id and group_id required'}), 400
|
||||
mode = normalize_mode(request.args.get('mode'))
|
||||
|
||||
if not mye_id:
|
||||
return jsonify({'error': 'mye_id required'}), 400
|
||||
|
||||
use_nexpart_nav = mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type
|
||||
|
||||
if not use_nexpart_nav and not group_id:
|
||||
return jsonify({'error': 'group_id (or nexpart_group + subgroup + part_type) required'}), 400
|
||||
|
||||
def _do(master, tenant, branch_id):
|
||||
result = catalog_service.get_parts(master, mye_id, group_id, tenant, branch_id, page, per_page)
|
||||
if use_nexpart_nav:
|
||||
result = catalog_service.get_parts_for_nexpart_triple(
|
||||
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
|
||||
tenant, branch_id, page, per_page,
|
||||
)
|
||||
elif mode == 'local':
|
||||
result = catalog_service.get_parts_local(
|
||||
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||
)
|
||||
else:
|
||||
result = catalog_service.get_parts(
|
||||
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
|
||||
)
|
||||
return jsonify(result)
|
||||
return _with_conns(_do)
|
||||
|
||||
|
||||
@@ -158,6 +158,61 @@ def create_employee():
|
||||
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
||||
|
||||
|
||||
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_employee(emp_id):
|
||||
"""Update an existing employee's name, email, role, branch, discount, active status.
|
||||
If PIN is provided, it gets re-hashed. Otherwise PIN stays unchanged."""
|
||||
import bcrypt
|
||||
data = request.get_json() or {}
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Check employee exists
|
||||
cur.execute("SELECT id FROM employees WHERE id = %s", (emp_id,))
|
||||
if not cur.fetchone():
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Employee not found'}), 404
|
||||
|
||||
# Build SET clause dynamically — only update provided fields
|
||||
updates = []
|
||||
params = []
|
||||
field_map = {
|
||||
'name': 'name', 'email': 'email', 'phone': 'phone',
|
||||
'role': 'role', 'branch_id': 'branch_id',
|
||||
'max_discount_pct': 'max_discount_pct', 'is_active': 'is_active',
|
||||
}
|
||||
for json_key, col in field_map.items():
|
||||
if json_key in data:
|
||||
updates.append(f"{col} = %s")
|
||||
params.append(data[json_key])
|
||||
|
||||
# PIN update (only if provided and non-empty)
|
||||
if data.get('pin') and len(str(data['pin'])) >= 4:
|
||||
pin_hash = bcrypt.hashpw(str(data['pin']).encode(), bcrypt.gensalt()).decode()
|
||||
updates.append("pin = %s")
|
||||
params.append(pin_hash)
|
||||
updates.append("password_hash = %s")
|
||||
params.append(pin_hash)
|
||||
|
||||
if not updates:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Nothing to update'}), 400
|
||||
|
||||
params.append(emp_id)
|
||||
cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params)
|
||||
|
||||
from services.audit import log_action
|
||||
log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id,
|
||||
new_value={k: v for k, v in data.items() if k != 'pin'})
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'message': 'Employee updated'})
|
||||
|
||||
|
||||
@config_bp.route('/currency', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_currency():
|
||||
@@ -244,6 +299,42 @@ def get_business():
|
||||
})
|
||||
|
||||
|
||||
@config_bp.route('/business', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_business():
|
||||
"""Save tenant business info to tenant_config."""
|
||||
data = request.get_json() or {}
|
||||
field_map = {
|
||||
'razon_social': 'tenant_razon_social',
|
||||
'nombre': 'tenant_nombre',
|
||||
'rfc': 'tenant_rfc',
|
||||
'regimen_fiscal': 'tenant_regimen_fiscal',
|
||||
'direccion': 'tenant_direccion',
|
||||
'telefono': 'tenant_telefono',
|
||||
'email': 'tenant_email',
|
||||
# Tax params
|
||||
'tax_iva': 'tax_iva',
|
||||
'tax_ieps': 'tax_ieps',
|
||||
'invoice_serie': 'invoice_serie',
|
||||
'invoice_folio': 'invoice_folio',
|
||||
'default_currency': 'default_currency',
|
||||
'default_payment_method': 'default_payment_method',
|
||||
}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
for field, key in field_map.items():
|
||||
val = data.get(field)
|
||||
if val is not None:
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (key, str(val).strip()))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@config_bp.route('/theme', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_theme():
|
||||
|
||||
@@ -1,360 +1,336 @@
|
||||
# /home/Autopartes/pos/blueprints/marketplace_bp.py
|
||||
"""Marketplace B2B: bodegas publish inventory, talleres/refaccionarias browse and order."""
|
||||
"""
|
||||
Nexus Marketplace B2B — REST endpoints (Phase 1).
|
||||
|
||||
Routes:
|
||||
Bodegas
|
||||
GET /pos/api/marketplace/bodegas list verified bodegas
|
||||
GET /pos/api/marketplace/bodegas/<id> bodega detail
|
||||
|
||||
Inventory
|
||||
POST /pos/api/marketplace/inventory/upload bulk CSV upload (seller)
|
||||
GET /pos/api/marketplace/inventory/search browse (text/brand/city filters)
|
||||
GET /pos/api/marketplace/inventory/part/<id> bodegas stocking this part
|
||||
|
||||
Purchase Orders
|
||||
POST /pos/api/marketplace/orders create draft
|
||||
GET /pos/api/marketplace/orders/mine buyer's PO list
|
||||
GET /pos/api/marketplace/orders/inbox seller's incoming PO list
|
||||
GET /pos/api/marketplace/orders/<id> full detail
|
||||
POST /pos/api/marketplace/orders/<id>/transition state change
|
||||
|
||||
NOTE: this replaces an earlier stub that referenced now-unused tables
|
||||
(marketplace_orders, marketplace_order_items, tenants.is_seller flag).
|
||||
The Phase 1 schema uses bodegas + purchase_orders + purchase_order_items.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
|
||||
from middleware import require_auth
|
||||
from tenant_db import get_master_conn, get_tenant_conn
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from services import marketplace_service as mkt
|
||||
|
||||
|
||||
marketplace_bp = Blueprint('marketplace', __name__, url_prefix='/pos/api/marketplace')
|
||||
|
||||
|
||||
@marketplace_bp.route('/sellers', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_sellers():
|
||||
"""List active sellers/bodegas."""
|
||||
# ─── Role loader + checker ────────────────────────────────────────────────
|
||||
|
||||
def _load_marketplace_profile():
|
||||
"""Fetch the caller's marketplace_role + bodega_id from the tenant DB
|
||||
and attach to flask.g. Call AFTER @require_auth. Idempotent."""
|
||||
if hasattr(g, 'marketplace_loaded'):
|
||||
return
|
||||
g.marketplace_role = 'buyer'
|
||||
g.marketplace_bodega_id = None
|
||||
try:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT marketplace_role, bodega_id FROM employees WHERE id = %s",
|
||||
(g.employee_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
g.marketplace_role = row[0] or 'buyer'
|
||||
g.marketplace_bodega_id = row[1]
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f'[marketplace] failed to load role: {e}')
|
||||
g.marketplace_loaded = True
|
||||
|
||||
|
||||
def require_marketplace_role(*allowed_roles):
|
||||
"""Decorator: only allow users whose marketplace_role is in the allowed list.
|
||||
Must be applied AFTER @require_auth()."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
_load_marketplace_profile()
|
||||
if g.marketplace_role not in allowed_roles:
|
||||
return jsonify({
|
||||
'error': f'Marketplace role {g.marketplace_role} cannot access this endpoint',
|
||||
'required': list(allowed_roles),
|
||||
}), 403
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
return decorator
|
||||
|
||||
|
||||
def _with_master(f):
|
||||
"""Open a master connection, run f(master_conn), always close."""
|
||||
conn = get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, name, subdomain, rfc
|
||||
FROM tenants
|
||||
WHERE is_active = true AND is_seller = true
|
||||
ORDER BY name
|
||||
""")
|
||||
sellers = []
|
||||
for r in cur.fetchall():
|
||||
sellers.append({'id': r[0], 'name': r[1], 'subdomain': r[2], 'rfc': r[3]})
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'data': sellers})
|
||||
try:
|
||||
return f(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@marketplace_bp.route('/search', methods=['GET'])
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# BODEGAS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_bp.route('/whoami', methods=['GET'])
|
||||
@require_auth()
|
||||
def whoami():
|
||||
"""Return the current user's marketplace profile (role, bodega_id, etc.)."""
|
||||
_load_marketplace_profile()
|
||||
return jsonify({
|
||||
'employee_id': g.employee_id,
|
||||
'employee_name': g.employee_name,
|
||||
'tenant_id': g.tenant_id,
|
||||
'marketplace_role': g.marketplace_role,
|
||||
'bodega_id': g.marketplace_bodega_id,
|
||||
})
|
||||
|
||||
|
||||
@marketplace_bp.route('/bodegas', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_bodegas():
|
||||
verified_only = request.args.get('verified_only', 'true').lower() != 'false'
|
||||
city = request.args.get('city')
|
||||
def _do(master):
|
||||
return jsonify({'data': mkt.list_bodegas(master, verified_only=verified_only, city=city)})
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
@marketplace_bp.route('/bodegas/<int:bodega_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_bodega(bodega_id):
|
||||
def _do(master):
|
||||
b = mkt.get_bodega(master, bodega_id)
|
||||
if not b:
|
||||
return jsonify({'error': 'Bodega not found'}), 404
|
||||
return jsonify(b)
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# INVENTORY
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_bp.route('/inventory/upload', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_marketplace_role('seller', 'admin')
|
||||
def upload_inventory():
|
||||
"""CSV bulk upload for a bodega's warehouse inventory.
|
||||
|
||||
Body options:
|
||||
multipart/form-data with file field 'file'
|
||||
OR
|
||||
application/json with {bodega_id, csv} (admin override)
|
||||
"""
|
||||
# Sellers upload to THEIR bodega; admin can upload to any.
|
||||
if g.marketplace_role == 'seller':
|
||||
bodega_id = g.marketplace_bodega_id
|
||||
if not bodega_id:
|
||||
return jsonify({'error': 'Seller has no bodega_id assigned'}), 400
|
||||
else:
|
||||
body = request.get_json(silent=True) or {}
|
||||
bodega_id = int(body.get('bodega_id') or 0)
|
||||
if not bodega_id:
|
||||
return jsonify({'error': 'bodega_id required for admin upload'}), 400
|
||||
|
||||
# Read CSV from either multipart file or JSON body
|
||||
csv_text = None
|
||||
if 'file' in request.files:
|
||||
csv_text = request.files['file'].read().decode('utf-8', errors='ignore')
|
||||
else:
|
||||
body = request.get_json(silent=True) or {}
|
||||
csv_text = body.get('csv')
|
||||
if not csv_text:
|
||||
return jsonify({'error': 'CSV payload required (file upload or JSON csv field)'}), 400
|
||||
|
||||
def _do(master):
|
||||
result = mkt.upload_inventory_csv(master, bodega_id, csv_text)
|
||||
return jsonify(result)
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
@marketplace_bp.route('/inventory/search', methods=['GET'])
|
||||
@require_auth()
|
||||
def search_inventory():
|
||||
"""Search across ALL seller tenant inventories.
|
||||
|
||||
Query params:
|
||||
q: search term (required, min 2 chars)
|
||||
seller_id: optional filter by specific seller
|
||||
page: page number (default 1)
|
||||
per_page: results per page (default 50, max 200)
|
||||
"""
|
||||
q = request.args.get('q', '').strip()
|
||||
if len(q) < 2:
|
||||
return jsonify({'error': 'Search query must be at least 2 characters'}), 400
|
||||
|
||||
seller_id = request.args.get('seller_id')
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# Get all seller tenants
|
||||
master = get_master_conn()
|
||||
mcur = master.cursor()
|
||||
|
||||
if seller_id:
|
||||
mcur.execute("""
|
||||
SELECT id, name, db_name FROM tenants
|
||||
WHERE is_active = true AND is_seller = true AND id = %s
|
||||
""", (seller_id,))
|
||||
else:
|
||||
mcur.execute("""
|
||||
SELECT id, name, db_name FROM tenants
|
||||
WHERE is_active = true AND is_seller = true
|
||||
ORDER BY name
|
||||
""")
|
||||
|
||||
sellers = mcur.fetchall()
|
||||
mcur.close()
|
||||
master.close()
|
||||
|
||||
results = []
|
||||
search_pattern = f'%{q}%'
|
||||
|
||||
for s_id, s_name, db_name in sellers:
|
||||
try:
|
||||
conn = get_tenant_conn(s_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.tax_rate, i.unit,
|
||||
COALESCE(s.stock, 0) AS stock
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS stock
|
||||
FROM inventory_operations GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = true
|
||||
AND COALESCE(s.stock, 0) > 0
|
||||
AND (i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s)
|
||||
ORDER BY i.name
|
||||
LIMIT %s
|
||||
""", (search_pattern, search_pattern, search_pattern, per_page))
|
||||
|
||||
for r in cur.fetchall():
|
||||
results.append({
|
||||
'seller_id': s_id,
|
||||
'seller_name': s_name,
|
||||
'part_number': r[0],
|
||||
'name': r[1],
|
||||
'brand': r[2],
|
||||
'price': float(r[3]) if r[3] else 0,
|
||||
'tax_rate': float(r[4]) if r[4] else 0.16,
|
||||
'unit': r[5] or 'PZA',
|
||||
'stock': r[6],
|
||||
})
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception:
|
||||
# Skip tenants with connection issues
|
||||
continue
|
||||
|
||||
# Sort all results by name, then paginate
|
||||
results.sort(key=lambda x: x['name'])
|
||||
total = len(results)
|
||||
paged = results[offset:offset + per_page]
|
||||
|
||||
return jsonify({
|
||||
'data': paged,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': total,
|
||||
'pages': (total + per_page - 1) // per_page if per_page else 1,
|
||||
}
|
||||
})
|
||||
q = request.args.get('q')
|
||||
brand = request.args.get('brand')
|
||||
city = request.args.get('city')
|
||||
limit = min(request.args.get('limit', 50, type=int), 200)
|
||||
def _do(master):
|
||||
data = mkt.search_inventory(master, query=q, brand=brand, city=city, limit=limit)
|
||||
return jsonify({'data': data, 'count': len(data)})
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
@marketplace_bp.route('/order', methods=['POST'])
|
||||
@marketplace_bp.route('/inventory/part/<int:part_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def bodegas_with_part(part_id):
|
||||
def _do(master):
|
||||
data = mkt.get_bodegas_with_part(master, part_id)
|
||||
return jsonify({'data': data, 'count': len(data)})
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PURCHASE ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@marketplace_bp.route('/orders', methods=['POST'])
|
||||
@require_auth()
|
||||
@require_marketplace_role('buyer', 'admin')
|
||||
def create_order():
|
||||
"""Create a marketplace order from buyer to seller.
|
||||
"""Create a new PO in draft status.
|
||||
|
||||
Body:
|
||||
seller_id: int (required)
|
||||
items: [{ part_number, part_name, quantity, unit_price }] (required)
|
||||
notes: str (optional)
|
||||
{
|
||||
"bodega_id": 1,
|
||||
"items": [{"part_id": 123, "quantity": 2, "unit_price": 150}, ...],
|
||||
"delivery_method": "pickup",
|
||||
"delivery_address": "...",
|
||||
"buyer_notes": "..."
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
seller_id = data.get('seller_id')
|
||||
items = data.get('items', [])
|
||||
body = request.get_json() or {}
|
||||
bodega_id = int(body.get('bodega_id') or 0)
|
||||
items = body.get('items') or []
|
||||
|
||||
if not seller_id:
|
||||
return jsonify({'error': 'seller_id required'}), 400
|
||||
if not bodega_id:
|
||||
return jsonify({'error': 'bodega_id required'}), 400
|
||||
if not items:
|
||||
return jsonify({'error': 'items required (non-empty array)'}), 400
|
||||
return jsonify({'error': 'At least one item required'}), 400
|
||||
|
||||
buyer_id = g.tenant_id
|
||||
|
||||
# Get buyer and seller names
|
||||
master = get_master_conn()
|
||||
mcur = master.cursor()
|
||||
mcur.execute("SELECT name FROM tenants WHERE id = %s", (buyer_id,))
|
||||
buyer_row = mcur.fetchone()
|
||||
mcur.execute("SELECT name FROM tenants WHERE id = %s AND is_seller = true AND is_active = true", (seller_id,))
|
||||
seller_row = mcur.fetchone()
|
||||
mcur.close()
|
||||
|
||||
if not buyer_row:
|
||||
master.close()
|
||||
return jsonify({'error': 'Buyer tenant not found'}), 404
|
||||
if not seller_row:
|
||||
master.close()
|
||||
return jsonify({'error': 'Seller not found or not active'}), 404
|
||||
|
||||
buyer_name = buyer_row[0]
|
||||
seller_name = seller_row[0]
|
||||
|
||||
# Calculate total
|
||||
total = 0
|
||||
for item in items:
|
||||
qty = item.get('quantity', 0)
|
||||
price = item.get('unit_price', 0)
|
||||
item['subtotal'] = round(qty * price, 2)
|
||||
total += item['subtotal']
|
||||
|
||||
mcur2 = master.cursor()
|
||||
mcur2.execute("""
|
||||
INSERT INTO marketplace_orders (buyer_tenant_id, seller_tenant_id, buyer_name, seller_name, total, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id
|
||||
""", (buyer_id, seller_id, buyer_name, seller_name, round(total, 2), data.get('notes')))
|
||||
order_id = mcur2.fetchone()[0]
|
||||
|
||||
for item in items:
|
||||
mcur2.execute("""
|
||||
INSERT INTO marketplace_order_items (order_id, part_number, part_name, quantity, unit_price, subtotal)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""", (order_id, item.get('part_number'), item.get('part_name'),
|
||||
item.get('quantity', 0), item.get('unit_price', 0), item.get('subtotal', 0)))
|
||||
|
||||
master.commit()
|
||||
mcur2.close()
|
||||
master.close()
|
||||
|
||||
return jsonify({'id': order_id, 'total': round(total, 2), 'message': 'Order created'}), 201
|
||||
def _do(master):
|
||||
try:
|
||||
po_id = mkt.create_po_draft(
|
||||
master,
|
||||
buyer_tenant_id=g.tenant_id,
|
||||
buyer_user_id=g.employee_id,
|
||||
buyer_display_name=g.employee_name,
|
||||
buyer_phone=body.get('buyer_phone'),
|
||||
buyer_email=body.get('buyer_email'),
|
||||
bodega_id=bodega_id,
|
||||
items=items,
|
||||
delivery_method=body.get('delivery_method', 'pickup'),
|
||||
delivery_address=body.get('delivery_address'),
|
||||
buyer_notes=body.get('buyer_notes'),
|
||||
)
|
||||
return jsonify({'ok': True, 'po_id': po_id}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
@marketplace_bp.route('/orders', methods=['GET'])
|
||||
@marketplace_bp.route('/orders/mine', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_orders():
|
||||
"""List marketplace orders (as buyer or seller).
|
||||
def my_orders():
|
||||
"""Buyer view: POs this tenant (or user) created."""
|
||||
only_mine = request.args.get('only_mine', 'true').lower() != 'false'
|
||||
def _do(master):
|
||||
data = mkt.list_pos_for_buyer(
|
||||
master,
|
||||
buyer_tenant_id=g.tenant_id,
|
||||
buyer_user_id=g.employee_id if only_mine else None,
|
||||
)
|
||||
return jsonify({'data': data, 'count': len(data)})
|
||||
return _with_master(_do)
|
||||
|
||||
Query params:
|
||||
role: 'buyer' or 'seller' (default: both)
|
||||
status: filter by status
|
||||
page: page number
|
||||
per_page: results per page
|
||||
"""
|
||||
tenant_id = g.tenant_id
|
||||
role = request.args.get('role', '')
|
||||
status = request.args.get('status', '')
|
||||
page = int(request.args.get('page', 1))
|
||||
per_page = min(int(request.args.get('per_page', 50)), 200)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
master = get_master_conn()
|
||||
mcur = master.cursor()
|
||||
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if role == 'buyer':
|
||||
where_clauses.append("buyer_tenant_id = %s")
|
||||
params.append(tenant_id)
|
||||
elif role == 'seller':
|
||||
where_clauses.append("seller_tenant_id = %s")
|
||||
params.append(tenant_id)
|
||||
@marketplace_bp.route('/orders/inbox', methods=['GET'])
|
||||
@require_auth()
|
||||
@require_marketplace_role('seller', 'admin')
|
||||
def seller_inbox():
|
||||
"""Seller view: incoming POs for this bodega."""
|
||||
if g.marketplace_role == 'seller':
|
||||
bodega_id = g.marketplace_bodega_id
|
||||
else:
|
||||
where_clauses.append("(buyer_tenant_id = %s OR seller_tenant_id = %s)")
|
||||
params.extend([tenant_id, tenant_id])
|
||||
|
||||
if status:
|
||||
where_clauses.append("status = %s")
|
||||
params.append(status)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
|
||||
mcur.execute(f"SELECT count(*) FROM marketplace_orders WHERE {where}", params)
|
||||
total = mcur.fetchone()[0]
|
||||
|
||||
mcur.execute(f"""
|
||||
SELECT id, buyer_tenant_id, seller_tenant_id, buyer_name, seller_name,
|
||||
total, status, notes, created_at, updated_at
|
||||
FROM marketplace_orders
|
||||
WHERE {where}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params + [per_page, offset])
|
||||
|
||||
orders = []
|
||||
for r in mcur.fetchall():
|
||||
orders.append({
|
||||
'id': r[0], 'buyer_tenant_id': r[1], 'seller_tenant_id': r[2],
|
||||
'buyer_name': r[3], 'seller_name': r[4],
|
||||
'total': float(r[5]) if r[5] else 0,
|
||||
'status': r[6], 'notes': r[7],
|
||||
'created_at': str(r[8]), 'updated_at': str(r[9]),
|
||||
})
|
||||
|
||||
mcur.close()
|
||||
master.close()
|
||||
|
||||
return jsonify({
|
||||
'data': orders,
|
||||
'pagination': {
|
||||
'page': page, 'per_page': per_page,
|
||||
'total': total, 'pages': (total + per_page - 1) // per_page if per_page else 1,
|
||||
}
|
||||
})
|
||||
bodega_id = int(request.args.get('bodega_id') or 0)
|
||||
if not bodega_id:
|
||||
return jsonify({'error': 'bodega_id required'}), 400
|
||||
def _do(master):
|
||||
data = mkt.list_pos_for_seller(master, bodega_id)
|
||||
return jsonify({'data': data, 'count': len(data)})
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
@marketplace_bp.route('/orders/<int:order_id>/status', methods=['PUT'])
|
||||
@marketplace_bp.route('/orders/<int:po_id>', methods=['GET'])
|
||||
@require_auth()
|
||||
def update_order_status(order_id):
|
||||
"""Update order status. Seller can confirm/ship/deliver/cancel. Buyer can cancel if pending.
|
||||
def get_order(po_id):
|
||||
"""PO detail — buyer sees their tenant's POs, seller sees their bodega's."""
|
||||
_load_marketplace_profile()
|
||||
def _do(master):
|
||||
po = mkt.get_po_detail(master, po_id)
|
||||
if not po:
|
||||
return jsonify({'error': 'PO not found'}), 404
|
||||
|
||||
Body:
|
||||
status: 'confirmed' | 'shipped' | 'delivered' | 'cancelled'
|
||||
# Authorization
|
||||
if g.marketplace_role == 'seller':
|
||||
if po['bodega_id'] != g.marketplace_bodega_id:
|
||||
return jsonify({'error': 'Not authorized'}), 403
|
||||
elif g.marketplace_role == 'buyer':
|
||||
if po['buyer_tenant_id'] != g.tenant_id:
|
||||
return jsonify({'error': 'Not authorized'}), 403
|
||||
# admin sees all
|
||||
|
||||
return jsonify(po)
|
||||
return _with_master(_do)
|
||||
|
||||
|
||||
@marketplace_bp.route('/orders/<int:po_id>/transition', methods=['POST'])
|
||||
@require_auth()
|
||||
def transition_order(po_id):
|
||||
"""Change a PO's status. Role determines which transitions are allowed.
|
||||
|
||||
Body: {"new_status": "confirmed", "note": "optional note"}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get('status')
|
||||
valid_statuses = ['confirmed', 'shipped', 'delivered', 'cancelled']
|
||||
if new_status not in valid_statuses:
|
||||
return jsonify({'error': f'status must be one of: {", ".join(valid_statuses)}'}), 400
|
||||
_load_marketplace_profile()
|
||||
body = request.get_json() or {}
|
||||
new_status = body.get('new_status')
|
||||
note = body.get('note')
|
||||
if not new_status:
|
||||
return jsonify({'error': 'new_status required'}), 400
|
||||
|
||||
tenant_id = g.tenant_id
|
||||
# Map marketplace_role to actor_kind for the state machine.
|
||||
actor_kind = g.marketplace_role
|
||||
if actor_kind == 'admin':
|
||||
actor_kind = 'seller' # admin defaults to seller path in Phase 1
|
||||
|
||||
master = get_master_conn()
|
||||
mcur = master.cursor()
|
||||
mcur.execute("""
|
||||
SELECT buyer_tenant_id, seller_tenant_id, status
|
||||
FROM marketplace_orders WHERE id = %s
|
||||
""", (order_id,))
|
||||
row = mcur.fetchone()
|
||||
def _do(master):
|
||||
po = mkt.get_po_detail(master, po_id)
|
||||
if not po:
|
||||
return jsonify({'error': 'PO not found'}), 404
|
||||
if g.marketplace_role == 'seller' and po['bodega_id'] != g.marketplace_bodega_id:
|
||||
return jsonify({'error': 'Not authorized'}), 403
|
||||
if g.marketplace_role == 'buyer' and po['buyer_tenant_id'] != g.tenant_id:
|
||||
return jsonify({'error': 'Not authorized'}), 403
|
||||
|
||||
if not row:
|
||||
mcur.close()
|
||||
master.close()
|
||||
return jsonify({'error': 'Order not found'}), 404
|
||||
|
||||
buyer_id, seller_id, current_status = row
|
||||
|
||||
# Permission check
|
||||
if tenant_id == buyer_id:
|
||||
# Buyer can only cancel pending orders
|
||||
if new_status != 'cancelled' or current_status != 'pending':
|
||||
mcur.close()
|
||||
master.close()
|
||||
return jsonify({'error': 'Buyer can only cancel pending orders'}), 403
|
||||
elif tenant_id == seller_id:
|
||||
# Seller can do any transition
|
||||
pass
|
||||
else:
|
||||
mcur.close()
|
||||
master.close()
|
||||
return jsonify({'error': 'Not authorized for this order'}), 403
|
||||
|
||||
mcur.execute("""
|
||||
UPDATE marketplace_orders SET status = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (new_status, order_id))
|
||||
master.commit()
|
||||
mcur.close()
|
||||
master.close()
|
||||
|
||||
return jsonify({'id': order_id, 'status': new_status, 'message': 'Order updated'})
|
||||
|
||||
|
||||
@marketplace_bp.route('/orders/<int:order_id>/items', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_order_items(order_id):
|
||||
"""Get items for a specific order."""
|
||||
tenant_id = g.tenant_id
|
||||
|
||||
master = get_master_conn()
|
||||
mcur = master.cursor()
|
||||
|
||||
# Verify tenant is buyer or seller
|
||||
mcur.execute("""
|
||||
SELECT buyer_tenant_id, seller_tenant_id FROM marketplace_orders WHERE id = %s
|
||||
""", (order_id,))
|
||||
row = mcur.fetchone()
|
||||
if not row or (row[0] != tenant_id and row[1] != tenant_id):
|
||||
mcur.close()
|
||||
master.close()
|
||||
return jsonify({'error': 'Not authorized'}), 403
|
||||
|
||||
mcur.execute("""
|
||||
SELECT id, part_number, part_name, quantity, unit_price, subtotal
|
||||
FROM marketplace_order_items WHERE order_id = %s ORDER BY id
|
||||
""", (order_id,))
|
||||
|
||||
items = []
|
||||
for r in mcur.fetchall():
|
||||
items.append({
|
||||
'id': r[0], 'part_number': r[1], 'part_name': r[2],
|
||||
'quantity': r[3], 'unit_price': float(r[4]) if r[4] else 0,
|
||||
'subtotal': float(r[5]) if r[5] else 0,
|
||||
})
|
||||
mcur.close()
|
||||
master.close()
|
||||
return jsonify({'data': items})
|
||||
result = mkt.transition_po(
|
||||
master,
|
||||
po_id=po_id,
|
||||
new_status=new_status,
|
||||
actor_user_id=g.employee_id,
|
||||
actor_kind=actor_kind,
|
||||
note=note,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
return jsonify(result), 400
|
||||
return jsonify(result)
|
||||
return _with_master(_do)
|
||||
|
||||
95
pos/blueprints/peer_bp.py
Normal file
95
pos/blueprints/peer_bp.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Peer API — public endpoints for inter-instance communication.
|
||||
|
||||
These endpoints do NOT require auth (they're called machine-to-machine by
|
||||
other Nexus instances on the network). They expose read-only inventory data
|
||||
so the marketplace can aggregate stock across the whole Nexus network.
|
||||
|
||||
Routes:
|
||||
GET /pos/api/peer/health — instance status + inventory count
|
||||
GET /pos/api/peer/inventory — search this instance's inventory
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from tenant_db import get_tenant_conn
|
||||
from services import peer_service
|
||||
|
||||
peer_bp = Blueprint('peer', __name__, url_prefix='/pos/api/peer')
|
||||
|
||||
|
||||
# ─── Which tenant to use for the peer endpoint? ──────────────────────────
|
||||
# In production each instance serves one tenant. For the demo, we hardcode
|
||||
# tenant_id=11 (the demo refaccionaria). This will be read from a config
|
||||
# file in the future when each instance has exactly 1 active tenant.
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
def _get_local_tenant_id():
|
||||
"""Read the local tenant ID from peers.json or fall back to 11."""
|
||||
try:
|
||||
cfg_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'peers.json')
|
||||
with open(cfg_path, 'r') as f:
|
||||
cfg = json.load(f)
|
||||
return cfg.get('tenant_id', 11)
|
||||
except Exception:
|
||||
return 11
|
||||
|
||||
|
||||
@peer_bp.route('/health', methods=['GET'])
|
||||
def peer_health():
|
||||
"""Public health check — no auth. Returns instance name + basic stats."""
|
||||
tenant_id = _get_local_tenant_id()
|
||||
inventory_count = 0
|
||||
try:
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM inventory i
|
||||
WHERE i.is_active = TRUE
|
||||
AND COALESCE((SELECT SUM(quantity) FROM inventory_operations WHERE inventory_id = i.id), 0) > 0
|
||||
""")
|
||||
inventory_count = cur.fetchone()[0]
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f'[peer] health check DB error: {e}')
|
||||
|
||||
return jsonify({
|
||||
'status': 'ok',
|
||||
'instance_name': peer_service.get_instance_name(),
|
||||
'instance_id': peer_service.get_instance_id(),
|
||||
'inventory_count': inventory_count,
|
||||
'peer_count': len(peer_service.get_peers()),
|
||||
})
|
||||
|
||||
|
||||
@peer_bp.route('/inventory', methods=['GET'])
|
||||
def peer_inventory():
|
||||
"""Public inventory search — no auth.
|
||||
|
||||
Called by other Nexus instances to see what this refaccionaria has in stock.
|
||||
Returns minimal data: part number, name, brand, price, stock hint.
|
||||
Does NOT expose exact stock quantities (competitive info).
|
||||
|
||||
Query params:
|
||||
q: search term (optional — without it, returns popular/all items)
|
||||
limit: max results (default 50, max 200)
|
||||
"""
|
||||
q = request.args.get('q', '').strip() or None
|
||||
limit = min(request.args.get('limit', 50, type=int), 200)
|
||||
|
||||
tenant_id = _get_local_tenant_id()
|
||||
try:
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
data = peer_service.get_local_inventory(conn, query=q, limit=limit)
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f'[peer] inventory query error: {e}')
|
||||
data = []
|
||||
|
||||
return jsonify({
|
||||
'instance_name': peer_service.get_instance_name(),
|
||||
'data': data,
|
||||
'count': len(data),
|
||||
})
|
||||
@@ -505,7 +505,8 @@ def list_quotations():
|
||||
where_clauses.append("q.status = %s")
|
||||
params.append(status)
|
||||
if g.branch_id:
|
||||
where_clauses.append("q.branch_id = %s")
|
||||
# Show both this branch's quotes AND branchless ones (e.g. WhatsApp)
|
||||
where_clauses.append("(q.branch_id = %s OR q.branch_id IS NULL)")
|
||||
params.append(g.branch_id)
|
||||
|
||||
where = " AND ".join(where_clauses)
|
||||
@@ -515,7 +516,7 @@ def list_quotations():
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT q.id, q.customer_id, q.employee_id, q.subtotal, q.tax_total,
|
||||
q.total, q.status, q.valid_until, q.created_at,
|
||||
q.total, q.status, q.valid_until, q.created_at, q.notes,
|
||||
c.name as customer_name, e.name as employee_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
@@ -527,6 +528,9 @@ def list_quotations():
|
||||
|
||||
quotations = []
|
||||
for r in cur.fetchall():
|
||||
notes = r[9] or ''
|
||||
source = 'whatsapp' if notes.startswith('WA:') else 'pos'
|
||||
wa_phone = notes.replace('WA:', '') if source == 'whatsapp' else None
|
||||
quotations.append({
|
||||
'id': r[0], 'customer_id': r[1], 'employee_id': r[2],
|
||||
'subtotal': float(r[3]) if r[3] else 0,
|
||||
@@ -534,7 +538,9 @@ def list_quotations():
|
||||
'total': float(r[5]) if r[5] else 0,
|
||||
'status': r[6], 'valid_until': str(r[7]) if r[7] else None,
|
||||
'created_at': str(r[8]),
|
||||
'customer_name': r[9], 'employee_name': r[10],
|
||||
'customer_name': r[10], 'employee_name': r[11],
|
||||
'source': source,
|
||||
'wa_phone': wa_phone,
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
@@ -546,6 +552,146 @@ def list_quotations():
|
||||
})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
|
||||
@require_auth('pos.sell')
|
||||
def delete_quotation(quot_id):
|
||||
"""Delete a quotation and its items."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
|
||||
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if deleted == 0:
|
||||
return jsonify({'error': 'Cotización no encontrada'}), 404
|
||||
return jsonify({'ok': True, 'deleted_id': quot_id})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/print', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def print_quotation_ticket(quot_id):
|
||||
"""Generate a printable ticket for a quotation (ESC/POS or browser)."""
|
||||
from flask import Response
|
||||
from services.thermal_printer import generate_quotation_ticket
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
printer_type = body.get('printer_type', 'escpos_raw')
|
||||
width = int(body.get('width', 80))
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
|
||||
q.created_at, q.notes, c.name as customer_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = %s
|
||||
""", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
notes = row[6] or ''
|
||||
wa_phone = notes.replace('WA:', '') if notes.startswith('WA:') else None
|
||||
|
||||
cur.execute("""
|
||||
SELECT part_number, name, quantity, unit_price, subtotal
|
||||
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||
""", (quot_id,))
|
||||
items = [{'part_number': r[0], 'name': r[1], 'quantity': r[2],
|
||||
'unit_price': float(r[3]) if r[3] else 0,
|
||||
'subtotal': float(r[4]) if r[4] else 0} for r in cur.fetchall()]
|
||||
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'tenant_%'")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'tenant_nombre': business_info['name'] = rw[1]
|
||||
elif rw[0] == 'tenant_rfc': business_info['rfc'] = rw[1]
|
||||
elif rw[0] == 'tenant_direccion': business_info['address'] = rw[1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
quote_data = {
|
||||
'id': row[0],
|
||||
'subtotal': float(row[1]) if row[1] else 0,
|
||||
'tax_total': float(row[2]) if row[2] else 0,
|
||||
'total': float(row[3]) if row[3] else 0,
|
||||
'valid_until': str(row[4]) if row[4] else None,
|
||||
'created_at': str(row[5]) if row[5] else '',
|
||||
'customer_name': row[7] or '',
|
||||
'wa_phone': wa_phone,
|
||||
'items': items,
|
||||
}
|
||||
|
||||
if printer_type == 'browser':
|
||||
return jsonify(quote_data)
|
||||
|
||||
raw = generate_quotation_ticket(quote_data, business_info, width=width)
|
||||
return Response(raw, mimetype='application/octet-stream',
|
||||
headers={'Content-Disposition': f'attachment; filename=cotizacion_{quot_id}.bin'})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/print-queue', methods=['GET'])
|
||||
@require_auth('pos.sell')
|
||||
def quotation_print_queue():
|
||||
"""Return quotations that were confirmed via WhatsApp and haven't been
|
||||
printed yet. The POS browser polls this endpoint and auto-prints.
|
||||
|
||||
Returns: {data: [{id, total, customer_name, wa_phone, confirmed_at}]}
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT q.id, q.total, q.notes, q.created_at,
|
||||
c.name as customer_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.status = 'converted'
|
||||
AND q.notes LIKE 'WA:%%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tenant_config
|
||||
WHERE key = 'printed_quote_' || q.id::text
|
||||
)
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
data = []
|
||||
for r in rows:
|
||||
notes = r[2] or ''
|
||||
data.append({
|
||||
'id': r[0],
|
||||
'total': float(r[1]) if r[1] else 0,
|
||||
'wa_phone': notes.replace('WA:', '') if notes.startswith('WA:') else None,
|
||||
'created_at': str(r[3]) if r[3] else '',
|
||||
'customer_name': r[4] or '',
|
||||
})
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': data})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/mark-printed', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def mark_quotation_printed(quot_id):
|
||||
"""Mark a quotation as printed so it doesn't appear in the print queue again."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (f'printed_quote_{quot_id}', 'true'))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_quotation(quot_id):
|
||||
|
||||
@@ -19,6 +19,133 @@ from services import whatsapp_service
|
||||
whatsapp_bp = Blueprint('whatsapp', __name__, url_prefix='/pos/api/whatsapp')
|
||||
|
||||
|
||||
def _enrich_wa_reply_with_part(search_query, vehicle, tenant_conn):
|
||||
"""Search the refaccionaria's LOCAL inventory and build a WhatsApp reply.
|
||||
|
||||
Returns:
|
||||
(formatted_text, first_part_dict) — first_part_dict is used by the
|
||||
quotation system to know what to add when the user says "cotizar".
|
||||
first_part_dict has: inventory_id, part_number, name, brand, price, tax_rate
|
||||
"""
|
||||
if not tenant_conn:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
# Translate common English search terms to Spanish for local inventory
|
||||
# (the AI sends search_query in English, but local inventory names
|
||||
# are often in Spanish)
|
||||
from services.translations import PART_TRANSLATIONS
|
||||
search_terms = [search_query]
|
||||
# Add the Spanish translation if we have one
|
||||
for en, es in PART_TRANSLATIONS.items():
|
||||
if en.upper() in search_query.upper():
|
||||
search_terms.append(es)
|
||||
break
|
||||
|
||||
# Build ILIKE conditions for all search terms
|
||||
conditions = []
|
||||
params = []
|
||||
for term in search_terms:
|
||||
conditions.append("(i.name ILIKE %s OR i.part_number ILIKE %s OR i.brand ILIKE %s)")
|
||||
like = f'%{term}%'
|
||||
params.extend([like, like, like])
|
||||
|
||||
where_search = ' OR '.join(conditions)
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT i.part_number, i.name, i.brand, i.price_1, i.price_2, i.price_3,
|
||||
COALESCE(s.stock, 0) AS stock,
|
||||
i.unit, i.location
|
||||
FROM inventory i
|
||||
LEFT JOIN (
|
||||
SELECT inventory_id, SUM(quantity) AS stock
|
||||
FROM inventory_operations
|
||||
GROUP BY inventory_id
|
||||
) s ON s.inventory_id = i.id
|
||||
WHERE i.is_active = TRUE
|
||||
AND ({where_search})
|
||||
ORDER BY
|
||||
COALESCE(s.stock, 0) > 0 DESC,
|
||||
i.name
|
||||
LIMIT 10
|
||||
""", params)
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
if not rows:
|
||||
return ('❌ No tenemos esa parte en inventario actualmente.\n'
|
||||
'_Puedes preguntar por otra parte o visitarnos en tienda._'), None
|
||||
|
||||
# Split into in-stock and out-of-stock
|
||||
in_stock = [r for r in rows if r[6] > 0]
|
||||
out_stock = [r for r in rows if r[6] <= 0]
|
||||
|
||||
# Build the first-part dict for quotation tracking
|
||||
# Use the first in-stock part, or first out-of-stock if none available
|
||||
best = in_stock[0] if in_stock else (out_stock[0] if out_stock else None)
|
||||
first_part = None
|
||||
if best:
|
||||
first_part = {
|
||||
'inventory_id': None, # we'd need the id — fetch it
|
||||
'part_number': best[0],
|
||||
'name': best[1],
|
||||
'brand': best[2] or '',
|
||||
'price': float(best[3]) if best[3] else 0,
|
||||
'tax_rate': 0.16,
|
||||
'stock': best[6],
|
||||
'unit': best[7] or 'PZA',
|
||||
}
|
||||
# Fetch the inventory ID for the quotation item FK
|
||||
try:
|
||||
cur2 = tenant_conn.cursor()
|
||||
cur2.execute("SELECT id FROM inventory WHERE part_number = %s AND is_active = TRUE LIMIT 1",
|
||||
(best[0],))
|
||||
inv_row = cur2.fetchone()
|
||||
if inv_row:
|
||||
first_part['inventory_id'] = inv_row[0]
|
||||
cur2.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lines = []
|
||||
|
||||
if in_stock:
|
||||
lines.append('✅ *Tenemos en stock:*')
|
||||
lines.append('')
|
||||
for r in in_stock:
|
||||
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
brand_str = f'*{brand}*' if brand else ''
|
||||
price_str = f'${float(p1):,.2f}' if p1 else 'Consultar precio'
|
||||
lines.append(f' • {brand_str} {name}')
|
||||
lines.append(f' #{part_num} — {price_str} ({stock} {unit or "pzas"} disponibles)')
|
||||
lines.append('')
|
||||
else:
|
||||
lines.append('⚠️ *Tenemos estas opciones pero sin stock actualmente:*')
|
||||
lines.append('')
|
||||
for r in out_stock[:5]:
|
||||
part_num, name, brand, p1, p2, p3, stock, unit, location = r
|
||||
brand_str = f'*{brand}*' if brand else ''
|
||||
price_str = f'${float(p1):,.2f}' if p1 else ''
|
||||
lines.append(f' • {brand_str} {name} #{part_num} {price_str}')
|
||||
lines.append('')
|
||||
lines.append('_Podemos pedirlo — consulta tiempo de entrega._')
|
||||
|
||||
# Vehicle context
|
||||
if vehicle and vehicle.get('brand'):
|
||||
v_str = f"{vehicle.get('brand','')} {vehicle.get('model','')} {vehicle.get('year','')}"
|
||||
lines.append(f'🚗 Vehículo: {v_str.strip()}')
|
||||
|
||||
lines.append('\n📞 _Escribe "cotizar" para agregar a tu cotización._')
|
||||
|
||||
return '\n'.join(lines), first_part
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Enrichment error: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
@whatsapp_bp.route('/status', methods=['GET'])
|
||||
@require_auth()
|
||||
def status():
|
||||
@@ -45,7 +172,14 @@ def logout():
|
||||
|
||||
@whatsapp_bp.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
"""Receive messages from Baileys bridge (public, no auth)."""
|
||||
"""Receive messages from Baileys bridge (public, no auth).
|
||||
|
||||
Flow:
|
||||
1. Persist the incoming message to the tenant's whatsapp_messages log.
|
||||
2. Build inventory context for the AI (what this tenant has in stock).
|
||||
3. Ask the chatbot for a reply, enriched with that context.
|
||||
4. Send the reply back via the Baileys bridge.
|
||||
"""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
|
||||
if data.get('event') != 'messages.upsert':
|
||||
@@ -55,30 +189,205 @@ def webhook():
|
||||
if not msg.get('phone') or msg.get('from_me'):
|
||||
return jsonify({'ok': True})
|
||||
|
||||
# Save to DB if tenant connection available
|
||||
# Reuse one tenant connection for the whole webhook path — we need it
|
||||
# for persistence AND for the inventory-context lookup.
|
||||
# TODO: resolve tenant from phone number when multi-tenant WhatsApp arrives.
|
||||
tenant_id = 11
|
||||
tenant_conn = None
|
||||
inventory_context = None
|
||||
try:
|
||||
# Try to get a tenant connection (use default tenant for webhook)
|
||||
conn = get_tenant_conn(11) # TODO: resolve tenant from phone number
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id)
|
||||
VALUES (%s, 'incoming', %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (msg['phone'], msg['text'], msg['message_id']))
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
tenant_conn = get_tenant_conn(tenant_id)
|
||||
|
||||
# Auto-reply with AI chatbot
|
||||
if msg.get('text'):
|
||||
# 1. Log the incoming message (with contact display name)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_messages (phone, direction, message_text, wa_message_id, push_name)
|
||||
VALUES (%s, 'incoming', %s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""", (msg['phone'], msg['text'], msg['message_id'], msg.get('push_name') or None))
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
|
||||
# 2. Build inventory context once per webhook call so the chatbot
|
||||
# can say things like "tengo 5 Bosch BP-123 por $450".
|
||||
try:
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(msg['text'])
|
||||
reply = ai_resp.get('message', '')
|
||||
from services.ai_chat import get_inventory_context
|
||||
inventory_context = get_inventory_context(tenant_conn)
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] inventory_context failed: {e}")
|
||||
inventory_context = None
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] tenant connection failed: {e}")
|
||||
|
||||
# 3. Dispatch by media kind + quotation commands
|
||||
reply = None
|
||||
reply_to = msg.get('jid') or msg['phone']
|
||||
media_kind = msg.get('media_kind', 'text')
|
||||
clean_phone = msg.get('phone', '')
|
||||
|
||||
# ── Check for quotation commands FIRST (before AI) ──
|
||||
if media_kind == 'text' and msg.get('text'):
|
||||
from services.wa_quotation import (
|
||||
detect_quote_intent, get_open_quotation, create_quotation,
|
||||
add_item_to_quotation, get_quotation_detail, format_quotation_wa,
|
||||
clear_quotation, confirm_quotation, get_last_shown_part, set_last_shown_part,
|
||||
)
|
||||
has_open = bool(tenant_conn and get_open_quotation(tenant_conn, clean_phone))
|
||||
intent, qty = detect_quote_intent(msg['text'], has_open_quote=has_open)
|
||||
|
||||
if intent == 'add':
|
||||
last_part = get_last_shown_part(clean_phone)
|
||||
if not last_part:
|
||||
reply = '⚠️ Primero pregunta por una parte y luego escribe "cotizar" para agregarla.'
|
||||
elif tenant_conn:
|
||||
qid = get_open_quotation(tenant_conn, clean_phone)
|
||||
if not qid:
|
||||
qid = create_quotation(tenant_conn, clean_phone)
|
||||
add_item_to_quotation(tenant_conn, qid, last_part, quantity=qty or 1)
|
||||
detail = get_quotation_detail(tenant_conn, qid)
|
||||
item_count = len(detail['items']) if detail else 0
|
||||
reply = (
|
||||
f'✅ *{last_part.get("name", "")}* × {qty or 1} agregado a tu cotización.\n'
|
||||
f'Llevas {item_count} producto{"s" if item_count != 1 else ""} — total parcial: ${detail["total"]:,.2f}\n\n'
|
||||
f'_Sigue preguntando por más partes, o escribe "enviar cotización" cuando termines._'
|
||||
)
|
||||
|
||||
elif intent == 'send':
|
||||
if tenant_conn:
|
||||
qid = get_open_quotation(tenant_conn, clean_phone)
|
||||
if qid:
|
||||
detail = get_quotation_detail(tenant_conn, qid)
|
||||
reply = format_quotation_wa(detail)
|
||||
if not reply:
|
||||
reply = '⚠️ Tu cotización está vacía. Pregunta por partes y escribe "cotizar" para agregarlas.'
|
||||
else:
|
||||
reply = '⚠️ No tienes una cotización abierta. Pregunta por una parte primero.'
|
||||
|
||||
elif intent == 'clear':
|
||||
if tenant_conn:
|
||||
clear_quotation(tenant_conn, clean_phone)
|
||||
reply = '🗑️ Cotización limpiada. Pregunta por partes para empezar una nueva.'
|
||||
|
||||
elif intent == 'confirm':
|
||||
if tenant_conn:
|
||||
qid = confirm_quotation(tenant_conn, clean_phone)
|
||||
if qid:
|
||||
reply = (
|
||||
f'✅ *Pedido confirmado!*\n\n'
|
||||
f'Tu cotización #{qid} fue registrada.\n'
|
||||
f'Nos pondremos en contacto contigo para coordinar la entrega/recolección.\n\n'
|
||||
f'¡Gracias por tu compra! 🙏'
|
||||
)
|
||||
else:
|
||||
reply = '⚠️ No tienes una cotización abierta para confirmar.'
|
||||
|
||||
if intent is not None:
|
||||
# It was a quote command — send reply and skip the AI
|
||||
if reply:
|
||||
whatsapp_service.send_message(msg['phone'], reply)
|
||||
result = whatsapp_service.send_message(reply_to, reply)
|
||||
if tenant_conn:
|
||||
try:
|
||||
cur_save = tenant_conn.cursor()
|
||||
cur_save.execute("INSERT INTO whatsapp_messages (phone, direction, message_text) VALUES (%s, 'outgoing', %s)", (clean_phone, reply))
|
||||
tenant_conn.commit()
|
||||
cur_save.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Clean up and return early
|
||||
if tenant_conn:
|
||||
try: tenant_conn.close()
|
||||
except Exception: pass
|
||||
return jsonify({'ok': True})
|
||||
|
||||
try:
|
||||
if media_kind == 'image' and msg.get('media_base64'):
|
||||
from services.ai_chat import chat_with_image
|
||||
# Prompt: use the caption if provided, else default to
|
||||
# "identify this part" which chat_with_image handles gracefully.
|
||||
prompt = msg.get('text') or 'Identifica esta parte automotriz y sugiere terminos de busqueda.'
|
||||
ai_resp = chat_with_image(
|
||||
user_message=prompt,
|
||||
image_base64=msg['media_base64'],
|
||||
inventory_context=inventory_context,
|
||||
)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
print(f"[WA-AI] Image from {reply_to}: {reply[:80]}...")
|
||||
|
||||
elif media_kind == 'audio' and msg.get('media_base64'):
|
||||
# Voice note handling — transcribe first, then chat().
|
||||
# See services.whisper_local for the transcriber.
|
||||
try:
|
||||
from services.whisper_local import transcribe_audio_base64
|
||||
transcript = transcribe_audio_base64(
|
||||
msg['media_base64'],
|
||||
mimetype=msg.get('media_mimetype') or 'audio/ogg',
|
||||
)
|
||||
except ImportError:
|
||||
transcript = None
|
||||
print("[WA-AI] whisper_local not installed — voice notes skipped")
|
||||
except Exception as e:
|
||||
transcript = None
|
||||
print(f"[WA-AI] Whisper transcription failed: {e}")
|
||||
|
||||
if transcript:
|
||||
print(f"[WA-AI] Voice note transcribed: {transcript[:100]}")
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(transcript, inventory_context=inventory_context)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
# Prefix the reply so the sender knows we understood the voice note
|
||||
if reply:
|
||||
reply = f'🎙️ Entendi: "{transcript}"\n\n{reply}'
|
||||
else:
|
||||
reply = ('Recibi tu nota de voz pero no pude transcribirla. '
|
||||
'Puedes escribirme el mensaje?')
|
||||
|
||||
elif msg.get('text'):
|
||||
# Plain text message — standard chatbot flow
|
||||
from services.ai_chat import chat
|
||||
ai_resp = chat(msg['text'], inventory_context=inventory_context)
|
||||
reply = ai_resp.get('message', '') or ''
|
||||
|
||||
# Enrich: if the AI returned a search_query, look up real parts
|
||||
# from the catalog and append them to the WhatsApp reply.
|
||||
search_q = ai_resp.get('search_query')
|
||||
vehicle = ai_resp.get('vehicle')
|
||||
if search_q and reply:
|
||||
try:
|
||||
enrichment, found_part = _enrich_wa_reply_with_part(search_q, vehicle, tenant_conn)
|
||||
if enrichment:
|
||||
reply = reply + '\n\n' + enrichment
|
||||
# Track the found part so "cotizar" can add it
|
||||
if found_part:
|
||||
from services.wa_quotation import set_last_shown_part
|
||||
set_last_shown_part(clean_phone, found_part)
|
||||
except Exception as enrich_err:
|
||||
print(f"[WA-AI] Enrichment failed: {enrich_err}")
|
||||
|
||||
# Send reply if we produced one
|
||||
if reply:
|
||||
result = whatsapp_service.send_message(reply_to, reply)
|
||||
print(f"[WA-AI] Replied to {reply_to} ({media_kind}): {reply[:80]}... result={result}")
|
||||
|
||||
# Save the bot's reply to DB so it shows in the WhatsApp UI
|
||||
if tenant_conn:
|
||||
try:
|
||||
cur2 = tenant_conn.cursor()
|
||||
cur2.execute("""
|
||||
INSERT INTO whatsapp_messages (phone, direction, message_text)
|
||||
VALUES (%s, 'outgoing', %s)
|
||||
""", (msg['phone'], reply))
|
||||
tenant_conn.commit()
|
||||
cur2.close()
|
||||
except Exception as db_err:
|
||||
print(f"[WA-AI] Failed to save bot reply to DB: {db_err}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Error handling {media_kind} from {reply_to}: {e}")
|
||||
|
||||
# 4. Clean up the connection
|
||||
if tenant_conn is not None:
|
||||
try:
|
||||
tenant_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -119,14 +428,37 @@ def conversations():
|
||||
try:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
# Clean up phone format: strip @lid and @s.whatsapp.net suffixes
|
||||
# so all variants of the same number are grouped together.
|
||||
cur.execute("""
|
||||
SELECT phone, MAX(message_text) as last_message, MAX(created_at) as last_at, COUNT(*) as msg_count
|
||||
FROM whatsapp_messages
|
||||
GROUP BY phone
|
||||
WITH cleaned AS (
|
||||
SELECT
|
||||
REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') AS clean_phone,
|
||||
message_text,
|
||||
direction,
|
||||
created_at,
|
||||
push_name
|
||||
FROM whatsapp_messages
|
||||
)
|
||||
SELECT clean_phone,
|
||||
(ARRAY_AGG(message_text ORDER BY created_at DESC))[1] AS last_message,
|
||||
(ARRAY_AGG(direction ORDER BY created_at DESC))[1] AS last_direction,
|
||||
MAX(created_at) AS last_at,
|
||||
COUNT(*) AS msg_count,
|
||||
(ARRAY_AGG(push_name ORDER BY created_at DESC) FILTER (WHERE push_name IS NOT NULL AND push_name != ''))[1] AS contact_name
|
||||
FROM cleaned
|
||||
GROUP BY clean_phone
|
||||
ORDER BY MAX(created_at) DESC
|
||||
LIMIT 50
|
||||
""")
|
||||
convos = [{'phone': r[0], 'last_message': r[1], 'last_at': str(r[2]), 'count': r[3]} for r in cur.fetchall()]
|
||||
convos = [{
|
||||
'phone': r[0],
|
||||
'last_message': r[1] or '',
|
||||
'last_direction': r[2] or 'incoming',
|
||||
'last_at': str(r[3]),
|
||||
'count': r[4],
|
||||
'contact_name': r[5] or '',
|
||||
} for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'conversations': convos})
|
||||
@@ -134,22 +466,68 @@ def conversations():
|
||||
return jsonify({'conversations': [], 'error': str(e)})
|
||||
|
||||
|
||||
@whatsapp_bp.route('/conversations/<phone>', methods=['GET'])
|
||||
@whatsapp_bp.route('/conversations/<path:phone>', methods=['GET'])
|
||||
@require_auth()
|
||||
def conversation_messages(phone):
|
||||
# Strip @lid or @s.whatsapp.net suffix for DB lookup
|
||||
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
|
||||
try:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
# Match all variants of this phone number
|
||||
cur.execute("""
|
||||
SELECT id, direction, message_text, created_at
|
||||
FROM whatsapp_messages
|
||||
WHERE phone = %s
|
||||
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
|
||||
ORDER BY created_at
|
||||
LIMIT 100
|
||||
""", (phone,))
|
||||
msgs = [{'id': r[0], 'direction': r[1], 'text': r[2], 'date': str(r[3])} for r in cur.fetchall()]
|
||||
""", (clean_phone,))
|
||||
msgs = [{
|
||||
'id': r[0],
|
||||
'direction': r[1],
|
||||
'message_text': r[2] or '',
|
||||
'created_at': str(r[3]),
|
||||
} for r in cur.fetchall()]
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'messages': msgs})
|
||||
except Exception as e:
|
||||
return jsonify({'messages': [], 'error': str(e)})
|
||||
|
||||
|
||||
@whatsapp_bp.route('/conversations/<path:phone>', methods=['DELETE'])
|
||||
@require_auth()
|
||||
def delete_conversation(phone):
|
||||
"""Delete all messages for a phone number."""
|
||||
clean_phone = phone.replace('@s.whatsapp.net', '').replace('@lid', '')
|
||||
try:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
DELETE FROM whatsapp_messages
|
||||
WHERE REPLACE(REPLACE(phone, '@s.whatsapp.net', ''), '@lid', '') = %s
|
||||
""", (clean_phone,))
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'deleted': deleted})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@whatsapp_bp.route('/conversations', methods=['DELETE'])
|
||||
@require_auth()
|
||||
def delete_all_conversations():
|
||||
"""Delete ALL whatsapp messages."""
|
||||
try:
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM whatsapp_messages")
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
return jsonify({'ok': True, 'deleted': deleted})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
19
pos/peers.json
Normal file
19
pos/peers.json
Normal 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."
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
129
pos/services/catalog_modes.py
Normal file
129
pos/services/catalog_modes.py
Normal 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
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
810
pos/services/marketplace_service.py
Normal file
810
pos/services/marketplace_service.py
Normal 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
|
||||
745
pos/services/nexpart_taxonomy.py
Normal file
745
pos/services/nexpart_taxonomy.py
Normal 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),
|
||||
}
|
||||
240
pos/services/peer_service.py
Normal file
240
pos/services/peer_service.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
284
pos/services/wa_quotation.py
Normal file
284
pos/services/wa_quotation.py
Normal 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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
151
pos/services/whisper_local.py
Normal file
151
pos/services/whisper_local.py
Normal 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
|
||||
683
pos/static/css/pos-glass.css
Normal file
683
pos/static/css/pos-glass.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
404
pos/static/js/pos-utils.js
Normal 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);
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
})();
|
||||
|
||||
@@ -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' ? '→ ' : '';
|
||||
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">×</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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
175
pos/templates/quotations.html
Normal file
175
pos/templates/quotations.html
Normal 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')">×</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user