feat(pos): add plate lookup (#8), 326 translations (#12), bulk image import (#11)

- 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:
2026-04-05 04:17:55 +00:00
parent 1bea31e83f
commit 4cc2c66208
8 changed files with 917 additions and 43 deletions

View File

@@ -18,6 +18,7 @@ from middleware import require_auth
from tenant_db import get_master_conn, get_tenant_conn
from services import catalog_service
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')
@@ -227,6 +228,113 @@ def decode_vin_route(vin):
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):
"""Try to find brand_id, model_id, year_id, mye_id from decoded VIN info."""
make = (vin_info.get('make') or '').upper().strip()

View File

@@ -19,6 +19,17 @@ from services.audit import log_action
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 ──────────────────────────────────
@inventory_bp.route('/items', methods=['GET'])
@@ -457,6 +468,80 @@ def delete_image(item_id):
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 ──────────────────────────
@inventory_bp.route('/purchase', methods=['POST'])