FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica
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
This commit is contained in:
165
pos/services/image_service.py
Normal file
165
pos/services/image_service.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user