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]>
This commit is contained in:
2026-06-10 02:21:18 -06:00
co-authored by Claude Fable 5
parent c3f4449296
commit 69c74faabb
43 changed files with 3536 additions and 1139 deletions
+343 -145
View File
@@ -1,26 +1,27 @@
"""Excel Generator for Cotizador E3 — port of export/excel/route.ts using openpyxl."""
"""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 get_column_letter
from openpyxl.utils import column_index_from_string, get_column_letter
from openpyxl.worksheet.worksheet import Worksheet
from app.services.calculators import FASES_SHORT, bucefalo_precio
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 _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:
@@ -34,15 +35,58 @@ def _thin_border(color: str = "FFD1D5DB") -> Border:
def _sanitize_sheet_name(name: str) -> str:
name = name[:31]
return re.sub(r"[/\\*?\[\]:]", "", name)
name = (name or "Servicio")[:31]
return re.sub(r"[/\\*?\[\]:]", "", name) or "Servicio"
def _set_col_widths(ws, widths: list[tuple[str, float]]):
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.
@@ -66,6 +110,7 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
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")
@@ -74,6 +119,7 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
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", "")
@@ -85,24 +131,26 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
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"
empresa = cliente_empresa or cliente_nombre
wb = Workbook()
wb.properties.creator = "Cotizador E3"
# ── HOJA RESUMEN ──────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════
# 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),
])
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()))
# Header banner
# Banner
ws.merge_cells("B2:K2")
cell = ws["B2"]
cell.value = razon_social
@@ -110,6 +158,7 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
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"]
@@ -122,217 +171,366 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte
# Info section
info_start = 5
info_rows = [
[("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "\u2014")],
[("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", fecha), ("E", "Esquema de Pago:"), ("F", esquema)],
[("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
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
(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)
# 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
# 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
cols = ["B", "C", "D", "K"]
headers = ["Fase", "Tipo de Pago", "Servicio", "Precio"]
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}")
for i, col_letter in enumerate(cols):
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 = headers[i]
cell.value = txt
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)
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
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)
# 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 = 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)
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
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
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
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
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:
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
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}")
ws[f"B{row}"].value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles."
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)
# ── DETAIL SHEETS ──────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════
# 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:
safe_name = _sanitize_sheet_name(serv.get("nombre", "Servicio"))
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, [
("A", 3), ("B", 35), ("C", 5), ("D", 15), ("E", 5),
("F", 35), ("G", 5), ("H", 15), ("I", 5), ("J", 18),
])
_set_col_widths(dw, list(detail_widths.items()))
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', '')}"
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")
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 = 30
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
# 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"
_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
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)
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
for col_letter in ["B", "I"]:
dw[f"{col_letter}{r}"].fill = primary_light_fill
dw[f"{col_letter}{r}"].border = _thin_border(PRIMARY)
# 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")
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 = Font(italic=False, size=9, name="Calibri", color=MUTED)
dw[f"B{r}"].font = iva_font
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."
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)