1
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user