feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts

Major features:
- Pixel-Perfect glassmorphism design (landing + POS + public catalog)
- OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types)
- Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications)
- Peer-to-peer inventory (multi-instance, LAN discovery)
- WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations
- Smart unified search (VIN/plate/part_number/keyword auto-detect)
- Shop Supplies tab (vehicle-independent parts)
- Chatbot AI fallback chain (5 models) + response cache
- CSV inventory import tool + setup_instance.sh installer
- Tablet-responsive CSS + sidebar toggle
- Filters, export CSV, employee edit, business data save
- Quotation system (WA→POS) with auto-print on confirmation
- Live stats on landing page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 05:35:53 +00:00
parent 6b097614a0
commit e95f7cf684
54 changed files with 11226 additions and 1422 deletions

View File

@@ -11,8 +11,9 @@ import uuid
import urllib.request
from datetime import datetime, timedelta
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'pos'))
_base = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_base, '..', 'pos')) # pos/ for auth, services
sys.path.insert(0, os.path.join(_base, '..')) # root config.py (has DB_URL)
from config import DB_URL
from auth import hash_password, check_password, create_access_token, create_refresh_token, decode_token, require_auth
from services.translations import translate_part_name, translate_category
@@ -225,6 +226,10 @@ def public_catalog():
def catalog_public_js():
return send_from_directory('.', 'catalog-public.js')
@app.route('/landing.js')
def landing_js():
return send_from_directory('.', 'landing.js')
@app.route('/static/<path:path>')
def static_files(path):
return send_from_directory('static', path)
@@ -372,8 +377,10 @@ NORTH_AMERICA_BRANDS = REGION_BRANDS['north-america']
@app.route('/api/catalog/brands')
def api_catalog_brands():
from services.catalog_modes import get_brands_for_mode, normalize_mode
region = request.args.get('region', 'north-america')
year_id = request.args.get('year_id', type=int)
mode = normalize_mode(request.args.get('mode'))
session = Session()
try:
params = {}
@@ -382,7 +389,18 @@ def api_catalog_brands():
year_filter = " AND mye.year_id = :year_id"
params['year_id'] = year_id
if region == 'all':
# 'local' mode overrides the region filter — curated bodega list only.
if mode == 'local':
params['brands'] = list(get_brands_for_mode('local'))
rows = session.execute(text("""
SELECT DISTINCT b.id_brand, b.name_brand
FROM brands b
JOIN models m ON m.brand_id = b.id_brand
JOIN model_year_engine mye ON mye.model_id = m.id_model
WHERE b.name_brand = ANY(:brands)""" + year_filter + """
ORDER BY b.name_brand
"""), params).mappings().all()
elif region == 'all':
rows = session.execute(text("""
SELECT DISTINCT b.id_brand, b.name_brand
FROM brands b
@@ -472,11 +490,39 @@ def api_catalog_engines():
session.close()
def _nexpart_master_conn():
"""Return a raw psycopg2 connection for passing to services.catalog_service
Nexpart functions (they expect .cursor() / .commit() / .close() DBAPI).
Uses SQLAlchemy's connection pool so we don't open a new socket per call.
"""
return engine.raw_connection()
@app.route('/api/catalog/categories')
def api_catalog_categories():
"""Categories for a vehicle.
OEM mode: TecDoc part_categories (integer ids).
Local mode: 14 Nexpart top-level groups filtered by what has parts for
this vehicle (strings as slugs).
"""
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
if mode == 'local':
from services.catalog_service import get_nexpart_groups_for_vehicle
conn = _nexpart_master_conn()
try:
data = get_nexpart_groups_for_vehicle(conn, mye_id)
finally:
conn.close()
return jsonify({'data': data, 'mode': 'local'})
# OEM mode (original behavior)
session = Session()
try:
rows = session.execute(text("""
@@ -502,10 +548,33 @@ def api_catalog_categories():
@app.route('/api/catalog/groups')
def api_catalog_groups():
"""Subgroups for a vehicle + parent category.
OEM mode: TecDoc part_groups (integer category_id).
Local mode: Nexpart subgroups within a Nexpart group (category_slug string).
"""
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
if mode == 'local':
if not category_slug:
return jsonify({'error': 'category_slug required for local mode'}), 400
from services.catalog_service import get_nexpart_subgroups_for_vehicle
conn = _nexpart_master_conn()
try:
data = get_nexpart_subgroups_for_vehicle(conn, mye_id, category_slug)
finally:
conn.close()
return jsonify({'data': data, 'mode': 'local'})
# OEM mode
if not category_id:
return jsonify({'error': 'category_id required for oem mode'}), 400
session = Session()
try:
rows = session.execute(text("""
@@ -526,33 +595,275 @@ def api_catalog_groups():
session.close()
@app.route('/api/catalog/parts')
def api_catalog_parts():
@app.route('/api/catalog/part-types')
def api_catalog_part_types():
"""Distinct part types within a vehicle+group (3rd subcategory level).
OEM mode: distinct name_part values within a TecDoc part_group_id.
Local mode: Nexpart Part Types within a Nexpart group + subgroup.
"""
from services.translations import translate_part_name
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)
if not mye_id or not group_id:
return jsonify({'error': 'mye_id and group_id required'}), 400
group_slug = request.args.get('group_slug')
subgroup_slug = request.args.get('subgroup_slug')
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
if mode == 'local':
if not group_slug or not subgroup_slug:
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
from services.catalog_service import get_nexpart_part_types_for_vehicle
conn = _nexpart_master_conn()
try:
data = get_nexpart_part_types_for_vehicle(conn, mye_id, group_slug, subgroup_slug)
finally:
conn.close()
return jsonify({'data': data, 'mode': 'local'})
# OEM mode
if not group_id:
return jsonify({'error': 'group_id required for oem mode'}), 400
session = Session()
try:
rows = session.execute(text("""
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 = :mye_id AND p.group_id = :group_id
GROUP BY p.name_part, COALESCE(p.name_es, p.name_part)
ORDER BY variants DESC, display_name ASC
"""), {'mye_id': mye_id, 'group_id': group_id}).mappings().all()
return jsonify({'data': [{
'slug': r['slug'],
'name': translate_part_name(r['display_name']),
'variant_count': r['variants'],
'sample_image': r['sample_image'],
} for r in rows]})
finally:
session.close()
@app.route('/api/catalog/shop-supplies/groups')
def api_shop_supplies_groups():
"""Public Shop Supplies tab: vehicle-independent groups."""
from services.catalog_service import get_shop_supplies_groups
return jsonify({'data': get_shop_supplies_groups()})
@app.route('/api/catalog/shop-supplies/subgroups')
def api_shop_supplies_subgroups():
group_slug = request.args.get('group_slug')
if not group_slug:
return jsonify({'error': 'group_slug required'}), 400
from services.catalog_service import get_shop_supplies_subgroups
conn = _nexpart_master_conn()
try:
return jsonify({'data': get_shop_supplies_subgroups(conn, group_slug)})
finally:
conn.close()
@app.route('/api/catalog/shop-supplies/part-types')
def api_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
from services.catalog_service import get_shop_supplies_part_types
conn = _nexpart_master_conn()
try:
return jsonify({'data': get_shop_supplies_part_types(conn, group_slug, subgroup_slug)})
finally:
conn.close()
@app.route('/api/catalog/shop-supplies/parts')
def api_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 = max(1, request.args.get('page', 1, type=int))
per_page = min(request.args.get('per_page', 30, type=int), 100)
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
from services.catalog_service import get_shop_supplies_parts
conn = _nexpart_master_conn()
try:
result = get_shop_supplies_parts(
conn, group_slug, subgroup_slug, part_type_slug,
tenant_conn=None, branch_id=None,
page=page, per_page=per_page,
)
return jsonify(result)
finally:
conn.close()
@app.route('/api/catalog/parts')
def api_catalog_parts():
from services.catalog_modes import (
normalize_mode,
LOCAL_PRIORITY_MANUFACTURERS_TIER1,
LOCAL_PRIORITY_MANUFACTURERS_TIER2,
)
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')
# Nexpart navigation slugs (Local mode, chosen via new Nexpart hierarchy)
nexpart_group = request.args.get('nexpart_group')
nexpart_subgroup = request.args.get('nexpart_subgroup')
nexpart_part_type = request.args.get('nexpart_part_type')
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
page = max(1, request.args.get('page', 1, type=int))
per_page = min(request.args.get('per_page', 30, type=int), 100)
offset = (page - 1) * per_page
mode = normalize_mode(request.args.get('mode'))
# ─── Nexpart-nav dispatch (delegates to POS service layer) ───
# Public catalog has no tenant context — pass tenant_conn=None which
# the service gracefully handles (no local stock / price enrichment).
if mode == 'local' and nexpart_group and nexpart_subgroup and nexpart_part_type:
from services.catalog_service import get_parts_for_nexpart_triple
conn = _nexpart_master_conn()
try:
result = get_parts_for_nexpart_triple(
conn, mye_id,
nexpart_group, nexpart_subgroup, nexpart_part_type,
tenant_conn=None, branch_id=None,
page=page, per_page=per_page,
)
return jsonify(result)
finally:
conn.close()
# Below here: legacy group_id based flow (OEM or legacy Local)
if not group_id:
return jsonify({'error': 'group_id required'}), 400
session = Session()
try:
# Optional 3rd-level Part Type filter (applies to both OEM and Local modes)
pt_clause = " AND p.name_part = :part_type" if part_type else ""
if mode == 'local':
# Aftermarket-oriented listing, prioritized + stock-aware.
count_params = {'mye_id': mye_id, 'group_id': group_id}
if part_type:
count_params['part_type'] = part_type
total = session.execute(text("""
SELECT 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 vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), count_params).scalar()
fetch_params = {
'mye_id': mye_id,
'group_id': group_id,
'tier1': list(LOCAL_PRIORITY_MANUFACTURERS_TIER1),
'tier2': list(LOCAL_PRIORITY_MANUFACTURERS_TIER2),
'limit': per_page,
'offset': offset,
}
if part_type:
fetch_params['part_type'] = part_type
rows = session.execute(text("""
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,
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 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 vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_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.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(:tier1) THEN 1
WHEN UPPER(afv.name_manufacture) = ANY(:tier2) 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 :limit OFFSET :offset
"""), fetch_params).mappings().all()
items = [{
'id_part': r['oem_part_id'],
'id_aftermarket': r['id_aftermarket_parts'],
'oem_part_number': r['oem_part_number'],
'part_number': r['part_number'],
'name': translate_part_name(r['am_name'] or r['oem_name']),
'description': r['oem_desc'],
'image_url': r['oem_image'],
'manufacturer': r['name_manufacture'],
'priority_tier': r['tier'],
'bodega_count': r['bodega_count'],
'warehouse_stock': r['warehouse_stock'],
'warehouse_price': float(r['warehouse_price']) if r['warehouse_price'] is not None else None,
'in_stock_network': r['bodega_count'] > 0,
} for r in rows]
total_pages = max(1, (total + per_page - 1) // per_page)
return jsonify({'data': items, 'mode': 'local', 'pagination': {
'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages
}})
# OEM mode (original behavior)
oem_count_params = {'mye_id': mye_id, 'group_id': group_id}
oem_fetch_params = {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset}
if part_type:
oem_count_params['part_type'] = part_type
oem_fetch_params['part_type'] = part_type
total = session.execute(text("""
SELECT COUNT(*)
FROM vehicle_parts vp
JOIN parts p ON p.id_part = vp.part_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id
"""), {'mye_id': mye_id, 'group_id': group_id}).scalar()
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause), oem_count_params).scalar()
rows = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
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 = :mye_id AND p.group_id = :group_id
WHERE vp.model_year_engine_id = :mye_id AND p.group_id = :group_id""" + pt_clause + """
ORDER BY p.name_part
LIMIT :limit OFFSET :offset
"""), {'mye_id': mye_id, 'group_id': group_id, 'limit': per_page, 'offset': offset}).mappings().all()
"""), oem_fetch_params).mappings().all()
items = [{
'id_part': r['id_part'],
@@ -563,7 +874,7 @@ def api_catalog_parts():
} for r in rows]
total_pages = max(1, (total + per_page - 1) // per_page)
return jsonify({'data': items, 'pagination': {
return jsonify({'data': items, 'mode': 'oem', 'pagination': {
'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages
}})
finally:
@@ -660,6 +971,7 @@ def api_catalog_search():
if is_part_number:
clean_q = q.replace(' ', '').upper()
# Search OEM part numbers first
rows = session.execute(text("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url
@@ -668,6 +980,28 @@ def api_catalog_search():
ORDER BY p.oem_part_number
LIMIT :limit
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
# If no OEM match, search aftermarket + cross-reference numbers
if not rows:
rows = session.execute(text("""
SELECT DISTINCT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url
FROM parts p
JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part
WHERE REPLACE(UPPER(ap.part_number), ' ', '') LIKE :q
ORDER BY p.oem_part_number
LIMIT :limit
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
if not rows:
rows = session.execute(text("""
SELECT DISTINCT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url
FROM parts p
JOIN part_cross_references cr ON cr.part_id = p.id_part
WHERE REPLACE(UPPER(cr.cross_reference_number), ' ', '') LIKE :q
ORDER BY p.oem_part_number
LIMIT :limit
"""), {'q': f'%{clean_q}%', 'limit': limit}).mappings().all()
else:
tsquery = ' & '.join(q.split())
rows = session.execute(text("""
@@ -723,19 +1057,46 @@ def api_catalog_search():
@app.route('/api/catalog/stats')
def api_catalog_stats():
"""Public stats endpoint consumed by the landing page hero cards.
Returns live counts from the master catalog so the landing never shows
stale numbers. Counts are fast (pg_class reltuples for the big tables)
and cached in-process for 10 minutes.
"""
# In-process cache — counts barely change and are called on every
# landing pageview; no reason to hit the DB every time.
import time as _t
now = _t.time()
cache = getattr(api_catalog_stats, '_cache', None)
if cache and cache[0] > now:
return jsonify(cache[1])
session = Session()
try:
# Use reltuples for large tables (parts, aftermarket_parts,
# part_cross_references) — exact COUNT(*) on 1M+ row tables is slow
# and the landing doesn't need exact accuracy, just "big numbers".
row = session.execute(text("""
SELECT
(SELECT COUNT(*) FROM brands) AS brands,
(SELECT COUNT(*) FROM models) AS models,
(SELECT COUNT(*) FROM model_year_engine) AS vehicles,
(SELECT COUNT(*) FROM parts) AS parts
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'parts') AS parts,
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'aftermarket_parts') AS aftermarket_parts,
(SELECT reltuples::bigint FROM pg_class WHERE relname = 'part_cross_references') AS cross_references,
(SELECT COUNT(*) FROM manufacturers) AS manufacturers
""")).mappings().first()
return jsonify({
'brands': row['brands'], 'models': row['models'],
'vehicles': row['vehicles'], 'parts': row['parts']
})
data = {
'brands': row['brands'] or 0,
'models': row['models'] or 0,
'vehicles': row['vehicles'] or 0,
'parts': row['parts'] or 0,
'aftermarket_parts': row['aftermarket_parts'] or 0,
'cross_references': row['cross_references'] or 0,
'manufacturers': row['manufacturers'] or 0,
}
api_catalog_stats._cache = (now + 600, data)
return jsonify(data)
finally:
session.close()
@@ -3581,9 +3942,13 @@ def api_pos_search_parts():
# Store Dashboard Endpoints
# ============================================================================
# Old page routes removed (demo, bodega, pitch, login, tienda)
# Old page routes removed (demo, bodega, login, tienda)
# APIs below are kept for backward compatibility
@app.route('/pitch')
def pitch_deck():
return send_from_directory(os.path.join(_base, '..', 'pitch'), 'deck.html')
@app.route('/api/tienda/stats')
def api_tienda_stats():