Files
cotizador/api/app/services/pdf_generator.py
T
2026-06-10 02:04:37 -06:00

438 lines
17 KiB
Python

"""PDF Generator for Cotizador E3 — port of pdf-generator.ts using ReportLab."""
from __future__ import annotations
import base64
import io
from datetime import datetime
from typing import Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from app.services.calculators import FASES_SHORT, PLANES_BUCEFALO, bucefalo_precio
PRIMARY_DEFAULT = "#2563eb"
DARK_DEFAULT = "#1e293b"
MUTED = "#64748b"
BORDER = "#cbd5e1"
LIGHT_BG = "#f1f5f9"
WHITE = "#ffffff"
PAGE_W, PAGE_H = LETTER
MARGIN_TOP = 45
MARGIN_BOTTOM = 55
MARGIN_LEFT = 50
MARGIN_RIGHT = 50
CONTENT_W = PAGE_W - MARGIN_LEFT - MARGIN_RIGHT
def _hex_to_rgb(hex_color: str) -> colors.Color:
h = hex_color.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return colors.Color(r / 255, g / 255, b / 255)
def _fmt_currency(n: float) -> str:
return f"${n:,.2f}"
def _fmt_date(d: datetime) -> str:
months = [
"enero", "febrero", "marzo", "abril", "mayo", "junio",
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre",
]
return f"{d.day} de {months[d.month - 1]} de {d.year}"
def _load_logo(logo_base64: str | None, logo_mime: str | None) -> ImageReader | None:
if not logo_base64:
return None
try:
raw = base64.b64decode(logo_base64)
return io.BytesIO(raw)
except Exception:
return None
def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
"""Generate a professional quotation PDF.
Args:
data: dict with keys matching CotizacionPDFData interface:
numero, clienteNombre, clienteEmpresa, asesorNombre, fecha, vigencia,
moneda, tipoCambio, proyecto, esquemaPago, servicios[],
planBucefaloNivel, planBucefaloPrecio, incluirBonos,
configBancaria{}, colorPrimario, colorSecundario, logoBase64, logoMime
"""
PRIMARY = data.get("colorPrimario") or PRIMARY_DEFAULT
DARK = data.get("colorSecundario") or DARK_DEFAULT
primary_rgb = _hex_to_rgb(PRIMARY)
dark_rgb = _hex_to_rgb(DARK)
muted_rgb = _hex_to_rgb(MUTED)
border_rgb = _hex_to_rgb(BORDER)
light_rgb = _hex_to_rgb(LIGHT_BG)
white_rgb = _hex_to_rgb(WHITE)
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=LETTER)
c.setTitle(f"Cotizacion {data.get('numero', '')}")
logo = _load_logo(data.get("logoBase64"), data.get("logoMime"))
servicios = data.get("servicios", [])
plan_nivel = data.get("planBucefaloNivel")
plan_precio = data.get("planBucefaloPrecio", 0)
incluir_bonos = data.get("incluirBonos", False)
cfg_bancaria = data.get("configBancaria") or {}
# ── PAGE 1: COVER ──────────────────────────────
c.setFillColor(white_rgb)
c.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0)
y = PAGE_H - 80
if logo:
try:
c.drawImage(logo, MARGIN_LEFT, y - 20, width=65, height=65, preserveAspectRatio=True, mask="auto")
y -= 85
except Exception:
logo = None
if not logo:
c.setFont("Helvetica-Bold", 28)
c.setFillColor(primary_rgb)
c.drawString(MARGIN_LEFT, y, "UJ")
c.setFont("Helvetica", 9)
c.setFillColor(muted_rgb)
c.drawString(MARGIN_LEFT + 48, y - 15, "Uriel Jareth Consulting")
y -= 40
bar_y = y
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, bar_y, CONTENT_W, 4, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 36)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT, bar_y - 40, "Cotizacion")
c.setFont("Helvetica", 18)
c.setFillColor(primary_rgb)
c.drawString(MARGIN_LEFT, bar_y - 65, data.get("numero", ""))
iy = bar_y - 100
cL = MARGIN_LEFT
cR = MARGIN_LEFT + CONTENT_W * 0.52
info_l = [
("Cliente", data.get("clienteNombre", "")),
("Empresa", data.get("clienteEmpresa", "")),
("Proyecto", data.get("proyecto", "")),
("Asesor", data.get("asesorNombre", "")),
]
info_r = [
("Fecha", _fmt_date(data.get("fecha", datetime.now()))),
("Vigencia", _fmt_date(data.get("vigencia", datetime.now()))),
("Moneda", data.get("moneda", "MXN")),
("Esquema", data.get("esquemaPago", "")),
]
for i in range(max(len(info_l), len(info_r))):
if i < len(info_l) and info_l[i][1]:
c.setFont("Helvetica", 9)
c.setFillColor(muted_rgb)
c.drawString(cL, iy, info_l[i][0])
c.setFont("Helvetica", 11)
c.setFillColor(dark_rgb)
c.drawString(cL, iy - 14, info_l[i][1])
if i < len(info_r) and info_r[i][1]:
c.setFont("Helvetica", 9)
c.setFillColor(muted_rgb)
c.drawString(cR, iy, info_r[i][0])
c.setFont("Helvetica", 11)
c.setFillColor(dark_rgb)
c.drawString(cR, iy - 14, str(info_r[i][1]))
iy -= 32
# ── PAGE 2+: RESUMEN ──────────────────────────
c.showPage()
y = PAGE_H - MARGIN_TOP
if logo:
try:
c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto")
except Exception:
pass
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 15)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 12, y - 15, "Hoja Resumen")
y -= 28
cliente_ref = data.get("clienteEmpresa") or data.get("clienteNombre", "")
c.setFont("Helvetica", 8)
c.setFillColor(muted_rgb)
c.drawString(MARGIN_LEFT, y, f"En atencion a: {cliente_ref}")
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"No. Cotizacion: {data.get('numero', '')}")
y -= 11
c.drawString(MARGIN_LEFT, y, f"Asesor: {data.get('asesorNombre', '')}")
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"Fecha: {_fmt_date(data.get('fecha', datetime.now()))} | Moneda: {data.get('moneda', 'MXN')}")
y -= 18
col_nombre = MARGIN_LEFT
col_tipo = MARGIN_LEFT + CONTENT_W - 200
col_tiempo = MARGIN_LEFT + CONTENT_W - 120
col_precio = MARGIN_LEFT + CONTENT_W - 60
# Header row
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7)
c.setFillColor(dark_rgb)
c.drawString(col_nombre + 6, y + 3, "Servicio")
c.drawString(col_tipo + 4, y + 3, "Tipo")
c.drawString(col_tiempo + 4, y + 3, "Entrega")
c.drawRightString(col_precio + 60, y + 3, "Precio")
y -= 4
c.setStrokeColor(border_rgb)
c.setLineWidth(0.3)
c.line(MARGIN_LEFT, y, MARGIN_LEFT + CONTENT_W, y)
y -= 14
unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
def _draw_section(srv_list, tipo_label, titulo_total, y_pos):
if not srv_list:
return y_pos
current_fase = -1
max_y = PAGE_H - MARGIN_BOTTOM - 5
for serv in srv_list:
fase = serv.get("fase", 0)
if fase != current_fase:
current_fase = fase
if y_pos - 20 < max_y - PAGE_H:
c.showPage()
y_pos = PAGE_H - MARGIN_TOP
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y_pos - 14, CONTENT_W, 14, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7)
c.setFillColor(primary_rgb)
c.drawString(MARGIN_LEFT + 6, y_pos - 11, FASES_SHORT.get(fase, f"FASE {fase}"))
y_pos -= 18
c.setFont("Helvetica", 8)
c.setFillColor(dark_rgb)
nombre = serv.get("nombre", "")
c.drawString(col_nombre + 6, y_pos - 10, nombre[:60])
c.setFont("Helvetica", 7)
c.setFillColor(muted_rgb)
c.drawString(col_tipo + 4, y_pos - 10, tipo_label)
c.drawString(col_tiempo + 4, y_pos - 10, serv.get("tiempoEntrega", "")[:15])
c.setFont("Helvetica-Bold", 8)
c.setFillColor(dark_rgb)
c.drawRightString(col_precio + 60, y_pos - 10, _fmt_currency(serv.get("precio", 0)))
y_pos -= 14
entregables = serv.get("entregables", [])
if entregables:
half = (len(entregables) + 1) // 2
for idx in range(half):
e1 = entregables[idx]
e2 = entregables[idx + half] if idx + half < len(entregables) else None
c.setFont("Helvetica", 6)
c.setFillColor(muted_rgb)
c.drawString(col_nombre + 10, y_pos - 8, f"\u2022 {e1[:50]}")
if e2:
c.drawString(col_nombre + CONTENT_W * 0.48, y_pos - 8, f"\u2022 {e2[:50]}")
y_pos -= 9
c.setStrokeColor(_hex_to_rgb("#e5e7eb"))
c.setLineWidth(0.2)
c.line(col_nombre + 6, y_pos, MARGIN_LEFT + CONTENT_W, y_pos)
y_pos -= 6
total = sum(s.get("precio", 0) for s in srv_list)
y_pos -= 4
c.setFillColor(_hex_to_rgb("#f8fafc"))
c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 18, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 8.5)
c.setFillColor(primary_rgb)
c.drawString(col_nombre + 6, y_pos + 3, titulo_total)
c.drawRightString(col_precio + 60, y_pos + 3, _fmt_currency(total))
y_pos -= 18
c.setFont("Helvetica", 6)
c.setFillColor(muted_rgb)
c.drawString(col_nombre + 6, y_pos, "(Precios en Moneda Nacional, no incluyen IVA)")
y_pos -= 14
return y_pos
if unicos:
y = _draw_section(unicos, "Unico", "Total Pago Unico", y)
if mensuales:
y = _draw_section(mensuales, "Mensual", "Total Pago Mensual", y)
if plan_nivel:
label = plan_nivel.capitalize()
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 14, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7.5)
c.setFillColor(dark_rgb)
c.drawString(col_nombre + 6, y + 3, f"Plan Bucefalo CRM - {label}")
c.drawRightString(col_precio + 60, y + 3, _fmt_currency(plan_precio))
y -= 20
if incluir_bonos:
y -= 6
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y - 10, 3, 10, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 9)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 10, y - 8, "Bonos (Pago en una exhibicion)")
y -= 20
bonos = [
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
"Bono 2: Workshop Estrategico de Buyer Persona",
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
"Bono 4: 1 ano de Membresia Premium",
"Bono 5: Un mes gratis de Bucefalo CRM",
"Bono 6: Script de Ventas con mas de 100 complementos",
]
for b in bonos:
c.setFont("Helvetica", 7)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 8, y - 8, f"\u2713 {b}")
y -= 12
# ── T&C PAGE ──────────────────────────────────
c.showPage()
y = PAGE_H - MARGIN_TOP
if logo:
try:
c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto")
except Exception:
pass
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 15)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 12, y - 15, "Terminos y Condiciones")
y -= 30
def _section_title(title, yy):
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, yy - 9, 3, 9, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 9)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 10, yy - 8, title)
return yy - 20
def _draw_bullets(title, items, yy):
yy = _section_title(title, yy)
for t in items:
c.setFont("Helvetica", 7.5)
c.setFillColor(dark_rgb)
# Simple text wrapping
words = t.split()
line = ""
for word in words:
test = f"{line} {word}".strip()
if c.stringWidth(test, "Helvetica", 7.5) > CONTENT_W - 10:
c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}")
yy -= 11
line = word
else:
line = test
if line:
c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}")
yy -= 11
return yy - 8
y = _draw_bullets("Condiciones", [
"Esta cotizacion tiene una vigencia de 15 dias habiles.",
"Cualquier ajuste al proyecto despues de la aprobacion del contenido afectara la fecha de entrega y por consiguiente el costo.",
"El cliente debera proporcionar la informacion solicitada por Uriel Jareth Consulting en tiempo y forma.",
"Si la falta de informacion provoca un excedente en los plazos de entrega del proyecto, las horas adicionales de servicio se cotizaran por separado.",
"Los pagos correspondientes a los servicios mensuales deberan realizarse en los primeros 5 dias del mes.",
"Todo el material e informacion necesarios para la realizacion del sitio web deberan ser entregados en un plazo maximo de 40 dias naturales a partir del arranque del proyecto.",
], y)
y = _draw_bullets("Que no incluye el proyecto?", [
"Generacion de disenos, videos, traducciones, cambios de divisas y unidades, o cualquier servicio externo a lo cotizado.",
"Redaccion de entradas de Blog.",
"Integracion de Servicios de terceros ajenos a los cotizados.",
"Servicio de Recuperacion de Accesos de: Google Analytics, Google Tag Manager, Google Search Console, Google Ads, y Meta Ads.",
"Creacion de Redes Sociales (En caso de requerir el servicio incluira un costo adicional).",
], y)
y = _draw_bullets("Notas", [
"El presente proyecto debera tener un responsable oficial.",
"La Hora Centinela tiene un precio de $700.00 MXN.",
"Los archivos editables/fuente (AI, PSD) son propiedad intelectual de la agencia. Si requiere los archivos editables, estos pueden ser adquiridos abonando una tarifa de liberacion (buy-out fee).",
"Si el proyecto se pausa por razones ajenas a Uriel Jareth Consulting, esto generara costo extra del 15% al 30% para retomar el proyecto.",
], y)
y -= 8
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y - 9, 3, 9, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 9)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 10, y - 8, "Datos Bancarios")
y -= 20
razon_social = cfg_bancaria.get("razon_social", "URIEL JARETH ALVARADO ORTIZ")
rfc = cfg_bancaria.get("rfc", "AAOU970201SU7")
clabe_nac = cfg_bancaria.get("clabe_interbancaria", cfg_bancaria.get("cuenta_nacional", ""))
cuenta_nac = cfg_bancaria.get("cuenta_nacional", "")
banco_nac = "BBVA" if cuenta_nac else ""
box_h = 48
c.setFillColor(light_rgb)
c.setStrokeColor(border_rgb)
c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1)
c.setFont("Helvetica-Bold", 7)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Nacional")
c.setFont("Helvetica", 6.5)
if cuenta_nac:
c.drawString(MARGIN_LEFT + 8, y - 24, f"Cuenta: {cuenta_nac}")
if clabe_nac:
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 24, f"CLABE: {clabe_nac}")
c.drawString(MARGIN_LEFT + 8, y - 35, f"Razon Social: {razon_social}")
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, f"RFC: {rfc}")
if banco_nac:
c.drawString(MARGIN_LEFT + 8, y - 46, f"Banco: {banco_nac}")
y -= box_h + 8
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1)
c.setFont("Helvetica-Bold", 7)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Internacional")
c.setFont("Helvetica", 6.5)
c.drawString(MARGIN_LEFT + 8, y - 24, f"Beneficiario: {razon_social}")
c.drawString(MARGIN_LEFT + 8, y - 35, "Banco: BBVA Mexico")
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, "SWIFT: BCMRMXMMPYM")
# ── FOOTER ON ALL PAGES ──────────────────────
page_count = c.getPageNumber()
for i in range(1, page_count + 1):
c.setPageSize(LETTER)
c.setFont("Helvetica", 6)
c.setFillColor(muted_rgb)
c.setStrokeColor(border_rgb)
c.setLineWidth(0.5)
c.line(MARGIN_LEFT, MARGIN_BOTTOM - 10, MARGIN_LEFT + CONTENT_W, MARGIN_BOTTOM - 10)
c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 22, "Uriel Jareth Consulting")
c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 32, "urieljareth.com | [email protected] | (445) 182 9943")
c.save()
return buf.getvalue()