- Plate lookup: new plate_vehicles table (v1.7 migration), plate_lookup service with Mexican plate validation, GET/POST endpoints on catalog_bp, plate search UI in catalog vehicle selector - Translations: extend PART_TRANSLATIONS from ~80 to 326 entries covering brake, engine, fuel, cooling, electrical, drivetrain, suspension, steering, exhaust, A/C, lighting, body, interior, fluids, and category translations - Bulk images: image_scraper service with download+resize+placeholder generation, bulk-images and auto-image endpoints on inventory_bp Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ from middleware import require_auth
|
|||||||
from tenant_db import get_master_conn, get_tenant_conn
|
from tenant_db import get_master_conn, get_tenant_conn
|
||||||
from services import catalog_service
|
from services import catalog_service
|
||||||
from services.vin_decoder import decode_vin
|
from services.vin_decoder import decode_vin
|
||||||
|
from services.plate_lookup import search_plate, register_plate, is_valid_mexican_plate, normalize_plate
|
||||||
|
|
||||||
catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog')
|
catalog_bp = Blueprint('catalog', __name__, url_prefix='/pos/api/catalog')
|
||||||
|
|
||||||
@@ -227,6 +228,113 @@ def decode_vin_route(vin):
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Plate Lookup ───
|
||||||
|
|
||||||
|
@catalog_bp.route('/plate/<plate>', methods=['GET'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def plate_lookup(plate):
|
||||||
|
"""Look up a vehicle by Mexican license plate in the local plate_vehicles table.
|
||||||
|
If found, also tries to match the vehicle to the catalog DB.
|
||||||
|
"""
|
||||||
|
plate = (plate or '').strip()
|
||||||
|
if not plate:
|
||||||
|
return jsonify({'error': 'Placa requerida.'}), 400
|
||||||
|
|
||||||
|
if not is_valid_mexican_plate(plate):
|
||||||
|
return jsonify({'error': 'Formato de placa no valido. Ej: ABC-1234 o AB-123-C'}), 400
|
||||||
|
|
||||||
|
tenant = None
|
||||||
|
master = None
|
||||||
|
try:
|
||||||
|
tenant = get_tenant_conn(g.tenant_id)
|
||||||
|
result = search_plate(tenant, plate)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return jsonify({
|
||||||
|
'found': False,
|
||||||
|
'plate': normalize_plate(plate),
|
||||||
|
'message': 'Placa no registrada.'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Try to match to catalog
|
||||||
|
catalog_match = None
|
||||||
|
try:
|
||||||
|
master = get_master_conn()
|
||||||
|
catalog_match = _match_plate_to_catalog(master, result)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if master:
|
||||||
|
try: master.close()
|
||||||
|
except: pass
|
||||||
|
master = None
|
||||||
|
|
||||||
|
response = {
|
||||||
|
'found': True,
|
||||||
|
'plate': result['plate'],
|
||||||
|
'make': result['make'],
|
||||||
|
'model': result['model'],
|
||||||
|
'year': result['year'],
|
||||||
|
'vin': result['vin'],
|
||||||
|
'customer_id': result['customer_id'],
|
||||||
|
}
|
||||||
|
if catalog_match:
|
||||||
|
response['catalog_match'] = catalog_match
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
if tenant:
|
||||||
|
try: tenant.close()
|
||||||
|
except: pass
|
||||||
|
if master:
|
||||||
|
try: master.close()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
|
@catalog_bp.route('/plate', methods=['POST'])
|
||||||
|
@require_auth('catalog.view')
|
||||||
|
def plate_register():
|
||||||
|
"""Register or update a plate-to-vehicle mapping."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
plate = (data.get('plate') or '').strip()
|
||||||
|
if not plate:
|
||||||
|
return jsonify({'error': 'plate required'}), 400
|
||||||
|
|
||||||
|
if not is_valid_mexican_plate(plate):
|
||||||
|
return jsonify({'error': 'Formato de placa no valido.'}), 400
|
||||||
|
|
||||||
|
tenant = None
|
||||||
|
try:
|
||||||
|
tenant = get_tenant_conn(g.tenant_id)
|
||||||
|
rec_id = register_plate(
|
||||||
|
tenant, plate,
|
||||||
|
make=data.get('make'),
|
||||||
|
model=data.get('model'),
|
||||||
|
year=data.get('year'),
|
||||||
|
vin=data.get('vin'),
|
||||||
|
customer_id=data.get('customer_id'),
|
||||||
|
)
|
||||||
|
return jsonify({'id': rec_id, 'message': 'Placa registrada.'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
if tenant:
|
||||||
|
try: tenant.close()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
|
||||||
|
def _match_plate_to_catalog(master_conn, plate_info):
|
||||||
|
"""Try to match plate vehicle info to the catalog DB (same logic as VIN)."""
|
||||||
|
return _match_vin_to_catalog(master_conn, {
|
||||||
|
'make': plate_info.get('make'),
|
||||||
|
'model': plate_info.get('model'),
|
||||||
|
'year': plate_info.get('year'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def _match_vin_to_catalog(master_conn, vin_info):
|
def _match_vin_to_catalog(master_conn, vin_info):
|
||||||
"""Try to find brand_id, model_id, year_id, mye_id from decoded VIN info."""
|
"""Try to find brand_id, model_id, year_id, mye_id from decoded VIN info."""
|
||||||
make = (vin_info.get('make') or '').upper().strip()
|
make = (vin_info.get('make') or '').upper().strip()
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ from services.audit import log_action
|
|||||||
inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory')
|
inventory_bp = Blueprint('inventory', __name__, url_prefix='/pos/api/inventory')
|
||||||
|
|
||||||
|
|
||||||
|
# ─── AI Classification ───────────────────────────
|
||||||
|
|
||||||
|
@inventory_bp.route('/classify/<part_number>', methods=['GET'])
|
||||||
|
@require_auth('inventory.create')
|
||||||
|
def classify_part_endpoint(part_number):
|
||||||
|
"""Ask AI to identify a part by its OEM number."""
|
||||||
|
from services.ai_chat import classify_part
|
||||||
|
result = classify_part(part_number)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
# ─── Item CRUD ──────────────────────────────────
|
# ─── Item CRUD ──────────────────────────────────
|
||||||
|
|
||||||
@inventory_bp.route('/items', methods=['GET'])
|
@inventory_bp.route('/items', methods=['GET'])
|
||||||
@@ -457,6 +468,80 @@ def delete_image(item_id):
|
|||||||
return jsonify({'message': 'Image deleted'})
|
return jsonify({'message': 'Image deleted'})
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Bulk Image Import ─────────────────────────
|
||||||
|
|
||||||
|
@inventory_bp.route('/bulk-images', methods=['POST'])
|
||||||
|
@require_auth('inventory.edit')
|
||||||
|
def bulk_upload_images():
|
||||||
|
"""Bulk import images from URLs for multiple inventory items.
|
||||||
|
|
||||||
|
Accepts JSON: {items: [{part_number, image_url}, ...]}
|
||||||
|
Downloads each image, resizes/optimizes, saves to disk, updates DB.
|
||||||
|
Returns {imported: N, errors: [...]}
|
||||||
|
"""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
items_list = data.get('items', [])
|
||||||
|
|
||||||
|
if not items_list:
|
||||||
|
return jsonify({'error': 'items array required'}), 400
|
||||||
|
|
||||||
|
if len(items_list) > 500:
|
||||||
|
return jsonify({'error': 'Maximum 500 items per request'}), 400
|
||||||
|
|
||||||
|
from services.image_scraper import bulk_import
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
try:
|
||||||
|
result = bulk_import(conn, g.tenant_id, items_list)
|
||||||
|
log_action(conn, 'BULK_IMAGE_IMPORT', 'inventory', None,
|
||||||
|
new_value={'imported': result['imported'], 'error_count': len(result['errors'])})
|
||||||
|
return jsonify(result)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@inventory_bp.route('/items/<int:item_id>/auto-image', methods=['POST'])
|
||||||
|
@require_auth('inventory.edit')
|
||||||
|
def auto_image(item_id):
|
||||||
|
"""Generate a placeholder image for an inventory item.
|
||||||
|
|
||||||
|
Creates a branded placeholder with the part number text.
|
||||||
|
Useful when no real product image is available.
|
||||||
|
"""
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (item_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Item not found'}), 404
|
||||||
|
|
||||||
|
part_number, name = row
|
||||||
|
|
||||||
|
try:
|
||||||
|
from services.image_scraper import generate_placeholder
|
||||||
|
rel_url = generate_placeholder(g.tenant_id, item_id, part_number, name or '')
|
||||||
|
|
||||||
|
cur.execute("UPDATE inventory SET image_url = %s WHERE id = %s", (rel_url, item_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_action(conn, 'AUTO_IMAGE_GENERATED', 'inventory', item_id,
|
||||||
|
new_value={'image_url': rel_url})
|
||||||
|
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({
|
||||||
|
'image_url': rel_url,
|
||||||
|
'message': 'Placeholder image generated'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ─── Stock Operations ──────────────────────────
|
# ─── Stock Operations ──────────────────────────
|
||||||
|
|
||||||
@inventory_bp.route('/purchase', methods=['POST'])
|
@inventory_bp.route('/purchase', methods=['POST'])
|
||||||
|
|||||||
16
pos/migrations/v1.7_plates.sql
Normal file
16
pos/migrations/v1.7_plates.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Plate-to-vehicle lookup table (tenant DB)
|
||||||
|
-- Allows instant vehicle identification from Mexican license plates.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS plate_vehicles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
plate VARCHAR(20) UNIQUE NOT NULL,
|
||||||
|
make VARCHAR(100),
|
||||||
|
model VARCHAR(100),
|
||||||
|
year INTEGER,
|
||||||
|
vin VARCHAR(17),
|
||||||
|
customer_id INTEGER REFERENCES customers(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plate_vehicles_plate ON plate_vehicles(plate);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plate_vehicles_customer ON plate_vehicles(customer_id);
|
||||||
188
pos/services/image_scraper.py
Normal file
188
pos/services/image_scraper.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# /home/Autopartes/pos/services/image_scraper.py
|
||||||
|
"""Bulk image downloader and processor for inventory parts.
|
||||||
|
|
||||||
|
Provides two capabilities:
|
||||||
|
1. Bulk import: Accept a list of {part_number, image_url}, download each image,
|
||||||
|
resize/optimize with Pillow, save to /pos/static/images/parts/, and update
|
||||||
|
the inventory.image_url in the tenant DB.
|
||||||
|
2. Auto-image: Generate a placeholder image with the part number text when no
|
||||||
|
real image is available.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
IMAGES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'images', 'parts')
|
||||||
|
MAX_SIZE = 800
|
||||||
|
THUMB_SIZE = 300
|
||||||
|
QUALITY = 85
|
||||||
|
DOWNLOAD_TIMEOUT = 15
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dir():
|
||||||
|
"""Ensure the parts image directory exists."""
|
||||||
|
os.makedirs(IMAGES_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _process_and_save(image_data, tenant_id, item_id):
|
||||||
|
"""Resize image, save full + thumbnail, return the relative URL path."""
|
||||||
|
_ensure_dir()
|
||||||
|
|
||||||
|
img = Image.open(io.BytesIO(image_data))
|
||||||
|
if img.mode not in ('RGB', 'L'):
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
# Full size
|
||||||
|
full = img.copy()
|
||||||
|
full.thumbnail((MAX_SIZE, MAX_SIZE), Image.LANCZOS)
|
||||||
|
full_path = os.path.join(IMAGES_DIR, f'{tenant_id}_{item_id}.jpg')
|
||||||
|
full.save(full_path, format='JPEG', quality=QUALITY)
|
||||||
|
|
||||||
|
# Thumbnail
|
||||||
|
thumb = img.copy()
|
||||||
|
thumb.thumbnail((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS)
|
||||||
|
thumb_path = os.path.join(IMAGES_DIR, f'{tenant_id}_{item_id}_thumb.jpg')
|
||||||
|
thumb.save(thumb_path, format='JPEG', quality=QUALITY)
|
||||||
|
|
||||||
|
return f'/pos/static/images/parts/{tenant_id}_{item_id}.jpg'
|
||||||
|
|
||||||
|
|
||||||
|
def download_and_process(url, tenant_id, item_id):
|
||||||
|
"""Download an image from a URL, process it, and save locally.
|
||||||
|
|
||||||
|
Returns the relative URL path on success, or raises on failure.
|
||||||
|
"""
|
||||||
|
resp = requests.get(url, timeout=DOWNLOAD_TIMEOUT, stream=True)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
# Read up to 10 MB
|
||||||
|
data = resp.content
|
||||||
|
if len(data) > 10 * 1024 * 1024:
|
||||||
|
raise ValueError('Image exceeds 10 MB limit')
|
||||||
|
|
||||||
|
return _process_and_save(data, tenant_id, item_id)
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_import(tenant_conn, tenant_id, items):
|
||||||
|
"""Process a list of {part_number, image_url} items.
|
||||||
|
|
||||||
|
For each item:
|
||||||
|
1. Find the inventory item by part_number
|
||||||
|
2. Download the image
|
||||||
|
3. Resize and save
|
||||||
|
4. Update inventory.image_url
|
||||||
|
|
||||||
|
Returns: {imported: int, errors: [str]}
|
||||||
|
"""
|
||||||
|
imported = 0
|
||||||
|
errors = []
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for entry in items:
|
||||||
|
pn = (entry.get('part_number') or '').strip()
|
||||||
|
url = (entry.get('image_url') or '').strip()
|
||||||
|
|
||||||
|
if not pn or not url:
|
||||||
|
errors.append(f'Missing part_number or image_url: {entry}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find inventory item
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM inventory WHERE part_number = %s AND is_active = true LIMIT 1",
|
||||||
|
(pn,)
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
errors.append(f'Part not found: {pn}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_id = row[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
rel_url = download_and_process(url, tenant_id, item_id)
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE inventory SET image_url = %s WHERE id = %s",
|
||||||
|
(rel_url, item_id)
|
||||||
|
)
|
||||||
|
tenant_conn.commit()
|
||||||
|
imported += 1
|
||||||
|
except Exception as e:
|
||||||
|
tenant_conn.rollback()
|
||||||
|
errors.append(f'{pn}: {str(e)}')
|
||||||
|
logger.warning('Failed to import image for %s: %s', pn, e)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
return {'imported': imported, 'errors': errors}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_placeholder(tenant_id, item_id, part_number, name=''):
|
||||||
|
"""Generate a placeholder image with the part number text.
|
||||||
|
|
||||||
|
Creates a 400x400 gray image with the part number centered.
|
||||||
|
Returns the relative URL path.
|
||||||
|
"""
|
||||||
|
_ensure_dir()
|
||||||
|
|
||||||
|
width, height = 400, 400
|
||||||
|
bg_color = (240, 240, 240)
|
||||||
|
text_color = (80, 80, 80)
|
||||||
|
accent_color = (245, 166, 35) # Nexus orange
|
||||||
|
|
||||||
|
img = Image.new('RGB', (width, height), bg_color)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Draw accent bar at top
|
||||||
|
draw.rectangle([0, 0, width, 6], fill=accent_color)
|
||||||
|
|
||||||
|
# Draw part icon placeholder (box outline)
|
||||||
|
box_margin = 100
|
||||||
|
draw.rectangle(
|
||||||
|
[box_margin, box_margin, width - box_margin, height - box_margin - 40],
|
||||||
|
outline=(200, 200, 200), width=2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to use a built-in font, fall back to default
|
||||||
|
try:
|
||||||
|
font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
|
||||||
|
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
|
||||||
|
except (IOError, OSError):
|
||||||
|
font_large = ImageFont.load_default()
|
||||||
|
font_small = font_large
|
||||||
|
|
||||||
|
# Part number (centered)
|
||||||
|
pn_text = part_number or 'SIN NUMERO'
|
||||||
|
bbox = draw.textbbox((0, 0), pn_text, font=font_large)
|
||||||
|
tw = bbox[2] - bbox[0]
|
||||||
|
draw.text(((width - tw) / 2, height - 80), pn_text, fill=accent_color, font=font_large)
|
||||||
|
|
||||||
|
# Name (centered, below part number)
|
||||||
|
if name:
|
||||||
|
display_name = name[:30] + ('...' if len(name) > 30 else '')
|
||||||
|
bbox2 = draw.textbbox((0, 0), display_name, font=font_small)
|
||||||
|
tw2 = bbox2[2] - bbox2[0]
|
||||||
|
draw.text(((width - tw2) / 2, height - 50), display_name, fill=text_color, font=font_small)
|
||||||
|
|
||||||
|
# "SIN IMAGEN" text centered in box
|
||||||
|
no_img = 'SIN IMAGEN'
|
||||||
|
bbox3 = draw.textbbox((0, 0), no_img, font=font_small)
|
||||||
|
tw3 = bbox3[2] - bbox3[0]
|
||||||
|
draw.text(((width - tw3) / 2, (height - 40) / 2), no_img, fill=(180, 180, 180), font=font_small)
|
||||||
|
|
||||||
|
# Save
|
||||||
|
full_path = os.path.join(IMAGES_DIR, f'{tenant_id}_{item_id}.jpg')
|
||||||
|
img.save(full_path, format='JPEG', quality=QUALITY)
|
||||||
|
|
||||||
|
thumb = img.copy()
|
||||||
|
thumb.thumbnail((THUMB_SIZE, THUMB_SIZE), Image.LANCZOS)
|
||||||
|
thumb_path = os.path.join(IMAGES_DIR, f'{tenant_id}_{item_id}_thumb.jpg')
|
||||||
|
thumb.save(thumb_path, format='JPEG', quality=QUALITY)
|
||||||
|
|
||||||
|
return f'/pos/static/images/parts/{tenant_id}_{item_id}.jpg'
|
||||||
108
pos/services/plate_lookup.py
Normal file
108
pos/services/plate_lookup.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# /home/Autopartes/pos/services/plate_lookup.py
|
||||||
|
"""Mexican license plate lookup service.
|
||||||
|
|
||||||
|
Validates Mexican plate formats and searches the local plate_vehicles table.
|
||||||
|
Since there is no free REPUVE API, this uses a tenant-local lookup table
|
||||||
|
populated when customers register their vehicles.
|
||||||
|
|
||||||
|
Mexican plate formats:
|
||||||
|
- Standard: ABC-1234 (3 letters + hyphen + 3-4 digits)
|
||||||
|
- Alternate: AB-123-C (2 letters + 3 digits + 1 letter)
|
||||||
|
- Also accepted without hyphens: ABC1234, AB123C
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Patterns for Mexican plates (with or without hyphens)
|
||||||
|
_PATTERNS = [
|
||||||
|
re.compile(r'^[A-Z]{3}-?\d{3,4}$'), # ABC-1234 or ABC1234
|
||||||
|
re.compile(r'^[A-Z]{2}-?\d{3}-?[A-Z]$'), # AB-123-C or AB123C
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_plate(plate):
|
||||||
|
"""Normalize a plate string: uppercase, strip spaces/hyphens."""
|
||||||
|
if not plate:
|
||||||
|
return ''
|
||||||
|
return re.sub(r'[\s\-]+', '', plate.strip().upper())
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_mexican_plate(plate):
|
||||||
|
"""Check if a string looks like a valid Mexican license plate."""
|
||||||
|
norm = normalize_plate(plate)
|
||||||
|
return any(p.match(norm) for p in _PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def search_plate(tenant_conn, plate):
|
||||||
|
"""Search plate_vehicles table for a matching plate.
|
||||||
|
|
||||||
|
Returns dict with vehicle info or None if not found.
|
||||||
|
"""
|
||||||
|
norm = normalize_plate(plate)
|
||||||
|
if not norm:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, plate, make, model, year, vin, customer_id, created_at
|
||||||
|
FROM plate_vehicles
|
||||||
|
WHERE REPLACE(REPLACE(UPPER(plate), '-', ''), ' ', '') = %s
|
||||||
|
LIMIT 1
|
||||||
|
""", (norm,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
'id': row[0],
|
||||||
|
'plate': row[1],
|
||||||
|
'make': row[2],
|
||||||
|
'model': row[3],
|
||||||
|
'year': row[4],
|
||||||
|
'vin': row[5],
|
||||||
|
'customer_id': row[6],
|
||||||
|
'created_at': row[7].isoformat() if row[7] else None,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
def register_plate(tenant_conn, plate, make=None, model=None, year=None,
|
||||||
|
vin=None, customer_id=None):
|
||||||
|
"""Register or update a plate-to-vehicle mapping.
|
||||||
|
|
||||||
|
Returns the plate_vehicles record id.
|
||||||
|
"""
|
||||||
|
norm_display = normalize_plate(plate)
|
||||||
|
if not norm_display:
|
||||||
|
raise ValueError('Plate is required')
|
||||||
|
|
||||||
|
# Format nicely: ABC-1234 or AB-123-C
|
||||||
|
if re.match(r'^[A-Z]{3}\d{3,4}$', norm_display):
|
||||||
|
display = norm_display[:3] + '-' + norm_display[3:]
|
||||||
|
elif re.match(r'^[A-Z]{2}\d{3}[A-Z]$', norm_display):
|
||||||
|
display = norm_display[:2] + '-' + norm_display[2:5] + '-' + norm_display[5:]
|
||||||
|
else:
|
||||||
|
display = norm_display
|
||||||
|
|
||||||
|
cur = tenant_conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO plate_vehicles (plate, make, model, year, vin, customer_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
ON CONFLICT (plate) DO UPDATE SET
|
||||||
|
make = COALESCE(EXCLUDED.make, plate_vehicles.make),
|
||||||
|
model = COALESCE(EXCLUDED.model, plate_vehicles.model),
|
||||||
|
year = COALESCE(EXCLUDED.year, plate_vehicles.year),
|
||||||
|
vin = COALESCE(EXCLUDED.vin, plate_vehicles.vin),
|
||||||
|
customer_id = COALESCE(EXCLUDED.customer_id, plate_vehicles.customer_id)
|
||||||
|
RETURNING id
|
||||||
|
""", (display, make, model, year, vin, customer_id))
|
||||||
|
rec_id = cur.fetchone()[0]
|
||||||
|
tenant_conn.commit()
|
||||||
|
return rec_id
|
||||||
|
except Exception:
|
||||||
|
tenant_conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
@@ -6,72 +6,335 @@ Falls back to the original name if no match is found.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
PART_TRANSLATIONS = {
|
PART_TRANSLATIONS = {
|
||||||
|
# ─── Brake System ───
|
||||||
'Brake Pad Set': 'Juego de Balatas',
|
'Brake Pad Set': 'Juego de Balatas',
|
||||||
|
'Brake Pad': 'Balata',
|
||||||
'Brake Disc': 'Disco de Freno',
|
'Brake Disc': 'Disco de Freno',
|
||||||
|
'Brake Rotor': 'Disco de Freno',
|
||||||
'Shock Absorber': 'Amortiguador',
|
'Shock Absorber': 'Amortiguador',
|
||||||
'Oil Filter': 'Filtro de Aceite',
|
'Brake Caliper': 'Caliper de Freno',
|
||||||
'Air Filter': 'Filtro de Aire',
|
'Brake Drum': 'Tambor de Freno',
|
||||||
'Spark Plug': 'Bujía',
|
'Brake Hose': 'Manguera de Freno',
|
||||||
'Water Pump': 'Bomba de Agua',
|
'Brake Line': 'Línea de Freno',
|
||||||
'Alternator': 'Alternador',
|
'Brake Shoe': 'Zapata de Freno',
|
||||||
'Starter Motor': 'Motor de Arranque',
|
'Master Cylinder': 'Cilindro Maestro',
|
||||||
'Radiator': 'Radiador',
|
'Wheel Cylinder': 'Cilindro de Rueda',
|
||||||
'Thermostat': 'Termostato',
|
'Brake Booster': 'Booster de Freno',
|
||||||
'Timing Belt': 'Banda de Distribución',
|
'ABS Sensor': 'Sensor de ABS',
|
||||||
'V-Belt': 'Banda Serpentina',
|
'ABS Module': 'Módulo de ABS',
|
||||||
'Serpentine Belt': 'Banda Serpentina',
|
'Brake Fluid': 'Líquido de Frenos',
|
||||||
'Clutch Kit': 'Kit de Embrague',
|
'Parking Brake': 'Freno de Mano',
|
||||||
'Fuel Pump': 'Bomba de Gasolina',
|
'Parking Brake Cable': 'Cable de Freno de Mano',
|
||||||
'Fuel Filter': 'Filtro de Gasolina',
|
'Brake Pedal': 'Pedal de Freno',
|
||||||
'Oxygen Sensor': 'Sensor de Oxígeno',
|
|
||||||
'Ignition Coil': 'Bobina de Encendido',
|
# ─── Engine ───
|
||||||
'Wheel Bearing': 'Balero de Rueda',
|
|
||||||
'Tie Rod End': 'Terminal de Dirección',
|
|
||||||
'Ball Joint': 'Rótula',
|
|
||||||
'CV Joint': 'Junta Homocinética',
|
|
||||||
'Wiper Blade': 'Pluma Limpiaparabrisas',
|
|
||||||
'Battery': 'Batería',
|
|
||||||
'Headlight': 'Faro Delantero',
|
|
||||||
'Tail Light': 'Calavera Trasera',
|
|
||||||
'Mirror': 'Espejo',
|
|
||||||
'Muffler': 'Mofle',
|
|
||||||
'Exhaust Pipe': 'Tubo de Escape',
|
|
||||||
'Catalytic Converter': 'Catalizador',
|
|
||||||
'Piston': 'Pistón',
|
'Piston': 'Pistón',
|
||||||
|
'Piston Ring': 'Anillos de Pistón',
|
||||||
'Gasket': 'Junta/Empaque',
|
'Gasket': 'Junta/Empaque',
|
||||||
|
'Head Gasket': 'Junta de Cabeza',
|
||||||
|
'Valve Cover Gasket': 'Junta de Tapa de Válvulas',
|
||||||
|
'Oil Pan Gasket': 'Junta de Cárter',
|
||||||
|
'Intake Manifold Gasket': 'Junta de Múltiple de Admisión',
|
||||||
|
'Exhaust Manifold Gasket': 'Junta de Múltiple de Escape',
|
||||||
'Valve': 'Válvula',
|
'Valve': 'Válvula',
|
||||||
|
'Valve Spring': 'Resorte de Válvula',
|
||||||
|
'Valve Stem Seal': 'Sello de Válvula',
|
||||||
'Camshaft': 'Árbol de Levas',
|
'Camshaft': 'Árbol de Levas',
|
||||||
'Crankshaft': 'Cigüeñal',
|
'Crankshaft': 'Cigüeñal',
|
||||||
'Connecting Rod': 'Biela',
|
'Connecting Rod': 'Biela',
|
||||||
'Engine Mount': 'Soporte de Motor',
|
'Engine Mount': 'Soporte de Motor',
|
||||||
|
'Flywheel': 'Volante de Motor',
|
||||||
|
'Timing Belt': 'Banda de Distribución',
|
||||||
|
'Timing Chain': 'Cadena de Distribución',
|
||||||
|
'Timing Cover': 'Tapa de Distribución',
|
||||||
|
'Rocker Arm': 'Balancín',
|
||||||
|
'Push Rod': 'Varilla de Empuje',
|
||||||
|
'Turbocharger': 'Turbocompresor',
|
||||||
|
'Supercharger': 'Supercargador',
|
||||||
|
'Intake Manifold': 'Múltiple de Admisión',
|
||||||
|
'Exhaust Manifold': 'Múltiple de Escape',
|
||||||
|
'Oil Pump': 'Bomba de Aceite',
|
||||||
|
'Oil Pan': 'Cárter',
|
||||||
|
'Oil Cooler': 'Enfriador de Aceite',
|
||||||
|
'Oil Pressure Switch': 'Bulbo de Aceite',
|
||||||
|
'Cylinder Head': 'Cabeza de Cilindro',
|
||||||
|
'Engine Block': 'Bloque de Motor',
|
||||||
|
'Harmonic Balancer': 'Polea de Cigüeñal',
|
||||||
|
'EGR Valve': 'Válvula EGR',
|
||||||
|
'PCV Valve': 'Válvula PCV',
|
||||||
|
'Vacuum Pump': 'Bomba de Vacío',
|
||||||
|
|
||||||
|
# ─── Filters ───
|
||||||
|
'Oil Filter': 'Filtro de Aceite',
|
||||||
|
'Air Filter': 'Filtro de Aire',
|
||||||
|
'Fuel Filter': 'Filtro de Gasolina',
|
||||||
|
'Cabin Air Filter': 'Filtro de Cabina',
|
||||||
|
'Transmission Filter': 'Filtro de Transmisión',
|
||||||
|
'Hydraulic Filter': 'Filtro Hidráulico',
|
||||||
|
|
||||||
|
# ─── Ignition ───
|
||||||
|
'Spark Plug': 'Bujía',
|
||||||
|
'Ignition Coil': 'Bobina de Encendido',
|
||||||
|
'Glow Plug': 'Bujía de Precalentamiento',
|
||||||
|
'Distributor Cap': 'Tapa de Distribuidor',
|
||||||
|
'Distributor Rotor': 'Rotor de Distribuidor',
|
||||||
|
'Ignition Wire': 'Cable de Bujía',
|
||||||
|
'Ignition Module': 'Módulo de Encendido',
|
||||||
|
'Knock Sensor': 'Sensor de Detonación',
|
||||||
|
'Crankshaft Position Sensor': 'Sensor de Posición del Cigüeñal',
|
||||||
|
'Camshaft Position Sensor': 'Sensor de Posición del Árbol de Levas',
|
||||||
|
|
||||||
|
# ─── Fuel System ───
|
||||||
|
'Fuel Pump': 'Bomba de Gasolina',
|
||||||
|
'Fuel Injector': 'Inyector de Gasolina',
|
||||||
|
'Injector': 'Inyector',
|
||||||
|
'Throttle Body': 'Cuerpo de Aceleración',
|
||||||
|
'Mass Air Flow Sensor': 'Sensor MAF',
|
||||||
|
'Oxygen Sensor': 'Sensor de Oxígeno',
|
||||||
|
'Fuel Tank': 'Tanque de Gasolina',
|
||||||
|
'Fuel Pressure Regulator': 'Regulador de Presión de Combustible',
|
||||||
|
'Fuel Rail': 'Riel de Inyectores',
|
||||||
|
'Carburetor': 'Carburador',
|
||||||
|
'Fuel Sending Unit': 'Flotador de Gasolina',
|
||||||
|
'Accelerator Pedal': 'Pedal de Acelerador',
|
||||||
|
'Throttle Position Sensor': 'Sensor TPS',
|
||||||
|
'MAP Sensor': 'Sensor MAP',
|
||||||
|
|
||||||
|
# ─── Cooling System ───
|
||||||
|
'Radiator': 'Radiador',
|
||||||
|
'Water Pump': 'Bomba de Agua',
|
||||||
|
'Thermostat': 'Termostato',
|
||||||
|
'Coolant': 'Anticongelante',
|
||||||
|
'Coolant Temperature Sensor': 'Sensor de Temperatura',
|
||||||
|
'Radiator Fan': 'Ventilador de Radiador',
|
||||||
|
'Fan Motor': 'Motor de Ventilador de Radiador',
|
||||||
|
'Radiator Hose': 'Manguera de Radiador',
|
||||||
|
'Coolant Reservoir': 'Depósito de Anticongelante',
|
||||||
|
'Fan Clutch': 'Clutch de Ventilador',
|
||||||
|
'Thermostat Housing': 'Carcasa de Termostato',
|
||||||
|
'Water Outlet': 'Toma de Agua',
|
||||||
|
|
||||||
|
# ─── Electrical ───
|
||||||
|
'Alternator': 'Alternador',
|
||||||
|
'Starter Motor': 'Motor de Arranque',
|
||||||
|
'Battery': 'Batería',
|
||||||
|
'Battery Cable': 'Cable de Batería',
|
||||||
|
'Battery Terminal': 'Terminal de Batería',
|
||||||
|
'Voltage Regulator': 'Regulador de Voltaje',
|
||||||
|
'Sensor': 'Sensor',
|
||||||
|
'Switch': 'Interruptor',
|
||||||
|
'Relay': 'Relevador',
|
||||||
|
'Fuse': 'Fusible',
|
||||||
|
'Fuse Box': 'Caja de Fusibles',
|
||||||
|
'Bulb': 'Foco',
|
||||||
|
'Horn': 'Claxon',
|
||||||
|
'Antenna': 'Antena',
|
||||||
|
'Wiring Harness': 'Arnés de Cables',
|
||||||
|
'Solenoid': 'Solenoide',
|
||||||
|
'Ignition Switch': 'Switch de Encendido',
|
||||||
|
'Headlight Switch': 'Switch de Faros',
|
||||||
|
'Turn Signal Switch': 'Switch de Direccionales',
|
||||||
|
'Window Switch': 'Switch de Ventanilla',
|
||||||
|
'Blower Motor Resistor': 'Resistencia de Ventilador',
|
||||||
|
'Speed Sensor': 'Sensor de Velocidad',
|
||||||
|
|
||||||
|
# ─── Belts & Pulleys ───
|
||||||
|
'V-Belt': 'Banda Serpentina',
|
||||||
|
'Serpentine Belt': 'Banda Serpentina',
|
||||||
|
'Tensioner': 'Tensor',
|
||||||
|
'Belt Tensioner': 'Tensor de Banda',
|
||||||
|
'Idler Pulley': 'Polea Loca',
|
||||||
|
'Belt': 'Banda',
|
||||||
|
'Chain': 'Cadena',
|
||||||
|
'Pulley': 'Polea',
|
||||||
|
|
||||||
|
# ─── Clutch & Transmission ───
|
||||||
|
'Clutch Kit': 'Kit de Embrague',
|
||||||
|
'Clutch Disc': 'Disco de Embrague',
|
||||||
|
'Clutch Pressure Plate': 'Plato de Presión',
|
||||||
|
'Clutch Release Bearing': 'Collarín',
|
||||||
|
'Clutch Master Cylinder': 'Cilindro Maestro de Embrague',
|
||||||
|
'Clutch Slave Cylinder': 'Cilindro Esclavo de Embrague',
|
||||||
|
'Clutch Cable': 'Cable de Embrague',
|
||||||
|
'Clutch Pedal': 'Pedal de Clutch',
|
||||||
'Transmission Mount': 'Soporte de Transmisión',
|
'Transmission Mount': 'Soporte de Transmisión',
|
||||||
|
'Transfer Case': 'Caja de Transferencia',
|
||||||
|
'Gear Shift': 'Palanca de Velocidades',
|
||||||
|
'Shift Cable': 'Cable de Palanca',
|
||||||
|
'Transmission Fluid': 'Aceite de Transmisión',
|
||||||
|
'Torque Converter': 'Convertidor de Par',
|
||||||
|
'Synchronizer Ring': 'Anillo Sincronizador',
|
||||||
|
|
||||||
|
# ─── Drivetrain ───
|
||||||
|
'Wheel Hub': 'Maza de Rueda',
|
||||||
|
'Axle Shaft': 'Flecha/Semieje',
|
||||||
|
'Drive Shaft': 'Flecha Cardán',
|
||||||
|
'U-Joint': 'Cruceta',
|
||||||
|
'CV Joint': 'Junta Homocinética',
|
||||||
|
'CV Boot': 'Guardapolvo Homocinético',
|
||||||
|
'Differential': 'Diferencial',
|
||||||
|
'Wheel Bearing': 'Balero de Rueda',
|
||||||
|
'Wheel Stud': 'Birlo de Rueda',
|
||||||
|
'Lug Nut': 'Tuerca de Rueda',
|
||||||
|
'Axle Nut': 'Tuerca de Flecha',
|
||||||
|
'Bearing': 'Balero/Rodamiento',
|
||||||
|
'Seal': 'Sello/Retén',
|
||||||
|
'Bushing': 'Buje',
|
||||||
|
'Mount': 'Soporte',
|
||||||
|
|
||||||
|
# ─── Suspension ───
|
||||||
'Control Arm': 'Brazo de Suspensión',
|
'Control Arm': 'Brazo de Suspensión',
|
||||||
'Strut': 'Puntal',
|
'Strut': 'Puntal',
|
||||||
|
'Strut Mount': 'Base de Amortiguador',
|
||||||
'Spring': 'Resorte',
|
'Spring': 'Resorte',
|
||||||
|
'Coil Spring': 'Resorte Helicoidal',
|
||||||
|
'Leaf Spring': 'Ballesta',
|
||||||
'Stabilizer Bar': 'Barra Estabilizadora',
|
'Stabilizer Bar': 'Barra Estabilizadora',
|
||||||
'Brake Caliper': 'Caliper de Freno',
|
'Stabilizer Link': 'Bieleta Estabilizadora',
|
||||||
'Brake Drum': 'Tambor de Freno',
|
'Sway Bar Link': 'Bieleta Estabilizadora',
|
||||||
'Brake Hose': 'Manguera de Freno',
|
'Trailing Arm': 'Brazo Trasero',
|
||||||
'Master Cylinder': 'Cilindro Maestro',
|
'Torsion Bar': 'Barra de Torsión',
|
||||||
'Wheel Cylinder': 'Cilindro de Rueda',
|
'Shock Mount': 'Base de Amortiguador',
|
||||||
|
'Bump Stop': 'Tope de Amortiguador',
|
||||||
|
'Air Spring': 'Bolsa de Aire de Suspensión',
|
||||||
|
'Panhard Rod': 'Barra Panhard',
|
||||||
|
|
||||||
|
# ─── Steering ───
|
||||||
'Power Steering Pump': 'Bomba de Dirección Hidráulica',
|
'Power Steering Pump': 'Bomba de Dirección Hidráulica',
|
||||||
'Rack and Pinion': 'Cremallera de Dirección',
|
'Rack and Pinion': 'Cremallera de Dirección',
|
||||||
|
'Tie Rod End': 'Terminal de Dirección',
|
||||||
|
'Tie Rod': 'Barra de Dirección',
|
||||||
|
'Ball Joint': 'Rótula',
|
||||||
|
'Steering Wheel': 'Volante',
|
||||||
|
'Steering Column': 'Columna de Dirección',
|
||||||
|
'Power Steering Hose': 'Manguera de Dirección',
|
||||||
|
'Power Steering Fluid': 'Aceite de Dirección',
|
||||||
|
'Pitman Arm': 'Brazo Pitman',
|
||||||
|
'Idler Arm': 'Brazo Loco',
|
||||||
|
'Center Link': 'Barra Central de Dirección',
|
||||||
|
'Drag Link': 'Barra de Acoplamiento',
|
||||||
|
'Steering Knuckle': 'Muñón de Dirección',
|
||||||
|
'King Pin': 'Perno Rey',
|
||||||
|
|
||||||
|
# ─── Exhaust ───
|
||||||
|
'Muffler': 'Mofle',
|
||||||
|
'Exhaust Pipe': 'Tubo de Escape',
|
||||||
|
'Catalytic Converter': 'Catalizador',
|
||||||
|
'Exhaust Gasket': 'Junta de Escape',
|
||||||
|
'Exhaust Clamp': 'Abrazadera de Escape',
|
||||||
|
'Resonator': 'Resonador',
|
||||||
|
'Flex Pipe': 'Tubo Flexible de Escape',
|
||||||
|
'O2 Sensor': 'Sensor de Oxígeno',
|
||||||
|
'Exhaust Tip': 'Punta de Escape',
|
||||||
|
|
||||||
|
# ─── A/C & Heating ───
|
||||||
'A/C Compressor': 'Compresor de Aire Acondicionado',
|
'A/C Compressor': 'Compresor de Aire Acondicionado',
|
||||||
'Condenser': 'Condensador',
|
'Condenser': 'Condensador',
|
||||||
'Evaporator': 'Evaporador',
|
'Evaporator': 'Evaporador',
|
||||||
'Heater Core': 'Radiador de Calefacción',
|
'Heater Core': 'Radiador de Calefacción',
|
||||||
'Blower Motor': 'Motor de Ventilador',
|
'Blower Motor': 'Motor de Ventilador',
|
||||||
'Tensioner': 'Tensor',
|
'A/C Hose': 'Manguera de Aire Acondicionado',
|
||||||
'Idler Pulley': 'Polea Loca',
|
'Expansion Valve': 'Válvula de Expansión',
|
||||||
'Flywheel': 'Volante de Motor',
|
'A/C Accumulator': 'Acumulador de A/C',
|
||||||
'Injector': 'Inyector',
|
'A/C Receiver Drier': 'Filtro Deshidratador',
|
||||||
'Throttle Body': 'Cuerpo de Aceleración',
|
'A/C Clutch': 'Clutch de Compresor',
|
||||||
'Mass Air Flow Sensor': 'Sensor MAF',
|
'Heater Valve': 'Válvula de Calefacción',
|
||||||
'Coolant': 'Anticongelante',
|
'Heater Hose': 'Manguera de Calefacción',
|
||||||
'Brake Fluid': 'Líquido de Frenos',
|
|
||||||
'Transmission Fluid': 'Aceite de Transmisión',
|
# ─── Lighting ───
|
||||||
|
'Headlight': 'Faro Delantero',
|
||||||
|
'Headlight Assembly': 'Faro Delantero Completo',
|
||||||
|
'Tail Light': 'Calavera Trasera',
|
||||||
|
'Tail Light Assembly': 'Calavera Trasera Completa',
|
||||||
|
'Fog Light': 'Faro de Niebla',
|
||||||
|
'Turn Signal': 'Direccional',
|
||||||
|
'Turn Signal Light': 'Luz Direccional',
|
||||||
|
'Side Marker': 'Luz Lateral',
|
||||||
|
'Reverse Light': 'Luz de Reversa',
|
||||||
|
'Third Brake Light': 'Tercera Luz de Freno',
|
||||||
|
'License Plate Light': 'Luz de Placa',
|
||||||
|
'Interior Light': 'Luz Interior',
|
||||||
|
'Dome Light': 'Luz de Techo',
|
||||||
|
'DRL Light': 'Luz Diurna',
|
||||||
|
'LED Module': 'Módulo LED',
|
||||||
|
'Ballast': 'Balastro',
|
||||||
|
'HID Bulb': 'Foco HID',
|
||||||
|
|
||||||
|
# ─── Body & Exterior ───
|
||||||
|
'Bumper': 'Defensa',
|
||||||
|
'Front Bumper': 'Defensa Delantera',
|
||||||
|
'Rear Bumper': 'Defensa Trasera',
|
||||||
|
'Fender': 'Salpicadera',
|
||||||
|
'Grille': 'Parrilla',
|
||||||
|
'Hood': 'Cofre',
|
||||||
|
'Trunk Lid': 'Tapa de Cajuela',
|
||||||
|
'Door': 'Puerta',
|
||||||
|
'Door Handle': 'Manija de Puerta',
|
||||||
|
'Door Hinge': 'Bisagra de Puerta',
|
||||||
|
'Door Lock': 'Cerradura',
|
||||||
|
'Door Lock Actuator': 'Actuador de Cerradura',
|
||||||
|
'Trunk Latch': 'Cerradura de Cajuela',
|
||||||
|
'Hood Latch': 'Cerradura de Cofre',
|
||||||
|
'Windshield': 'Parabrisas',
|
||||||
|
'Rear Window': 'Medallón Trasero',
|
||||||
|
'Door Glass': 'Cristal de Puerta',
|
||||||
|
'Quarter Panel': 'Panel Trasero',
|
||||||
|
'Rocker Panel': 'Estribo',
|
||||||
|
'Mud Flap': 'Loderas',
|
||||||
|
'Splash Guard': 'Guardabarros',
|
||||||
|
'Molding': 'Moldura',
|
||||||
|
'Emblem': 'Emblema',
|
||||||
|
'Body Clip': 'Grapa de Carrocería',
|
||||||
|
'Weather Strip': 'Empaque de Puerta',
|
||||||
|
|
||||||
|
# ─── Glass & Mirrors ───
|
||||||
|
'Mirror': 'Espejo',
|
||||||
|
'Side Mirror': 'Espejo Lateral',
|
||||||
|
'Rear View Mirror': 'Espejo Retrovisor',
|
||||||
|
'Mirror Glass': 'Luna de Espejo',
|
||||||
|
'Window Regulator': 'Elevador de Cristal',
|
||||||
|
'Window Motor': 'Motor de Elevador',
|
||||||
|
'Windshield Wiper Motor': 'Motor de Limpiaparabrisas',
|
||||||
|
'Wiper Blade': 'Pluma Limpiaparabrisas',
|
||||||
|
'Wiper Arm': 'Brazo de Limpiaparabrisas',
|
||||||
|
'Wiper Linkage': 'Varillaje de Limpiaparabrisas',
|
||||||
|
'Washer Pump': 'Bomba de Limpiaparabrisas',
|
||||||
|
'Washer Reservoir': 'Depósito de Limpiaparabrisas',
|
||||||
|
|
||||||
|
# ─── Interior & Safety ───
|
||||||
|
'Seat Belt': 'Cinturón de Seguridad',
|
||||||
|
'Air Bag': 'Bolsa de Aire',
|
||||||
|
'Clock Spring': 'Espiral de Reloj',
|
||||||
|
'Dashboard': 'Tablero',
|
||||||
|
'Instrument Cluster': 'Cuadro de Instrumentos',
|
||||||
|
'Glove Box': 'Guantera',
|
||||||
|
'Sun Visor': 'Visera',
|
||||||
|
'Headliner': 'Cielo de Techo',
|
||||||
|
'Floor Mat': 'Tapete',
|
||||||
|
'Seat Cover': 'Funda de Asiento',
|
||||||
|
'Carpet': 'Alfombra',
|
||||||
|
'Center Console': 'Consola Central',
|
||||||
|
'Cup Holder': 'Portavasos',
|
||||||
|
|
||||||
|
# ─── Hoses & General ───
|
||||||
|
'Hose': 'Manguera',
|
||||||
|
'Hose Clamp': 'Abrazadera de Manguera',
|
||||||
|
'Pump': 'Bomba',
|
||||||
|
|
||||||
|
# ─── Fluids & Chemicals ───
|
||||||
'Engine Oil': 'Aceite de Motor',
|
'Engine Oil': 'Aceite de Motor',
|
||||||
# Categories
|
'Power Steering Fluid': 'Aceite de Dirección',
|
||||||
|
'Brake Cleaner': 'Limpiador de Frenos',
|
||||||
|
'Antifreeze': 'Anticongelante',
|
||||||
|
'Windshield Washer Fluid': 'Líquido Limpiaparabrisas',
|
||||||
|
'Grease': 'Grasa',
|
||||||
|
'Thread Locker': 'Fijador de Roscas',
|
||||||
|
'Silicone': 'Silicón',
|
||||||
|
'Adhesive': 'Adhesivo',
|
||||||
|
'Sealant': 'Sellador',
|
||||||
|
'Refrigerant': 'Refrigerante',
|
||||||
|
|
||||||
|
# ─── Categories ───
|
||||||
'Braking System': 'Sistema de Frenos',
|
'Braking System': 'Sistema de Frenos',
|
||||||
'Engine': 'Motor',
|
'Engine': 'Motor',
|
||||||
'Suspension/Damping': 'Suspensión',
|
'Suspension/Damping': 'Suspensión',
|
||||||
@@ -88,6 +351,29 @@ PART_TRANSLATIONS = {
|
|||||||
'Axle Drive': 'Transmisión/Ejes',
|
'Axle Drive': 'Transmisión/Ejes',
|
||||||
'Body': 'Carrocería',
|
'Body': 'Carrocería',
|
||||||
'Axle Mounting/ Steering/ Wheels': 'Suspensión/Dirección/Ruedas',
|
'Axle Mounting/ Steering/ Wheels': 'Suspensión/Dirección/Ruedas',
|
||||||
|
'Transmission': 'Transmisión',
|
||||||
|
'Air Conditioning': 'Aire Acondicionado',
|
||||||
|
'Interior': 'Interior',
|
||||||
|
'Exterior': 'Exterior',
|
||||||
|
'Lighting': 'Iluminación',
|
||||||
|
'Wipers': 'Limpiaparabrisas',
|
||||||
|
'Accessories': 'Accesorios',
|
||||||
|
'Tools': 'Herramientas',
|
||||||
|
'Chemicals': 'Químicos/Líquidos',
|
||||||
|
'Hardware': 'Tornillería',
|
||||||
|
'Clutch/Parts': 'Embrague/Partes',
|
||||||
|
'Wheel Suspension': 'Suspensión de Rueda',
|
||||||
|
'Gaskets/Seals': 'Juntas/Sellos',
|
||||||
|
'Fuel Supply System': 'Sistema de Suministro de Combustible',
|
||||||
|
'Air Supply': 'Suministro de Aire',
|
||||||
|
'Comfort Systems': 'Sistemas de Confort',
|
||||||
|
'Communication Systems': 'Sistemas de Comunicación',
|
||||||
|
'Locking System': 'Sistema de Cierre',
|
||||||
|
'Windscreen Cleaning': 'Limpieza de Parabrisas',
|
||||||
|
'Universal Parts': 'Partes Universales',
|
||||||
|
'Oils/Fluids': 'Aceites/Líquidos',
|
||||||
|
'Tyres': 'Neumáticos/Llantas',
|
||||||
|
'Wheels': 'Rines',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1019,6 +1019,75 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PLATE LOOKUP ───
|
||||||
|
var plateInputWrap = document.getElementById('plateInputWrap');
|
||||||
|
var plateInput = document.getElementById('plateInput');
|
||||||
|
var plateStatus = document.getElementById('plateStatus');
|
||||||
|
var plateToggle = document.getElementById('plateToggle');
|
||||||
|
|
||||||
|
function togglePlate() {
|
||||||
|
var isVisible = plateInputWrap.style.display !== 'none';
|
||||||
|
plateInputWrap.style.display = isVisible ? 'none' : '';
|
||||||
|
plateToggle.textContent = isVisible ? 'Tienes las placas?' : 'Ocultar placas';
|
||||||
|
if (!isVisible && plateInput) plateInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPlateStatus(msg, isError) {
|
||||||
|
plateStatus.style.display = msg ? '' : 'none';
|
||||||
|
plateStatus.textContent = msg;
|
||||||
|
plateStatus.style.color = isError ? 'var(--color-error)' : 'var(--color-text-muted)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupPlate() {
|
||||||
|
var plate = (plateInput.value || '').trim().toUpperCase();
|
||||||
|
if (!plate || plate.length < 5) {
|
||||||
|
showPlateStatus('Ingresa una placa valida (Ej: ABC-1234).', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showPlateStatus('Buscando placa...', false);
|
||||||
|
|
||||||
|
apiFetch(API + '/plate/' + encodeURIComponent(plate)).then(function (data) {
|
||||||
|
if (!data) {
|
||||||
|
showPlateStatus('Error de conexion al buscar placa.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.error) {
|
||||||
|
showPlateStatus(data.error, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.found) {
|
||||||
|
plateStatus.style.display = '';
|
||||||
|
plateStatus.innerHTML = 'Placa no registrada. <a href="/pos/customers" style="color:var(--color-primary);">Registrar vehiculo</a>';
|
||||||
|
plateStatus.style.color = 'var(--color-warning, #e6a700)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = [];
|
||||||
|
if (data.year) parts.push(data.year);
|
||||||
|
if (data.make) parts.push(data.make);
|
||||||
|
if (data.model) parts.push(data.model);
|
||||||
|
var label = parts.join(' ') || 'Vehiculo encontrado';
|
||||||
|
|
||||||
|
// If we got a catalog match, auto-fill the dropdowns
|
||||||
|
var match = data.catalog_match;
|
||||||
|
if (match && match.brand_id) {
|
||||||
|
showPlateStatus(label + ' — Cargando catalogo...', false);
|
||||||
|
_autoFillFromVin(match, data);
|
||||||
|
} else {
|
||||||
|
showPlateStatus(label + ' — No encontrado en el catalogo TecDoc.', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plateInput) {
|
||||||
|
plateInput.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
lookupPlate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── VIN DECODER ───
|
// ─── VIN DECODER ───
|
||||||
var vinInputWrap = document.getElementById('vinInputWrap');
|
var vinInputWrap = document.getElementById('vinInputWrap');
|
||||||
var vinInput = document.getElementById('vinInput');
|
var vinInput = document.getElementById('vinInput');
|
||||||
@@ -1160,6 +1229,8 @@
|
|||||||
startBarcodeScan: startBarcodeScan,
|
startBarcodeScan: startBarcodeScan,
|
||||||
toggleVin: toggleVin,
|
toggleVin: toggleVin,
|
||||||
decodeVin: decodeVin,
|
decodeVin: decodeVin,
|
||||||
|
togglePlate: togglePlate,
|
||||||
|
lookupPlate: lookupPlate,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── INIT ───
|
// ─── INIT ───
|
||||||
|
|||||||
@@ -635,6 +635,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="vs-clear" id="vsClear" onclick="CatalogApp.vsClear()" title="Limpiar seleccion" style="display:none;">✕</button>
|
<button class="vs-clear" id="vsClear" onclick="CatalogApp.vsClear()" title="Limpiar seleccion" style="display:none;">✕</button>
|
||||||
<span class="vs-vin-divider" style="color:var(--color-text-disabled);padding-bottom:6px;flex-shrink:0;">|</span>
|
<span class="vs-vin-divider" style="color:var(--color-text-disabled);padding-bottom:6px;flex-shrink:0;">|</span>
|
||||||
|
<div class="vs-group" id="plateGroup" style="position:relative;">
|
||||||
|
<a class="vs-label" id="plateToggle" href="#" onclick="event.preventDefault();CatalogApp.togglePlate();" style="color:var(--color-primary);cursor:pointer;text-decoration:underline;white-space:nowrap;">Tienes las placas?</a>
|
||||||
|
<div id="plateInputWrap" style="display:none;">
|
||||||
|
<div style="display:flex;gap:4px;">
|
||||||
|
<input type="text" class="vs-select" id="plateInput" placeholder="Ej: ABC-1234" maxlength="12" style="text-transform:uppercase;font-family:var(--font-mono,monospace);letter-spacing:0.05em;flex:1;" />
|
||||||
|
<button class="btn btn-primary" id="plateLookupBtn" onclick="CatalogApp.lookupPlate()" style="height:auto;padding:var(--space-2) var(--space-3);font-size:var(--text-body-sm);">Buscar</button>
|
||||||
|
</div>
|
||||||
|
<div id="plateStatus" style="font-size:var(--text-caption);margin-top:4px;color:var(--color-text-muted);display:none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="vs-vin-divider" style="color:var(--color-text-disabled);padding-bottom:6px;flex-shrink:0;">|</span>
|
||||||
<div class="vs-group" id="vinGroup" style="position:relative;">
|
<div class="vs-group" id="vinGroup" style="position:relative;">
|
||||||
<a class="vs-label" id="vinToggle" href="#" onclick="event.preventDefault();CatalogApp.toggleVin();" style="color:var(--color-primary);cursor:pointer;text-decoration:underline;white-space:nowrap;">Tienes el VIN?</a>
|
<a class="vs-label" id="vinToggle" href="#" onclick="event.preventDefault();CatalogApp.toggleVin();" style="color:var(--color-primary);cursor:pointer;text-decoration:underline;white-space:nowrap;">Tienes el VIN?</a>
|
||||||
<div id="vinInputWrap" style="display:none;">
|
<div id="vinInputWrap" style="display:none;">
|
||||||
@@ -738,6 +749,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/pos/static/js/i18n.js"></script>
|
<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/app-init.js"></script>
|
||||||
<script src="/pos/static/js/sidebar.js"></script>
|
<script src="/pos/static/js/sidebar.js"></script>
|
||||||
<script src="/pos/static/js/catalog.js"></script>
|
<script src="/pos/static/js/catalog.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user