FASE 4: - Redis cache de stock con fallback graceful - Multi-moneda (MXN/USD) con contabilidad en MXN - Proveedores y ordenes de compra completo - Meilisearch 1.5M+ partes indexadas - Metabase KPIs con dashboard auto-generado FASE 5: - CRM mejorado: activities, tags, loyalty program, analytics - Imagenes de partes: upload, resize, thumbnails WebP - Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered - Garantias/RMA, alertas de reorden, multi-sucursal - Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi) FASE 6: - Notificaciones automaticas: push/WhatsApp/email/in-app - Reportes de ahorro vs retail_price - Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber - API Publica: API keys, rate limiting, catalog search Migraciones: v1.9-v3.0 Tests: 93/93 pasando Backup: nexus_backup_20260427_045859.tar.gz
166 lines
5.1 KiB
Python
166 lines
5.1 KiB
Python
"""Image Service: part image upload, processing, and storage.
|
|
|
|
Stores images at:
|
|
/home/Autopartes/data/images/parts/<tenant_id>/<item_id>_full.webp
|
|
/home/Autopartes/data/images/parts/<tenant_id>/<item_id>_thumb.webp
|
|
|
|
Serves statically via Flask at /pos/static/images/parts/<tenant_id>/<filename>
|
|
"""
|
|
|
|
import os
|
|
import uuid
|
|
import requests
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
|
|
# Base directories
|
|
DATA_DIR = '/home/Autopartes/data/images/parts'
|
|
STATIC_DIR = '/home/Autopartes/pos/static/images/parts'
|
|
|
|
# Image processing settings
|
|
MAX_WIDTH = 1200
|
|
THUMB_SIZE = (300, 300)
|
|
FORMAT = 'WEBP'
|
|
QUALITY = 85
|
|
THUMB_QUALITY = 80
|
|
|
|
|
|
def _ensure_dir(path):
|
|
os.makedirs(path, exist_ok=True)
|
|
|
|
|
|
def _build_paths(tenant_id, item_id):
|
|
"""Return (data_dir, static_dir, basename) for an item."""
|
|
ddir = os.path.join(DATA_DIR, str(tenant_id))
|
|
sdir = os.path.join(STATIC_DIR, str(tenant_id))
|
|
basename = str(item_id)
|
|
return ddir, sdir, basename
|
|
|
|
|
|
def _process_image(img):
|
|
"""Resize and convert image to WebP. Returns (full_bytes, thumb_bytes)."""
|
|
# Convert to RGB if necessary (e.g. RGBA -> RGB for WEBP compatibility)
|
|
if img.mode in ('RGBA', 'P'):
|
|
img = img.convert('RGB')
|
|
|
|
# Resize original if too wide
|
|
w, h = img.size
|
|
if w > MAX_WIDTH:
|
|
ratio = MAX_WIDTH / w
|
|
new_h = int(h * ratio)
|
|
img = img.resize((MAX_WIDTH, new_h), Image.LANCZOS)
|
|
|
|
# Full image
|
|
full_buf = BytesIO()
|
|
img.save(full_buf, format=FORMAT, quality=QUALITY, optimize=True)
|
|
full_buf.seek(0)
|
|
|
|
# Thumbnail (crop to square from center)
|
|
thumb = img.copy()
|
|
tw, th = thumb.size
|
|
min_dim = min(tw, th)
|
|
left = (tw - min_dim) // 2
|
|
top = (th - min_dim) // 2
|
|
thumb = thumb.crop((left, top, left + min_dim, top + min_dim))
|
|
thumb = thumb.resize(THUMB_SIZE, Image.LANCZOS)
|
|
|
|
thumb_buf = BytesIO()
|
|
thumb.save(thumb_buf, format=FORMAT, quality=THUMB_QUALITY, optimize=True)
|
|
thumb_buf.seek(0)
|
|
|
|
return full_buf, thumb_buf
|
|
|
|
|
|
def save_image(tenant_id, item_id, file_obj=None, image_url=None,
|
|
filename_hint=None):
|
|
"""Save an image for an inventory item.
|
|
|
|
Args:
|
|
tenant_id: tenant ID for path isolation
|
|
item_id: inventory item ID
|
|
file_obj: file-like object (from Flask request.files)
|
|
image_url: URL to download image from
|
|
filename_hint: original filename for extension detection
|
|
|
|
Returns:
|
|
dict with 'image_url', 'thumb_url', 'size_full', 'size_thumb'
|
|
"""
|
|
if not file_obj and not image_url:
|
|
raise ValueError("Either file_obj or image_url is required")
|
|
|
|
# Load image
|
|
if file_obj:
|
|
img = Image.open(file_obj)
|
|
else:
|
|
resp = requests.get(image_url, timeout=30)
|
|
resp.raise_for_status()
|
|
img = Image.open(BytesIO(resp.content))
|
|
|
|
# Process
|
|
full_buf, thumb_buf = _process_image(img)
|
|
|
|
# Save to filesystem
|
|
ddir, sdir, basename = _build_paths(tenant_id, item_id)
|
|
_ensure_dir(ddir)
|
|
_ensure_dir(sdir)
|
|
|
|
full_path = os.path.join(ddir, f"{basename}_full.webp")
|
|
thumb_path = os.path.join(ddir, f"{basename}_thumb.webp")
|
|
|
|
# Also symlink/copy to static dir for Flask serving
|
|
static_full = os.path.join(sdir, f"{basename}_full.webp")
|
|
static_thumb = os.path.join(sdir, f"{basename}_thumb.webp")
|
|
|
|
with open(full_path, 'wb') as f:
|
|
f.write(full_buf.read())
|
|
with open(thumb_path, 'wb') as f:
|
|
f.write(thumb_buf.read())
|
|
|
|
# Copy to static dir
|
|
import shutil
|
|
shutil.copy2(full_path, static_full)
|
|
shutil.copy2(thumb_path, static_thumb)
|
|
|
|
# Build URLs
|
|
image_url = f"/pos/static/images/parts/{tenant_id}/{basename}_full.webp"
|
|
thumb_url = f"/pos/static/images/parts/{tenant_id}/{basename}_thumb.webp"
|
|
|
|
return {
|
|
'image_url': image_url,
|
|
'thumb_url': thumb_url,
|
|
'size_full': os.path.getsize(full_path),
|
|
'size_thumb': os.path.getsize(thumb_path),
|
|
}
|
|
|
|
|
|
def delete_image(tenant_id, item_id):
|
|
"""Delete all images for an inventory item."""
|
|
ddir, sdir, basename = _build_paths(tenant_id, item_id)
|
|
deleted = []
|
|
|
|
for directory in [ddir, sdir]:
|
|
for suffix in ['_full.webp', '_thumb.webp']:
|
|
path = os.path.join(directory, f"{basename}{suffix}")
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
deleted.append(path)
|
|
|
|
return {'deleted': deleted}
|
|
|
|
|
|
def get_image_info(tenant_id, item_id):
|
|
"""Get image info for an inventory item."""
|
|
ddir, sdir, basename = _build_paths(tenant_id, item_id)
|
|
full_path = os.path.join(ddir, f"{basename}_full.webp")
|
|
thumb_path = os.path.join(ddir, f"{basename}_thumb.webp")
|
|
|
|
info = {'has_image': False, 'image_url': None, 'thumb_url': None}
|
|
if os.path.exists(full_path):
|
|
info['has_image'] = True
|
|
info['image_url'] = f"/pos/static/images/parts/{tenant_id}/{basename}_full.webp"
|
|
info['thumb_url'] = f"/pos/static/images/parts/{tenant_id}/{basename}_thumb.webp"
|
|
info['size_full'] = os.path.getsize(full_path)
|
|
info['size_thumb'] = os.path.getsize(thumb_path)
|
|
info['updated_at'] = os.path.getmtime(full_path)
|
|
return info
|