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:
+343
-145
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user