This commit is contained in:
2026-06-10 02:04:37 -06:00
parent 1ba3d8dea4
commit 31cdce03a0
131 changed files with 15922 additions and 106 deletions
View File
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from datetime import date, datetime
IVA_RATE = 0.16
FASES: dict[int, str] = {
0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura",
2: "FASE 2 - Publicidad y Manejo",
3: "FASE 3 - Contenido y SEO",
}
FASES_SHORT: dict[int, str] = {
0: "FASE 0 - Auditoria",
1: "FASE 1 - Setup e Infraestructura",
2: "FASE 2 - Publicidad y Manejo",
3: "FASE 3 - Contenido y SEO",
}
PLANES_BUCEFALO = [
{"nivel": "basico", "label": "Basico", "precio": 1000},
{"nivel": "estandar", "label": "Estandar", "precio": 3500},
{"nivel": "premium", "label": "Premium", "precio": 4500},
{"nivel": "empresarial", "label": "Empresarial", "precio": 7500},
]
ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"]
FINANCIAMIENTO_PLANES = [
{"meses": 3, "tasa": 0.077, "comision": 2.5, "montoMinimo": 300},
{"meses": 6, "tasa": 0.107, "comision": 2.5, "montoMinimo": 600},
{"meses": 9, "tasa": 0.137, "comision": 2.5, "montoMinimo": 900},
{"meses": 12, "tasa": 0.167, "comision": 2.5, "montoMinimo": 1200},
]
BONOS = [
{"id": "bono-1", "numero": 1, "titulo": "Servicio Centinela Web", "descripcion": "Monitoreo web 30 min/mes", "activo": True},
{"id": "bono-2", "numero": 2, "titulo": "Workshop Buyer Persona", "descripcion": "Taller de definición de buyer persona", "activo": True},
{"id": "bono-3", "numero": 3, "titulo": "Workshop Propuesta de Valor", "descripcion": "Taller de propuesta de valor", "activo": True},
{"id": "bono-4", "numero": 4, "titulo": "Membresia Premium", "descripcion": "Membresía premium por 1 año", "activo": True},
{"id": "bono-5", "numero": 5, "titulo": "Mes Gratis Bucefalo CRM", "descripcion": "Un mes gratis del CRM Bucefalo", "activo": True},
{"id": "bono-6", "numero": 6, "titulo": "Script de Ventas", "descripcion": "Script de ventas con 100+ complementos", "activo": True},
]
def bucefalo_precio(nivel: str) -> float:
for p in PLANES_BUCEFALO:
if p["nivel"] == nivel:
return float(p["precio"])
return 0.0
def calcular_vigencia(fecha: datetime | date) -> datetime:
if isinstance(fecha, datetime):
vigencia = fecha.replace()
else:
vigencia = datetime.combine(fecha, datetime.min.time())
dias_habiles = 0
while dias_habiles < 15:
vigencia = vigencia.replace(day=vigencia.day + 1) if vigencia.day < 28 else vigencia + __import__("datetime").timedelta(days=1)
vigencia = vigencia + __import__("datetime").timedelta(days=1)
dia = vigencia.weekday()
if dia < 5:
dias_habiles += 1
return vigencia
def calcular_financiamiento(monto: float, meses: int, tasa: float, comision: float, iva: float = IVA_RATE) -> dict:
comision_total = (monto * comision) / 100
monto_con_comision = monto + comision_total
pago_mensual = monto_con_comision * (1 + tasa) / meses
iva_mensual = pago_mensual * iva
total_mensual = pago_mensual + iva_mensual
return {
"pagoMensual": round(pago_mensual, 2),
"ivaMensual": round(iva_mensual, 2),
"totalMensual": round(total_mensual, 2),
"comisionTotal": round(comision_total, 2),
"granTotal": round(total_mensual * meses, 2),
}
def generar_numero_cotizacion(iniciales: str, secuencia: int) -> str:
now = datetime.now()
yy = str(now.year)[-2:]
mm = f"{now.month:02d}"
seq = f"{secuencia:03d}"
return f"UJ{yy}{mm}{iniciales}{seq}"
def sanitize_filename(name: str) -> str:
import re
name = re.sub(r"[^\w\s.\-]", "", name)
name = re.sub(r"\s+", " ", name)
return name.strip()
def format_currency(amount: float) -> str:
return f"${amount:,.2f}"
def format_date(d: datetime | date) -> str:
return f"{d.day:02d}/{d.month:02d}/{d.year}"
+339
View File
@@ -0,0 +1,339 @@
"""Excel Generator for Cotizador E3 — port of export/excel/route.ts using openpyxl."""
from __future__ import annotations
import io
import re
from datetime import datetime
from typing import Any
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from app.services.calculators import FASES_SHORT, bucefalo_precio
FASES_MAP = {0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3"}
def _hex_to_fill(hex_color: str) -> PatternFill:
h = hex_color.lstrip("#")
return PatternFill(start_color=f"FF{h.upper()}", end_color=f"FF{h.upper()}", fill_type="solid")
def _argb(hex_color: str, alpha: str = "FF") -> str:
h = hex_color.lstrip("#")
if len(h) > 6:
h = h[:6]
return f"{alpha}{h.upper()}"
def _thin_border(color: str = "FFD1D5DB") -> Border:
side = Side(style="thin", color=color)
return Border(top=side, left=side, bottom=side, right=side)
def _sanitize_sheet_name(name: str) -> str:
name = name[:31]
return re.sub(r"[/\\*?\[\]:]", "", name)
def _set_col_widths(ws, widths: list[tuple[str, float]]):
for col_letter, width in widths:
ws.column_dimensions[col_letter].width = width
def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> bytes:
"""Generate a multi-sheet Excel workbook for a quotation.
Args:
data: dict with quotation data (same as PDF generator)
saved: if True, uses real quotation number instead of "BORRADOR"
"""
config_color = data.get("colorPrimario", "#2563eb")
PRIMARY = _argb(config_color)
PRIMARY_LIGHT = _argb(config_color, alpha="18")
SECONDARY = _argb(data.get("colorSecundario", "#1e293b"))
WHITE = "FFFFFFFF"
LIGHT_BG = "FFF8FAFC"
MUTED = "FF64748B"
BORDER_COLOR = "FFE2E8F0"
header_font = Font(bold=True, size=10, name="Calibri", color=WHITE)
label_font = Font(bold=True, size=10, name="Calibri", color=MUTED)
value_font = Font(size=10, name="Calibri")
bold_font = Font(bold=True, size=10, name="Calibri")
total_font = Font(bold=True, size=11, name="Calibri")
small_font = Font(italic=True, size=9, name="Calibri", color=MUTED)
fase_font = Font(bold=True, size=11, name="Calibri", color=PRIMARY)
primary_fill = PatternFill(start_color=PRIMARY, end_color=PRIMARY, fill_type="solid")
primary_light_fill = PatternFill(start_color=PRIMARY_LIGHT, end_color=PRIMARY_LIGHT, fill_type="solid")
secondary_fill = PatternFill(start_color=SECONDARY, end_color=SECONDARY, fill_type="solid")
light_fill = PatternFill(start_color=LIGHT_BG, end_color=LIGHT_BG, fill_type="solid")
white_fill = PatternFill(start_color=WHITE, end_color=WHITE, fill_type="solid")
razon_social = data.get("razonSocial", "Cotizador E3")
cliente_nombre = data.get("clienteNombre", "")
cliente_empresa = data.get("clienteEmpresa", "")
asesor_nombre = data.get("asesorNombre", "")
fecha = data.get("fecha", datetime.now())
vigencia = data.get("vigencia", datetime.now())
moneda = data.get("moneda", "MXN")
tipo_cambio = data.get("tipoCambio", "NA")
proyecto = data.get("proyecto", "MKT Digital")
esquema = data.get("esquemaPago", "Pago Unico/Mensual")
servicios = data.get("servicios", [])
plan_nivel = data.get("planBucefaloNivel")
numero_label = data.get("numero", "BORRADOR") if saved else "BORRADOR"
empresa = cliente_empresa or cliente_nombre
wb = Workbook()
wb.properties.creator = "Cotizador E3"
# ── HOJA RESUMEN ──────────────────────────────
ws = wb.active
ws.title = "HOJA RESUMEN"
ws.sheet_view.showGridLines = False
_set_col_widths(ws, [
("A", 3), ("B", 22), ("C", 22), ("D", 16),
("E", 38), ("F", 5), ("G", 20), ("H", 5),
("I", 20), ("J", 5), ("K", 18),
])
# Header banner
ws.merge_cells("B2:K2")
cell = ws["B2"]
cell.value = razon_social
cell.font = Font(bold=True, size=16, name="Calibri", color=WHITE)
cell.alignment = Alignment(vertical="center")
for col in range(1, 12):
ws.cell(row=2, column=col).fill = primary_fill
ws.merge_cells("B3:K3")
cell = ws["B3"]
cell.value = numero_label
cell.font = Font(bold=True, size=11, name="Calibri", color=WHITE)
cell.alignment = Alignment(vertical="center")
for col in range(1, 12):
ws.cell(row=3, column=col).fill = secondary_fill
# Info section
info_start = 5
info_rows = [
[("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "\u2014")],
[("B", "Proyecto:"), ("C", proyecto), ("E", "Moneda:"), ("F", moneda)],
[("B", "Asesor:"), ("C", asesor_nombre), ("E", "Tipo de Cambio:"), ("F", tipo_cambio)],
[("B", "Fecha:"), ("C", fecha), ("E", "Esquema de Pago:"), ("F", esquema)],
]
for i, row_data in enumerate(info_rows):
r = info_start + i
for col_letter, label, is_value in [
(row_data[0][0], row_data[0][1], False),
(row_data[1][0], row_data[1][1], True),
(row_data[2][0], row_data[2][1], False),
(row_data[3][0], row_data[3][1], True),
]:
cell = ws[f"{col_letter}{r}"]
cell.value = label
cell.font = value_font if is_value else label_font
# Vigencia
vigencia_row = info_start + 4
ws[f"B{vigencia_row}"].value = "Vigencia:"
ws[f"B{vigencia_row}"].font = label_font
ws[f"C{vigencia_row}"].value = vigencia
ws[f"C{vigencia_row}"].number_format = "DD/MM/YYYY"
ws[f"C{vigencia_row}"].font = value_font
# Services table
table_start = info_start + 6
cols = ["B", "C", "D", "K"]
headers = ["Fase", "Tipo de Pago", "Servicio", "Precio"]
ws.merge_cells(f"D{table_start}:J{table_start}")
for i, col_letter in enumerate(cols):
cell = ws[f"{col_letter}{table_start}"]
cell.value = headers[i]
cell.font = header_font
cell.fill = primary_fill
cell.alignment = Alignment(
horizontal="right" if i == len(cols) - 1 else "left",
vertical="center",
)
cell.border = _thin_border(PRIMARY)
ws.row_dimensions[table_start].height = 24
servicios_unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
servicios_mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
row = table_start + 1
current_fase = -1
for serv in servicios_unicos + servicios_mensuales:
fase = serv.get("fase", 0)
if fase != current_fase:
current_fase = fase
ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = FASES_MAP.get(fase, f"FASE {fase}")
ws[f"B{row}"].font = fase_font
for col in range(2, 12):
ws.cell(row=row, column=col).fill = primary_light_fill
row += 1
row_bg = light_fill if row % 2 == 0 else white_fill
ws[f"B{row}"].value = ""
ws[f"C{row}"].value = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
ws[f"C{row}"].font = value_font
ws.merge_cells(f"D{row}:J{row}")
ws[f"D{row}"].value = serv.get("nombre", "")
ws[f"D{row}"].font = value_font
ws[f"K{row}"].value = serv.get("precio", 0)
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = value_font
ws[f"K{row}"].alignment = Alignment(horizontal="right")
for col_letter in cols:
ws[f"{col_letter}{row}"].fill = row_bg
ws[f"{col_letter}{row}"].border = _thin_border(BORDER_COLOR)
row += 1
row += 1
total_unico = sum(s.get("precio", 0) for s in servicios_unicos)
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "Total Pago Unico"
ws[f"B{row}"].font = total_font
ws[f"K{row}"].value = total_unico
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = total_font
ws[f"K{row}"].alignment = Alignment(horizontal="right")
for col_letter in cols:
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
ws[f"{col_letter}{row}"].fill = primary_light_fill
row += 1
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "(+ IVA)"
ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
row += 1
total_mensual = sum(s.get("precio", 0) for s in servicios_mensuales)
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "Total Pago Mensual"
ws[f"B{row}"].font = total_font
ws[f"K{row}"].value = total_mensual
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = total_font
ws[f"K{row}"].alignment = Alignment(horizontal="right")
for col_letter in cols:
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
ws[f"{col_letter}{row}"].fill = primary_light_fill
row += 1
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "(+ IVA)"
ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
row += 2
if plan_nivel:
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = f"CRM Bucefalo - {plan_nivel.capitalize()}"
ws[f"B{row}"].font = bold_font
ws[f"K{row}"].value = bucefalo_precio(plan_nivel)
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = bold_font
ws[f"K{row}"].alignment = Alignment(horizontal="right")
for col_letter in cols:
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
ws[f"{col_letter}{row}"].fill = primary_light_fill
row += 2
ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles."
ws[f"B{row}"].font = small_font
# ── DETAIL SHEETS ──────────────────────────────
for serv in servicios:
safe_name = _sanitize_sheet_name(serv.get("nombre", "Servicio"))
dw = wb.create_sheet(title=safe_name)
dw.sheet_view.showGridLines = False
_set_col_widths(dw, [
("A", 3), ("B", 35), ("C", 5), ("D", 15), ("E", 5),
("F", 35), ("G", 5), ("H", 15), ("I", 5), ("J", 18),
])
fase = serv.get("fase", 0)
dw.merge_cells("B1:J1")
dw["B1"].value = f"{FASES_MAP.get(fase, f'FASE {fase}')} \u2014 {serv.get('nombre', '')}"
dw["B1"].font = Font(bold=True, size=14, name="Calibri", color=WHITE)
dw["B1"].alignment = Alignment(vertical="center")
for col in range(1, 11):
dw.cell(row=1, column=col).fill = primary_fill
dw.row_dimensions[1].height = 30
r = 3
dw[f"B{r}"].value = "Cliente:"
dw[f"B{r}"].font = label_font
dw[f"C{r}"].value = cliente_empresa or cliente_nombre
dw[f"C{r}"].font = bold_font
dw[f"F{r}"].value = "No. Cotizacion:"
dw[f"F{r}"].font = label_font
dw[f"G{r}"].value = numero_label
dw[f"G{r}"].font = bold_font
r += 2
dw[f"B{r}"].value = "Servicio:"
dw[f"B{r}"].font = label_font
dw[f"C{r}"].value = serv.get("nombre", "")
dw[f"C{r}"].font = Font(bold=True, size=12, name="Calibri", color=PRIMARY)
dw[f"F{r}"].value = "Tiempo de Entrega:"
dw[f"F{r}"].font = label_font
dw[f"G{r}"].value = serv.get("tiempoEntrega", "")
dw[f"G{r}"].font = value_font
r += 2
# Entregables header
for col_letter in ["B", "I"]:
dw[f"{col_letter}{r}"].fill = primary_fill
dw[f"{col_letter}{r}"].font = header_font
dw[f"B{r}"].value = "Entregable"
r += 1
entregables = serv.get("entregables", [])
for i, ent in enumerate(entregables):
bg = light_fill if i % 2 == 1 else white_fill
dw[f"B{i + r}"].value = f"{i + 1}. {ent}"
dw[f"B{i + r}"].font = value_font
dw[f"B{i + r}"].fill = bg
dw[f"B{i + r}"].border = _thin_border(BORDER_COLOR)
r += len(entregables) + 1
# Total
for col_letter in ["B", "I"]:
dw[f"{col_letter}{r}"].fill = primary_light_fill
dw[f"{col_letter}{r}"].border = _thin_border(PRIMARY)
tipo_label = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
dw[f"B{r}"].value = f"Total Pago {tipo_label}"
dw[f"B{r}"].font = total_font
dw[f"I{r}"].value = serv.get("precio", 0)
dw[f"I{r}"].number_format = "$#,##0.00"
dw[f"I{r}"].font = total_font
dw[f"I{r}"].alignment = Alignment(horizontal="right")
r += 1
dw[f"B{r}"].value = "(+ IVA)"
dw[f"B{r}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
r += 2
dw.merge_cells(f"B{r}:J{r}")
dw[f"B{r}"].value = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA."
dw[f"B{r}"].font = small_font
r += 2
dw[f"B{r}"].value = razon_social
dw[f"B{r}"].font = Font(bold=True, size=10, name="Calibri", color=PRIMARY)
output = io.BytesIO()
wb.save(output)
return output.getvalue()
+437
View File
@@ -0,0 +1,437 @@
"""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()