feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search - New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py - New marketplace_external.html/js with ML management UI - Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors - Inventory: new .btn--meli styles, select/label CSS fixes - WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog - DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue - Add Celery tasks for ML sync and webhook processing - Sidebar: MercadoLibre navigation link
This commit is contained in:
127
pos/services/quote_image.py
Normal file
127
pos/services/quote_image.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import io
|
||||
import base64
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
def generate_quote_image(quote_items, totals, tenant_name="Autopartes", logo_text="NEXUS"):
|
||||
"""
|
||||
Generate a visually appealing quote image.
|
||||
|
||||
quote_items: list of dicts with keys: name, sku, qty, price, total
|
||||
totals: dict with keys: subtotal, tax, total
|
||||
Returns: base64 encoded PNG string
|
||||
"""
|
||||
# Dimensions
|
||||
WIDTH = 800
|
||||
HEADER_H = 120
|
||||
FOOTER_H = 100
|
||||
ITEM_H = 60
|
||||
PADDING = 30
|
||||
|
||||
total_height = HEADER_H + len(quote_items) * ITEM_H + FOOTER_H + PADDING * 3
|
||||
|
||||
# Colors
|
||||
BG_COLOR = (250, 250, 252)
|
||||
PRIMARY = (0, 82, 155) # Dark blue
|
||||
ACCENT = (230, 57, 70) # Red accent
|
||||
TEXT_DARK = (30, 30, 30)
|
||||
TEXT_MED = (80, 80, 80)
|
||||
TEXT_LIGHT = (150, 150, 150)
|
||||
WHITE = (255, 255, 255)
|
||||
ROW_ALT = (245, 247, 250)
|
||||
|
||||
img = Image.new('RGB', (WIDTH, total_height), BG_COLOR)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to load fonts, fallback to default
|
||||
try:
|
||||
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
|
||||
font_sub = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)
|
||||
font_item = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||
font_bold = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22)
|
||||
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
|
||||
except Exception:
|
||||
font_title = ImageFont.load_default()
|
||||
font_sub = font_title
|
||||
font_item = font_title
|
||||
font_bold = font_title
|
||||
font_small = font_title
|
||||
|
||||
# --- Header ---
|
||||
draw.rectangle([0, 0, WIDTH, HEADER_H], fill=PRIMARY)
|
||||
|
||||
# Logo text
|
||||
draw.text((PADDING, 25), logo_text, font=font_title, fill=WHITE)
|
||||
draw.text((PADDING, 70), tenant_name, font=font_sub, fill=(200, 210, 230))
|
||||
|
||||
# Date and Quote label
|
||||
from datetime import datetime
|
||||
date_str = datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||
draw.text((WIDTH - PADDING - 200, 30), "COTIZACIÓN", font=font_title, fill=WHITE)
|
||||
draw.text((WIDTH - PADDING - 200, 75), date_str, font=font_sub, fill=(200, 210, 230))
|
||||
|
||||
# --- Items Header ---
|
||||
y = HEADER_H + PADDING
|
||||
draw.rectangle([PADDING, y, WIDTH - PADDING, y + ITEM_H], fill=(230, 235, 240))
|
||||
draw.text((PADDING + 10, y + 18), "PRODUCTO", font=font_bold, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 220, y + 18), "CANT.", font=font_bold, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 130, y + 18), "P.UNIT", font=font_bold, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 50, y + 18), "TOTAL", font=font_bold, fill=TEXT_DARK)
|
||||
y += ITEM_H
|
||||
|
||||
# --- Items ---
|
||||
for idx, item in enumerate(quote_items):
|
||||
row_y = y + idx * ITEM_H
|
||||
bg = ROW_ALT if idx % 2 == 0 else WHITE
|
||||
draw.rectangle([PADDING, row_y, WIDTH - PADDING, row_y + ITEM_H], fill=bg)
|
||||
|
||||
name = item.get('name', 'Producto')
|
||||
sku = item.get('sku', '')
|
||||
qty = str(item.get('qty', 1))
|
||||
price = f"${item.get('price', 0):,.2f}"
|
||||
total = f"${item.get('total', 0):,.2f}"
|
||||
|
||||
# Truncate name if too long
|
||||
name_display = name
|
||||
if len(name_display) > 35:
|
||||
name_display = name_display[:32] + "..."
|
||||
|
||||
draw.text((PADDING + 10, row_y + 8), name_display, font=font_item, fill=TEXT_DARK)
|
||||
draw.text((PADDING + 10, row_y + 32), f"SKU: {sku}", font=font_small, fill=TEXT_MED)
|
||||
|
||||
draw.text((WIDTH - PADDING - 220, row_y + 18), qty, font=font_item, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 130, row_y + 18), price, font=font_item, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 50, row_y + 18), total, font=font_item, fill=TEXT_DARK)
|
||||
|
||||
y += len(quote_items) * ITEM_H + PADDING
|
||||
|
||||
# --- Totals ---
|
||||
draw.line([(PADDING, y), (WIDTH - PADDING, y)], fill=(200, 200, 200), width=2)
|
||||
y += 20
|
||||
|
||||
subtotal = totals.get('subtotal', 0)
|
||||
tax = totals.get('tax', 0)
|
||||
total = totals.get('total', 0)
|
||||
|
||||
draw.text((WIDTH - PADDING - 300, y), "Subtotal:", font=font_sub, fill=TEXT_MED)
|
||||
draw.text((WIDTH - PADDING - 50, y), f"${subtotal:,.2f}", font=font_sub, fill=TEXT_DARK)
|
||||
y += 30
|
||||
|
||||
draw.text((WIDTH - PADDING - 300, y), "IVA (16%):", font=font_sub, fill=TEXT_MED)
|
||||
draw.text((WIDTH - PADDING - 50, y), f"${tax:,.2f}", font=font_sub, fill=TEXT_DARK)
|
||||
y += 35
|
||||
|
||||
draw.text((WIDTH - PADDING - 300, y), "TOTAL:", font=font_bold, fill=ACCENT)
|
||||
draw.text((WIDTH - PADDING - 50, y), f"${total:,.2f}", font=font_bold, fill=ACCENT)
|
||||
y += 50
|
||||
|
||||
# --- Footer ---
|
||||
draw.rectangle([0, total_height - FOOTER_H, WIDTH, total_height], fill=PRIMARY)
|
||||
footer_text = "Validez: 5 días hábiles | Envíos a todo México | Contacto: ventas@nexusautoparts.com"
|
||||
draw.text((PADDING, total_height - FOOTER_H + 35), footer_text, font=font_small, fill=(200, 210, 230))
|
||||
|
||||
# Convert to base64
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
return base64.b64encode(buffer.read()).decode('utf-8')
|
||||
|
||||
Reference in New Issue
Block a user