Files
urieljarethandClaude Fable 5 69c74faabb Restaurar funcionalidades desde stash + setup de despliegue Coolify
- Recupera el trabajo revertido (doble propuesta, retainer, niveles, API Python) desde el stash de GitHub Desktop
- Dockerfile multi-stage para Next.js (migraciones automáticas al arrancar, seed opcional via RUN_SEED)
- docker-compose.coolify.yml: postgres + web + api con healthchecks y SERVICE_FQDN_*
- Endpoint público /api/health (verifica BD) para healthchecks
- seed.ts parametrizado: conexión DB_* y credenciales SEED_* por entorno (sin passwords hardcodeadas)
- .env.example, .dockerignore, uvicorn con --proxy-headers
- Limpieza: .xlsx y __pycache__ fuera del repo

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-06-10 02:21:18 -06:00

538 lines
22 KiB
Python

"""Excel Generator for Cotizador E3 — port of src/lib/excel-builder.ts using openpyxl.
Mantiene paridad de layout con el builder de TypeScript: mismas columnas, merges,
celdas dinámicas (wrapText + altura calculada) para que el texto nunca se salga.
"""
from __future__ import annotations
import io
import math
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 column_index_from_string, get_column_letter
from openpyxl.worksheet.worksheet import Worksheet
from app.services.calculators import bucefalo_precio, detalle_modelo, calcular_totales_opcion, format_currency
FASES_MAP = {0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3"}
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 or "Servicio")[:31]
return re.sub(r"[/\\*?\[\]:]", "", name) or "Servicio"
def _set_col_widths(ws: Worksheet, widths: list[tuple[str, float]]):
for col_letter, width in widths:
ws.column_dimensions[col_letter].width = width
def _merged_width(widths: dict[str, float], start_col: str, end_col: str) -> float:
"""Suma de anchos (unidades de carácter de Excel) entre dos columnas, inclusive."""
start = column_index_from_string(start_col)
end = column_index_from_string(end_col)
return sum(widths.get(get_column_letter(c), 8) for c in range(start, end + 1))
def _estimate_row_height(text: str, width_chars: float, font_size: int = 10) -> float:
"""Altura (pt) que necesita una fila para mostrar `text` envuelto en `width_chars`."""
cpl = max(8, int(width_chars))
if font_size <= 10:
line_h = 14.0
elif font_size <= 11:
line_h = 15.5
elif font_size <= 12:
line_h = 17.0
else:
line_h = 20.0
lines = 0
for seg in str(text or "").split("\n"):
lines += max(1, math.ceil(len(seg) / cpl))
return max(16.0, round(lines * line_h + 4))
def _set_wrapped(
ws: Worksheet,
row: int,
col: str,
text: str,
width_chars: float,
font_size: int = 10,
horizontal: str = "left",
vertical: str = "top",
):
"""Marca la celda como envolvente y agranda la fila si el texto lo requiere."""
cell = ws[f"{col}{row}"]
cell.alignment = Alignment(wrap_text=True, vertical=vertical, horizontal=horizontal)
needed = _estimate_row_height(text, width_chars, font_size)
current = ws.row_dimensions[row].height or 0
if needed > current:
ws.row_dimensions[row].height = needed
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)
iva_font = Font(italic=False, size=9, name="Calibri", color=MUTED)
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")
domicilio_fiscal = data.get("domicilioFiscal")
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")
plan_precio = data.get("planBucefaloPrecio")
numero_label = data.get("numero", "BORRADOR") if saved else "BORRADOR"
wb = Workbook()
wb.properties.creator = "Cotizador E3"
# ═══════════════════════════════════════════════════════════════════════════
# HOJA RESUMEN
# ═══════════════════════════════════════════════════════════════════════════
ws = wb.active
ws.title = "HOJA RESUMEN"
ws.sheet_view.showGridLines = False
resumen_widths = {
"A": 3, "B": 18, "C": 26, "D": 13, "E": 16,
"F": 22, "G": 6, "H": 6, "I": 6, "J": 6, "K": 16,
}
_set_col_widths(ws, list(resumen_widths.items()))
# 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.row_dimensions[2].height = 28
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 "—")],
[("B", "Proyecto:"), ("C", proyecto), ("E", "Moneda:"), ("F", moneda)],
[("B", "Asesor:"), ("C", asesor_nombre), ("E", "Tipo de Cambio:"), ("F", tipo_cambio)],
[("B", "Fecha:"), ("C", ""), ("E", "Esquema de Pago:"), ("F", esquema)],
]
c_val_w = _merged_width(resumen_widths, "C", "C")
f_val_w = _merged_width(resumen_widths, "F", "F")
for i, row_data in enumerate(info_rows):
r = info_start + i
(lc1, lv1), (vc1, vv1), (lc2, lv2), (vc2, vv2) = row_data
ws[f"{lc1}{r}"].value = lv1
ws[f"{lc1}{r}"].font = label_font
ws[f"{vc1}{r}"].value = vv1
ws[f"{vc1}{r}"].font = value_font
if vv1:
_set_wrapped(ws, r, vc1, vv1, c_val_w)
ws[f"{lc2}{r}"].value = lv2
ws[f"{lc2}{r}"].font = label_font
ws[f"{vc2}{r}"].value = vv2
ws[f"{vc2}{r}"].font = value_font
if vv2:
_set_wrapped(ws, r, vc2, vv2, f_val_w)
# Fecha (en la fila "Fecha:")
fecha_cell = ws[f"C{info_start + 3}"]
fecha_cell.value = fecha
fecha_cell.number_format = "DD/MM/YYYY"
fecha_cell.font = value_font
vig_row = info_start + 4
ws[f"B{vig_row}"].value = "Vigencia:"
ws[f"B{vig_row}"].font = label_font
ws[f"C{vig_row}"].value = vigencia
ws[f"C{vig_row}"].number_format = "DD/MM/YYYY"
ws[f"C{vig_row}"].font = value_font
# Services table
table_start = info_start + 6
col_b = column_index_from_string("B")
col_k = column_index_from_string("K")
def style_table_row(row_num: int, fill: PatternFill, border_color: str):
"""Estiliza (relleno + borde) todo el ancho de la fila, de B a K."""
border = _thin_border(border_color)
for c in range(col_b, col_k + 1):
cell = ws.cell(row=row_num, column=c)
cell.fill = fill
cell.border = border
ws.merge_cells(f"D{table_start}:J{table_start}")
style_table_row(table_start, primary_fill, PRIMARY)
for col_letter, txt, align in [
("B", "Fase", "left"),
("C", "Tipo de Pago", "left"),
("D", "Servicio", "left"),
("K", "Precio", "right"),
]:
cell = ws[f"{col_letter}{table_start}"]
cell.value = txt
cell.font = header_font
cell.alignment = Alignment(horizontal=align, vertical="center")
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"]
serv_width = _merged_width(resumen_widths, "D", "J")
es_doble = bool(data.get("esDoble"))
opciones_meta = data.get("opcionesMetadata") or {}
row = table_start + 1
# Totales (helpers reutilizados por modo normal y doble propuesta)
def write_total_row(label: str, amount: float, font: Font):
nonlocal row
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = label
ws[f"B{row}"].font = font
ws[f"B{row}"].alignment = Alignment(vertical="center")
ws[f"K{row}"].value = amount
ws[f"K{row}"].number_format = "$#,##0.00"
ws[f"K{row}"].font = font
ws[f"K{row}"].alignment = Alignment(horizontal="right", vertical="center")
style_table_row(row, primary_light_fill, PRIMARY)
row += 1
def write_iva_note():
nonlocal row
ws.merge_cells(f"B{row}:J{row}")
ws[f"B{row}"].value = "(+ IVA)"
ws[f"B{row}"].font = iva_font
row += 1
def draw_service_rows(servs):
nonlocal row
current_fase = -1
for serv in servs:
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
ws[f"B{row}"].alignment = Alignment(vertical="center")
for col in range(2, 12):
ws.cell(row=row, column=col).fill = primary_light_fill
ws.row_dimensions[row].height = 20
row += 1
row_bg = light_fill if row % 2 == 0 else white_fill
ws.merge_cells(f"D{row}:J{row}")
style_table_row(row, row_bg, BORDER_COLOR)
ws[f"C{row}"].value = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
ws[f"C{row}"].font = value_font
ws[f"C{row}"].alignment = Alignment(vertical="top")
_detalle = detalle_modelo(serv)
serv_text = f"{serv.get('nombre', '')}{_detalle}" if _detalle else serv.get("nombre", "")
ws[f"D{row}"].value = serv_text
ws[f"D{row}"].font = value_font
_set_wrapped(ws, row, "D", serv_text, serv_width)
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", vertical="top")
row += 1
def write_opcion_banner(op: str):
nonlocal row
meta = opciones_meta.get(op) or {}
ws.merge_cells(f"B{row}:K{row}")
titulo = meta.get("titulo")
ws[f"B{row}"].value = f"OPCION {op}" + (f": {titulo}" if titulo else "")
ws[f"B{row}"].font = Font(bold=True, size=12, name="Calibri", color=WHITE)
ws[f"B{row}"].alignment = Alignment(vertical="center")
for col in range(2, 12):
ws.cell(row=row, column=col).fill = secondary_fill
ws.row_dimensions[row].height = 22
row += 1
desc = meta.get("descripcion")
if desc:
ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = desc
ws[f"B{row}"].font = value_font
_set_wrapped(ws, row, "B", desc, _merged_width(resumen_widths, "B", "K"))
row += 1
no_incluye = meta.get("noIncluye")
if no_incluye:
ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = f"No incluye: {no_incluye}"
ws[f"B{row}"].font = small_font
_set_wrapped(ws, row, "B", f"No incluye: {no_incluye}", _merged_width(resumen_widths, "B", "K"), font_size=9)
row += 1
if es_doble:
for op in ("1", "2"):
u = [s for s in servicios_unicos if s.get("opcion") in (op, "ambas")]
me = [s for s in servicios_mensuales if s.get("opcion") in (op, "ambas")]
write_opcion_banner(op)
draw_service_rows(u + me)
row += 1
write_total_row(f"Total Pago Unico - Opcion {op}", sum(s.get("precio", 0) for s in u), total_font)
write_iva_note()
write_total_row(f"Total Pago Mensual - Opcion {op}", sum(s.get("precio", 0) for s in me), total_font)
write_iva_note()
row += 1
# Comparativa de opciones (totales con IVA + horas)
t1 = calcular_totales_opcion(servicios, "1")
t2 = calcular_totales_opcion(servicios, "2")
ws.merge_cells(f"B{row}:K{row}")
ws[f"B{row}"].value = "COMPARATIVA DE OPCIONES"
ws[f"B{row}"].font = Font(bold=True, size=12, name="Calibri", color=WHITE)
ws[f"B{row}"].alignment = Alignment(vertical="center")
for col in range(2, 12):
ws.cell(row=row, column=col).fill = primary_fill
ws.row_dimensions[row].height = 22
row += 1
t1_tit = (opciones_meta.get("1") or {}).get("titulo")
t2_tit = (opciones_meta.get("2") or {}).get("titulo")
def comp_row(lab: str, v1: str, v2: str, font: Font):
nonlocal row
ws.merge_cells(f"B{row}:F{row}")
ws[f"B{row}"].value = lab
ws[f"B{row}"].font = font
ws.merge_cells(f"G{row}:H{row}")
ws[f"G{row}"].value = v1
ws[f"G{row}"].font = font
ws[f"G{row}"].alignment = Alignment(horizontal="right")
ws.merge_cells(f"I{row}:K{row}")
ws[f"I{row}"].value = v2
ws[f"I{row}"].font = font
ws[f"I{row}"].alignment = Alignment(horizontal="right")
border = _thin_border(BORDER_COLOR)
for col in range(col_b, col_k + 1):
ws.cell(row=row, column=col).border = border
row += 1
comp_row("Concepto", "Opcion 1" + (f" - {t1_tit}" if t1_tit else ""), "Opcion 2" + (f" - {t2_tit}" if t2_tit else ""), bold_font)
comp_row("Total unico (c/IVA)", format_currency(t1["totalUnico"] * 1.16), format_currency(t2["totalUnico"] * 1.16), value_font)
comp_row("Total mensual (c/IVA)", format_currency(t1["totalMensual"] * 1.16), format_currency(t2["totalMensual"] * 1.16), value_font)
comp_row("Horas estimadas", f"{t1['horas']:g} h", f"{t2['horas']:g} h", value_font)
row += 1
else:
draw_service_rows(servicios_unicos + servicios_mensuales)
row += 1
write_total_row("Total Pago Unico", sum(s.get("precio", 0) for s in servicios_unicos), total_font)
write_iva_note()
write_total_row("Total Pago Mensual", sum(s.get("precio", 0) for s in servicios_mensuales), total_font)
write_iva_note()
row += 1
if plan_nivel:
precio = plan_precio if plan_precio is not None else bucefalo_precio(plan_nivel)
write_total_row(f"CRM Bucefalo - {plan_nivel.capitalize()}", precio, bold_font)
row += 1
ws.merge_cells(f"B{row}:K{row}")
nota_resumen = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles."
ws[f"B{row}"].value = nota_resumen
ws[f"B{row}"].font = small_font
_set_wrapped(ws, row, "B", nota_resumen, _merged_width(resumen_widths, "B", "K"), font_size=9)
# ═══════════════════════════════════════════════════════════════════════════
# HOJAS DETALLADAS POR SERVICIO
# ═══════════════════════════════════════════════════════════════════════════
detail_widths = {
"A": 3, "B": 16, "C": 30, "D": 8, "E": 4,
"F": 18, "G": 22, "H": 6, "I": 6, "J": 16,
}
used_names: set[str] = set()
for serv in servicios:
base = _sanitize_sheet_name(serv.get("nombre", "Servicio"))
safe_name = base
n = 2
while safe_name.lower() in used_names:
suffix = f" ({n})"
safe_name = base[: 31 - len(suffix)] + suffix
n += 1
used_names.add(safe_name.lower())
dw = wb.create_sheet(title=safe_name)
dw.sheet_view.showGridLines = False
_set_col_widths(dw, list(detail_widths.items()))
fase = serv.get("fase", 0)
dw.merge_cells("B1:J1")
banner_text = f"{FASES_MAP.get(fase, f'FASE {fase}')}{serv.get('nombre', '')}"
dw["B1"].value = banner_text
dw["B1"].font = Font(bold=True, size=14, name="Calibri", color=WHITE)
dw["B1"].alignment = Alignment(vertical="center", wrap_text=True)
for col in range(1, 11):
dw.cell(row=1, column=col).fill = primary_fill
dw.row_dimensions[1].height = max(
30, _estimate_row_height(banner_text, _merged_width(detail_widths, "B", "J"), 14)
)
c_val = _merged_width(detail_widths, "C", "D")
g_val = _merged_width(detail_widths, "G", "H")
r = 3
dw[f"B{r}"].value = "Cliente:"
dw[f"B{r}"].font = label_font
dw.merge_cells(f"C{r}:D{r}")
dw[f"C{r}"].value = cliente_empresa or cliente_nombre
dw[f"C{r}"].font = bold_font
_set_wrapped(dw, r, "C", cliente_empresa or cliente_nombre, c_val)
dw[f"F{r}"].value = "No. Cotizacion:"
dw[f"F{r}"].font = label_font
dw.merge_cells(f"G{r}:H{r}")
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.merge_cells(f"C{r}:D{r}")
dw[f"C{r}"].value = serv.get("nombre", "")
dw[f"C{r}"].font = Font(bold=True, size=12, name="Calibri", color=PRIMARY)
_set_wrapped(dw, r, "C", serv.get("nombre", ""), c_val, font_size=12)
dw[f"F{r}"].value = "Tiempo de Entrega:"
dw[f"F{r}"].font = label_font
dw.merge_cells(f"G{r}:H{r}")
dw[f"G{r}"].value = serv.get("tiempoEntrega", "")
dw[f"G{r}"].font = value_font
_set_wrapped(dw, r, "G", serv.get("tiempoEntrega", ""), g_val)
r += 2
_detalle_serv = detalle_modelo(serv)
if _detalle_serv:
dw[f"B{r}"].value = "Detalle:"
dw[f"B{r}"].font = label_font
dw.merge_cells(f"C{r}:J{r}")
dw[f"C{r}"].value = _detalle_serv
dw[f"C{r}"].font = value_font
_set_wrapped(dw, r, "C", _detalle_serv, _merged_width(detail_widths, "C", "J"))
r += 2
# Encabezado de entregables — barra completa B:J
dw.merge_cells(f"B{r}:J{r}")
dw[f"B{r}"].value = "Entregables"
dw[f"B{r}"].font = header_font
dw[f"B{r}"].alignment = Alignment(vertical="center")
for col in range(column_index_from_string("B"), column_index_from_string("J") + 1):
dw.cell(row=r, column=col).fill = primary_fill
dw.row_dimensions[r].height = 22
r += 1
ent_width = _merged_width(detail_widths, "B", "J")
entregables = serv.get("entregables", [])
b_idx = column_index_from_string("B")
j_idx = column_index_from_string("J")
for i, ent in enumerate(entregables):
er = r + i
dw.merge_cells(f"B{er}:J{er}")
txt = f"{i + 1}. {ent}"
dw[f"B{er}"].value = txt
dw[f"B{er}"].font = value_font
_set_wrapped(dw, er, "B", txt, ent_width)
bg = light_fill if i % 2 == 1 else white_fill
for col in range(b_idx, j_idx + 1):
dw.cell(row=er, column=col).fill = bg
dw.cell(row=er, column=col).border = _thin_border(BORDER_COLOR)
r += len(entregables) + 1
# Total: etiqueta B:H + precio I:J
dw.merge_cells(f"B{r}:H{r}")
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"B{r}"].alignment = Alignment(vertical="center")
dw.merge_cells(f"I{r}:J{r}")
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", vertical="center")
for col in range(b_idx, j_idx + 1):
dw.cell(row=r, column=col).fill = primary_light_fill
dw.cell(row=r, column=col).border = _thin_border(PRIMARY)
r += 1
dw[f"B{r}"].value = "(+ IVA)"
dw[f"B{r}"].font = iva_font
r += 2
dw.merge_cells(f"B{r}:J{r}")
nota_detalle = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA."
dw[f"B{r}"].value = nota_detalle
dw[f"B{r}"].font = small_font
_set_wrapped(dw, r, "B", nota_detalle, ent_width, font_size=9)
r += 2
dw.merge_cells(f"B{r}:J{r}")
dw[f"B{r}"].value = razon_social
dw[f"B{r}"].font = Font(bold=True, size=10, name="Calibri", color=PRIMARY)
if domicilio_fiscal:
r += 1
dw.merge_cells(f"B{r}:J{r}")
dw[f"B{r}"].value = domicilio_fiscal
dw[f"B{r}"].font = iva_font
_set_wrapped(dw, r, "B", domicilio_fiscal, ent_width, font_size=9)
output = io.BytesIO()
wb.save(output)
return output.getvalue()