"""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()