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
+2 -2
View File
@@ -15,8 +15,8 @@ async def init_db() -> None:
user=settings.DB_USER,
password=settings.DB_PASSWORD,
database=settings.DB_NAME,
min_size=2,
max_size=10,
min_size=5,
max_size=20,
)
+20 -13
View File
@@ -168,6 +168,8 @@ async def _crear_cotizacion(conn, args: dict) -> dict:
moneda = args.get("moneda", "MXN")
proyecto = args.get("proyecto", "MKT Digital")
esquema = args.get("esquema_pago", "Pago Unico/Mensual")
es_doble = bool(args.get("es_doble", False))
opciones_metadata = args.get("opciones_metadata") if es_doble else None
cliente = await conn.fetchrow(
'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2',
@@ -194,10 +196,13 @@ async def _crear_cotizacion(conn, args: dict) -> dict:
async with conn.transaction():
cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, '', $7, $8, NOW(), NOW())
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, $7, $8, '', $9, $10, NOW(), NOW())
RETURNING id, numero""",
numero, now, vigencia, moneda, proyecto, esquema, cliente_id, "agent",
numero, now, vigencia, moneda, proyecto, esquema,
es_doble,
json.dumps(opciones_metadata) if opciones_metadata else None,
cliente_id, "agent",
)
cot_id = cot["id"]
@@ -218,12 +223,14 @@ async def _crear_cotizacion(conn, args: dict) -> dict:
except (json.JSONDecodeError, TypeError):
entregables = []
opcion = (srv.get("opcion") or "ambas") if es_doble else None
await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, true, NOW(), NOW())""",
precio, "tiempoEntrega", entregables, opcion, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, true, NOW(), NOW())""",
cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"],
precio, cat_row["tiempoEntrega"], json.dumps(entregables or []),
precio, cat_row["tiempoEntrega"], json.dumps(entregables or []), opcion,
)
if plan_bucefalo:
@@ -363,13 +370,13 @@ async def _duplicar_cotizacion(conn, args: dict) -> dict:
async with conn.transaction():
new_cot = await conn.fetchrow(
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, NOW(), NOW())
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, $13, $14, NOW(), NOW())
RETURNING id, numero""",
new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"],
original["proyecto"], original["esquemaPago"], original["incluirBonos"],
original["incluirFinanciamiento"], original["observaciones"],
original["clienteId"], original["asesorId"],
original["incluirFinanciamiento"], original["esDoble"], original["opcionesMetadata"],
original["observaciones"], original["clienteId"], original["asesorId"],
)
new_id = new_cot["id"]
@@ -379,10 +386,10 @@ async def _duplicar_cotizacion(conn, args: dict) -> dict:
for s in servicios:
await conn.fetchrow(
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
precio, "tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())""",
precio, "tiempoEntrega", entregables, notas, opcion, seleccionado, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())""",
new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"],
s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["seleccionado"],
s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["opcion"], s["seleccionado"],
)
plan = await conn.fetchrow(
+17 -1
View File
@@ -45,7 +45,10 @@ TOOLS = [
"Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, "
"y configuración de moneda y esquema de pago. "
"La cotización se crea en estado 'borrador'. "
"Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes."
"Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes. "
"Soporta DOBLE PROPUESTA: con es_doble=true se presentan dos opciones comparables; cada servicio "
"se asigna a la opción '1', '2' o 'ambas' (compartido), y opciones_metadata define el título, "
"descripción y exclusiones de cada opción."
),
"inputSchema": {
"type": "object",
@@ -67,11 +70,24 @@ TOOLS = [
"properties": {
"servicio_id": {"type": "string", "description": "ID del servicio del catálogo (obtener con buscar_servicios)"},
"precio_personalizado": {"type": "number", "description": "Precio personalizado (opcional, usa precio base si no se especifica)"},
"opcion": {"type": "string", "enum": ["1", "2", "ambas"], "description": "Solo en doble propuesta: opción a la que pertenece el servicio ('ambas' = compartido). Default 'ambas'."},
},
"required": ["servicio_id"],
},
"description": "Lista de servicios a incluir en la cotización",
},
"es_doble": {
"type": "boolean",
"description": "Si es true, la cotización presenta dos opciones comparables (doble propuesta).",
},
"opciones_metadata": {
"type": "object",
"description": "Solo en doble propuesta. Metadatos por opción, p.ej. {\"1\": {\"titulo\": \"...\", \"descripcion\": \"...\", \"noIncluye\": \"...\"}, \"2\": {...}}.",
"properties": {
"1": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
"2": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}},
},
},
"plan_bucefalo": {
"type": "string",
"enum": ["basico", "estandar", "premium", "empresarial"],
+2
View File
@@ -43,6 +43,7 @@ class ServicioCatalogoBase(BaseModel):
entregablesDefault: list[str] = Field(default_factory=list, description="Lista de entregables")
categoriaId: str = Field(..., min_length=1, description="ID de la categoría")
variante: str | None = None
nivel: str | None = Field(None, description="Nivel/tier: Emprendedor, PYME, Empresario, Corporativo")
orden: int = Field(0, ge=0)
@@ -60,6 +61,7 @@ class ServicioCatalogoUpdate(BaseModel):
entregablesDefault: list[str] | None = None
categoriaId: str | None = None
variante: str | None = None
nivel: str | None = None
activo: bool | None = None
orden: int | None = Field(None, ge=0)
+22 -1
View File
@@ -7,13 +7,28 @@ from pydantic import BaseModel, Field
class ServicioCotizadoInput(BaseModel):
catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo")
catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo o 'custom-...' para partidas personalizadas")
nombre: str = Field(..., min_length=1, description="Nombre del servicio")
fase: int = Field(..., ge=0, le=3)
tipoPago: str = Field(..., pattern="^(unico|mensual)$")
precio: float = Field(..., ge=0, description="Precio (puede diferir del precioBase)")
tiempoEntrega: str
entregables: list[str] = Field(default_factory=list)
# Partidas por tiempo (sin catálogo). modeloCobro: "fijo" | "horas" | "retainer".
esPersonalizado: bool = False
horas: float | None = Field(None, ge=0)
tarifaHora: float | None = Field(None, ge=0)
modeloCobro: str | None = Field(None, pattern="^(fijo|horas|retainer)$")
montoMinimo: float | None = Field(None, ge=0)
horasIncluidas: float | None = Field(None, ge=0)
# Doble propuesta: "1" | "2" | "ambas". None en cotizaciones normales.
opcion: str | None = Field(None, pattern="^(1|2|ambas)$")
class MetaOpcionInput(BaseModel):
titulo: str | None = None
descripcion: str | None = None
noIncluye: str | None = None
class PlanBucefaloInput(BaseModel):
@@ -38,6 +53,8 @@ class CotizacionCreate(BaseModel):
esquemaPago: str = Field("Pago Unico/Mensual", pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
incluirBonos: bool = False
incluirFinanciamiento: bool = False
esDoble: bool = False
opciones: dict[str, MetaOpcionInput] | None = None
observaciones: str = ""
asesorId: str = Field(..., min_length=1)
cliente: ClienteInput
@@ -92,6 +109,8 @@ class CotizacionUpdate(BaseModel):
esquemaPago: str | None = Field(None, pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
incluirBonos: bool | None = None
incluirFinanciamiento: bool | None = None
esDoble: bool | None = None
opciones: dict[str, MetaOpcionInput] | None = None
observaciones: str | None = None
estado: str | None = Field(None, pattern="^(borrador|enviada|aprobada|rechazada)$")
cliente: ClienteInput | None = None
@@ -120,6 +139,8 @@ class CotizacionResponse(BaseModel):
estado: str
incluirBonos: bool
incluirFinanciamiento: bool
esDoble: bool = False
opcionesMetadata: dict[str, Any] | None = None
observaciones: str | None
clienteId: str
asesorId: str
+16
View File
@@ -10,6 +10,20 @@ class ServicioDraft(BaseModel):
precio: float
tiempoEntrega: str
entregables: list[str] = Field(default_factory=list)
esPersonalizado: bool = False
horas: float | None = None
tarifaHora: float | None = None
modeloCobro: str | None = None
montoMinimo: float | None = None
horasIncluidas: float | None = None
# Doble propuesta: "1" | "2" | "ambas".
opcion: str | None = None
class MetaOpcionDraft(BaseModel):
titulo: str | None = None
descripcion: str | None = None
noIncluye: str | None = None
class ExportDraft(BaseModel):
@@ -25,6 +39,8 @@ class ExportDraft(BaseModel):
servicios: list[ServicioDraft] = Field(default_factory=list)
planBucefaloNivel: str | None = None
observaciones: str = ""
esDoble: bool = False
opciones: dict[str, MetaOpcionDraft] | None = None
class FinanciamientoRequest(BaseModel):
+14 -11
View File
@@ -47,6 +47,7 @@ def _row_to_response(row, categoria_row=None) -> ServicioCatalogoResponse:
entregablesDefault=entregables,
categoriaId=row["categoriaId"],
variante=row["variante"],
nivel=row["nivel"],
activo=row["activo"],
orden=row["orden"],
createdAt=row["createdAt"],
@@ -99,16 +100,16 @@ async def list_catalogo(
*params,
)
result: list[ServicioCatalogoResponse] = []
for r in rows:
cat_row = None
if r["categoriaId"]:
cat_row = await db.fetchrow(
'SELECT * FROM "Categoria" WHERE id = $1', r["categoriaId"]
)
result.append(_row_to_response(r, cat_row))
# Una sola query para todas las categorías referenciadas (evita N+1).
cat_ids = list({r["categoriaId"] for r in rows if r["categoriaId"]})
cat_map: dict = {}
if cat_ids:
cat_rows = await db.fetch(
'SELECT * FROM "Categoria" WHERE id = ANY($1::text[])', cat_ids
)
cat_map = {c["id"]: c for c in cat_rows}
return result
return [_row_to_response(r, cat_map.get(r["categoriaId"])) for r in rows]
@router.post(
@@ -134,8 +135,8 @@ async def create_catalogo(
row = await db.fetchrow(
'INSERT INTO "ServicioCatalogo" '
'(nombre, descripcion, fase, "tipoPago", "precioBase", "tiempoEntrega", '
'"entregablesDefault", "categoriaId", variante, activo, orden) '
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10) RETURNING *",
'"entregablesDefault", "categoriaId", variante, nivel, activo, orden) '
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true, $11) RETURNING *",
body.nombre,
body.descripcion,
body.fase,
@@ -145,6 +146,7 @@ async def create_catalogo(
entregables_json,
body.categoriaId,
body.variante,
body.nivel,
body.orden,
)
@@ -218,6 +220,7 @@ async def update_catalogo(
"entregablesDefault": '"entregablesDefault"',
"categoriaId": '"categoriaId"',
"variante": "variante",
"nivel": "nivel",
"activo": "activo",
"orden": "orden",
}
+148 -69
View File
@@ -26,8 +26,66 @@ def _cuid() -> str:
return uuid.uuid4().hex[:25]
async def _insert_servicio_cotizado(conn, cotizacion_id: str, svc, bucefalo_servicio_id, now, es_doble: bool = False) -> None:
"""Inserta un ServicioCotizado desde un ServicioCotizadoInput, manejando
partidas personalizadas (por horas / retainer) en paridad con el backend Next.js."""
es_personalizado = bool(getattr(svc, "esPersonalizado", False)) or svc.catalogoId.startswith("custom-")
if es_personalizado:
catalogo_id = None
elif svc.catalogoId.startswith("bucefalo-") and bucefalo_servicio_id:
catalogo_id = bucefalo_servicio_id
else:
catalogo_id = svc.catalogoId
modelo_cobro = svc.modeloCobro or ("horas" if es_personalizado else "fijo")
# Doble propuesta: por defecto "ambas"; None en cotizaciones normales.
opcion = (svc.opcion or "ambas") if es_doble else None
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado",
horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas",
opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas,
seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$18)
""",
_cuid(),
cotizacion_id,
catalogo_id,
svc.nombre if es_personalizado else None,
es_personalizado,
svc.horas if es_personalizado else None,
svc.tarifaHora if es_personalizado else None,
modelo_cobro,
svc.montoMinimo if es_personalizado else None,
svc.horasIncluidas if es_personalizado else None,
opcion,
svc.fase,
svc.tipoPago,
svc.precio,
svc.tiempoEntrega,
json.dumps(svc.entregables),
None,
now,
)
def _parse_json(val):
"""asyncpg devuelve columnas jsonb como str; las parsea a dict/list."""
if isinstance(val, str):
try:
return json.loads(val)
except (ValueError, TypeError):
return None
return val
def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict:
d = dict(cot)
if "opcionesMetadata" in d:
d["opcionesMetadata"] = _parse_json(d["opcionesMetadata"])
d["cliente"] = dict(cliente) if cliente else None
d["asesor"] = dict(asesor) if asesor else None
d["servicios"] = [dict(s) for s in servicios] if servicios else []
@@ -35,6 +93,18 @@ def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=
return d
# LEFT JOIN: los servicios personalizados no tienen servicioCatalogoId y un JOIN
# interno los dejaría fuera de la respuesta. COALESCE conserva el nombre custom.
_SERVICIOS_SQL = """
SELECT sc.*, sc."servicioCatalogoId" as "catalogoId",
COALESCE(s.nombre, sc.nombre) AS nombre, s.descripcion, s."categoriaId"
FROM "ServicioCotizado" sc
LEFT JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = {}
ORDER BY sc.fase, sc.opcion, sc.id
"""
async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None:
cot = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
if not cot:
@@ -42,22 +112,46 @@ async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None:
cliente = await conn.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cot["clienteId"])
asesor = await conn.fetchrow('SELECT id, email, name, role FROM "User" WHERE id = $1', cot["asesorId"])
servicios = await conn.fetch(
"""
SELECT sc.*, sc."servicioCatalogoId" as "catalogoId",
s.nombre, s.descripcion, s."categoriaId"
FROM "ServicioCotizado" sc
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = $1
ORDER BY sc.fase, sc.id
""",
cot_id,
)
servicios = await conn.fetch(_SERVICIOS_SQL.format("$1"), cot_id)
plan = await conn.fetchrow('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id)
return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan)
async def _fetch_cotizaciones_full(conn, cot_rows) -> list[dict]:
"""Versión batch de _fetch_cotizacion_full: 4 queries en total para toda la
página en lugar de 4 por cotización (evita N+1 en el listado)."""
if not cot_rows:
return []
cot_ids = [r["id"] for r in cot_rows]
cliente_ids = list({r["clienteId"] for r in cot_rows})
asesor_ids = list({r["asesorId"] for r in cot_rows})
clientes = await conn.fetch('SELECT * FROM "Cliente" WHERE id = ANY($1::text[])', cliente_ids)
asesores = await conn.fetch('SELECT id, email, name, role FROM "User" WHERE id = ANY($1::text[])', asesor_ids)
servicios = await conn.fetch(_SERVICIOS_SQL.format("ANY($1::text[])"), cot_ids)
planes = await conn.fetch('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = ANY($1::text[])', cot_ids)
clientes_map = {c["id"]: c for c in clientes}
asesores_map = {a["id"]: a for a in asesores}
planes_map = {p["cotizacionId"]: p for p in planes}
servicios_map: dict[str, list] = {}
for s in servicios:
servicios_map.setdefault(s["cotizacionId"], []).append(s)
return [
_build_cotizacion_dict(
cot,
clientes_map.get(cot["clienteId"]),
asesores_map.get(cot["asesorId"]),
servicios_map.get(cot["id"], []),
planes_map.get(cot["id"]),
)
for cot in cot_rows
]
async def _resolve_bucefalo_servicio(conn) -> str | None:
row = await conn.fetchrow(
"""
@@ -168,11 +262,7 @@ async def list_cotizaciones(
pagination.offset,
)
cotizaciones = []
for r in rows:
full = await _fetch_cotizacion_full(pool, r["id"])
if full:
cotizaciones.append(full)
cotizaciones = await _fetch_cotizaciones_full(pool, rows)
pages = (total + pagination.limit - 1) // pagination.limit if total > 0 else 0
@@ -200,13 +290,16 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
cot_id = _cuid()
now = datetime.now(timezone.utc)
opciones_json = None
if body.esDoble and body.opciones:
opciones_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()})
await conn.execute(
"""
INSERT INTO "Cotizacion"
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones,
"clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14)
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata",
observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16)
""",
cot_id,
body.numero,
@@ -218,6 +311,8 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
body.esquemaPago,
body.incluirBonos,
body.incluirFinanciamiento,
body.esDoble,
opciones_json,
body.observaciones or None,
cliente_id,
body.asesorId,
@@ -227,28 +322,7 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
for svc in body.servicios:
catalogo_id = svc.catalogoId
if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id:
catalogo_id = bucefalo_servicio_id
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10)
""",
_cuid(),
cot_id,
catalogo_id,
svc.fase,
svc.tipoPago,
svc.precio,
svc.tiempoEntrega,
json.dumps(svc.entregables),
None,
now,
)
await _insert_servicio_cotizado(conn, cot_id, svc, bucefalo_servicio_id, now, body.esDoble)
if body.planBucefalo:
await conn.execute(
@@ -265,7 +339,9 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
)
row = await pool.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
return dict(row)
result = dict(row)
result["opcionesMetadata"] = _parse_json(result.get("opcionesMetadata"))
return result
# ---------------------------------------------------------------------------
@@ -325,9 +401,20 @@ async def update_cotizacion(
_add("esquemaPago", '"esquemaPago"', body.esquemaPago)
_add("incluirBonos", '"incluirBonos"', body.incluirBonos)
_add("incluirFinanciamiento", '"incluirFinanciamiento"', body.incluirFinanciamiento)
_add("esDoble", '"esDoble"', body.esDoble)
_add("observaciones", "observaciones", body.observaciones)
_add("estado", "estado", body.estado)
es_doble_final = body.esDoble if body.esDoble is not None else existing["esDoble"]
if body.esDoble is not None:
# opcionesMetadata se sincroniza con esDoble: dict (json) si doble, NULL si no.
meta_json = None
if es_doble_final and body.opciones:
meta_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()})
updates.append(f'"opcionesMetadata" = ${idx}')
params.append(meta_json)
idx += 1
if cliente_id != existing["clienteId"]:
updates.append(f'"clienteId" = ${idx}')
params.append(cliente_id)
@@ -348,27 +435,7 @@ async def update_cotizacion(
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
now = datetime.now(timezone.utc)
for svc in body.servicios:
catalogo_id = svc.catalogoId
if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id:
catalogo_id = bucefalo_servicio_id
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10)
""",
_cuid(),
cotizacion_id,
catalogo_id,
svc.fase,
svc.tipoPago,
svc.precio,
svc.tiempoEntrega,
json.dumps(svc.entregables),
None,
now,
)
await _insert_servicio_cotizado(conn, cotizacion_id, svc, bucefalo_servicio_id, now, es_doble_final)
if body.planBucefalo is not None:
existing_plan = await conn.fetchrow(
@@ -505,9 +572,9 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require
"""
INSERT INTO "Cotizacion"
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones,
"clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14)
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata",
observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16)
""",
new_id,
new_numero,
@@ -519,6 +586,8 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require
original["esquemaPago"],
original["incluirBonos"],
original["incluirFinanciamiento"],
original["esDoble"],
original["opcionesMetadata"],
original["observaciones"],
original["clienteId"],
original["asesorId"],
@@ -529,13 +598,23 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11)
(id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado",
horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas",
opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas,
seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19)
""",
_cuid(),
new_id,
svc["servicioCatalogoId"],
svc["nombre"],
svc["esPersonalizado"],
svc["horas"],
svc["tarifaHora"],
svc["modeloCobro"],
svc["montoMinimo"],
svc["horasIncluidas"],
svc["opcion"],
svc["fase"],
svc["tipoPago"],
svc["precio"],
+35 -4
View File
@@ -69,9 +69,20 @@ def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) ->
"precio": s.precio,
"tiempoEntrega": s.tiempoEntrega,
"entregables": s.entregables,
"esPersonalizado": s.esPersonalizado,
"horas": s.horas,
"tarifaHora": s.tarifaHora,
"modeloCobro": s.modeloCobro,
"montoMinimo": s.montoMinimo,
"horasIncluidas": s.horasIncluidas,
"opcion": s.opcion,
}
for s in draft.servicios
],
"esDoble": draft.esDoble,
"opcionesMetadata": (
{k: v.model_dump() for k, v in draft.opciones.items()} if draft.esDoble and draft.opciones else None
),
"planBucefaloNivel": draft.planBucefaloNivel,
"planBucefaloPrecio": bucefalo_precio(draft.planBucefaloNivel) if draft.planBucefaloNivel else 0,
"incluirBonos": draft.incluirBonos,
@@ -81,6 +92,7 @@ def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) ->
"logoMime": logo_mime,
"configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"),
"domicilioFiscal": branding.get("domicilio_fiscal"),
}
@@ -99,11 +111,13 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
servicios = await db.fetch(
"""SELECT sc.fase, sc."tipoPago", sc.precio, sc."tiempoEntrega", sc.entregables,
s.nombre
sc."esPersonalizado", sc.horas, sc."tarifaHora", sc."modeloCobro",
sc."montoMinimo", sc."horasIncluidas", sc.opcion,
COALESCE(s.nombre, sc.nombre) AS nombre
FROM "ServicioCotizado" sc
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
LEFT JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = $1 AND sc.seleccionado = true
ORDER BY sc.fase, sc.id""",
ORDER BY sc.fase, sc.opcion, sc.id""",
cotizacion_id,
)
@@ -133,14 +147,28 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
except (json.JSONDecodeError, TypeError):
ent = []
servicios_list.append({
"nombre": s["nombre"],
"nombre": s["nombre"] or "Servicio",
"fase": s["fase"],
"tipoPago": s["tipoPago"],
"precio": float(s["precio"]),
"tiempoEntrega": s["tiempoEntrega"],
"entregables": ent or [],
"esPersonalizado": s["esPersonalizado"],
"horas": float(s["horas"]) if s["horas"] is not None else None,
"tarifaHora": float(s["tarifaHora"]) if s["tarifaHora"] is not None else None,
"modeloCobro": s["modeloCobro"],
"montoMinimo": float(s["montoMinimo"]) if s["montoMinimo"] is not None else None,
"horasIncluidas": float(s["horasIncluidas"]) if s["horasIncluidas"] is not None else None,
"opcion": s["opcion"],
})
opciones_meta = cot["opcionesMetadata"]
if isinstance(opciones_meta, str):
try:
opciones_meta = json.loads(opciones_meta)
except (json.JSONDecodeError, TypeError):
opciones_meta = None
plan_nivel = plan["nivel"] if plan else None
return {
@@ -155,6 +183,8 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
"proyecto": cot["proyecto"],
"esquemaPago": cot["esquemaPago"],
"servicios": servicios_list,
"esDoble": cot["esDoble"],
"opcionesMetadata": opciones_meta,
"planBucefaloNivel": plan_nivel,
"planBucefaloPrecio": float(plan["precio"]) if plan else 0,
"incluirBonos": cot["incluirBonos"],
@@ -164,6 +194,7 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
"logoMime": logo_mime,
"configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"),
"domicilioFiscal": branding.get("domicilio_fiscal"),
}
+84 -72
View File
@@ -19,96 +19,111 @@ from app.models.common import ErrorResponse, OkResponse
router = APIRouter(prefix="/paquetes", tags=["Paquetes"])
async def _build_paquete_response(conn, paquete_row: dict) -> dict:
"""Build a PaqueteResponse dict with nested fases and servicios."""
paquete_id = paquete_row["id"]
async def _build_paquetes_responses(conn, paquete_rows: list[dict]) -> list[dict]:
"""Build PaqueteResponse dicts for several paquetes with 2 queries in total
(instead of 1 + N fases per paquete)."""
if not paquete_rows:
return []
paquete_ids = [p["id"] for p in paquete_rows]
fases_rows = await conn.fetch(
'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"'
' FROM "FasePaquete" WHERE "paqueteId" = $1 ORDER BY orden ASC',
paquete_id,
' FROM "FasePaquete" WHERE "paqueteId" = ANY($1::text[]) ORDER BY orden ASC',
paquete_ids,
)
fases: list[dict] = []
for fase in fases_rows:
fase_ids = [f["id"] for f in fases_rows]
servicios_rows = []
if fase_ids:
servicios_rows = await conn.fetch(
'SELECT sp.id, sp."servicioCatalogoId", sp."fasePaqueteId",'
' sp."createdAt", sp."updatedAt",'
' sc.id AS sc_id, sc.nombre AS sc_nombre, sc.descripcion AS sc_descripcion,'
' sc.fase AS sc_fase, sc."tipoPago" AS sc_tipoPago,'
' sc."precioBase" AS sc_precioBase, sc."tiempoEntrega" AS sc_tiempoEntrega,'
' sc."entregablesDefault" AS sc_entregablesDefault,'
' sc."categoriaId" AS sc_categoriaId, sc.variante AS sc_variante,'
' sc.activo AS sc_activo, sc.orden AS sc_orden,'
' sc."createdAt" AS sc_createdAt, sc."updatedAt" AS sc_updatedAt,'
' cat.id AS cat_id, cat.nombre AS cat_nombre, cat.descripcion AS cat_descripcion,'
' cat.color AS cat_color, cat.activo AS cat_activo, cat.orden AS cat_orden,'
' cat."createdAt" AS cat_createdAt, cat."updatedAt" AS cat_updatedAt'
' sc.id AS "sc_id", sc.nombre AS "sc_nombre", sc.descripcion AS "sc_descripcion",'
' sc.fase AS "sc_fase", sc."tipoPago" AS "sc_tipoPago",'
' sc."precioBase" AS "sc_precioBase", sc."tiempoEntrega" AS "sc_tiempoEntrega",'
' sc."entregablesDefault" AS "sc_entregablesDefault",'
' sc."categoriaId" AS "sc_categoriaId", sc.variante AS "sc_variante",'
' sc.activo AS "sc_activo", sc.orden AS "sc_orden",'
' sc."createdAt" AS "sc_createdAt", sc."updatedAt" AS "sc_updatedAt",'
' cat.id AS "cat_id", cat.nombre AS "cat_nombre", cat.descripcion AS "cat_descripcion",'
' cat.color AS "cat_color", cat.activo AS "cat_activo", cat.orden AS "cat_orden",'
' cat."createdAt" AS "cat_createdAt", cat."updatedAt" AS "cat_updatedAt"'
' FROM "ServicioPaquete" sp'
' JOIN "ServicioCatalogo" sc ON sc.id = sp."servicioCatalogoId"'
' LEFT JOIN "Categoria" cat ON cat.id = sc."categoriaId"'
' WHERE sp."fasePaqueteId" = $1',
fase["id"],
' WHERE sp."fasePaqueteId" = ANY($1::text[])',
fase_ids,
)
servicios: list[dict] = []
for s in servicios_rows:
cat = None
if s["cat_id"]:
cat = {
"id": s["cat_id"],
"nombre": s["cat_nombre"],
"descripcion": s["cat_descripcion"],
"color": s["cat_color"],
"activo": s["cat_activo"],
"orden": s["cat_orden"],
"createdAt": s["cat_createdAt"],
"updatedAt": s["cat_updatedAt"],
}
servicios.append({
"id": s["id"],
"servicioCatalogoId": s["servicioCatalogoId"],
"fasePaqueteId": s["fasePaqueteId"],
"createdAt": s["createdAt"],
"updatedAt": s["updatedAt"],
"servicio": {
"id": s["sc_id"],
"nombre": s["sc_nombre"],
"descripcion": s["sc_descripcion"],
"fase": s["sc_fase"],
"tipoPago": s["sc_tipoPago"],
"precioBase": s["sc_precioBase"],
"tiempoEntrega": s["sc_tiempoEntrega"],
"entregablesDefault": s["sc_entregablesDefault"],
"categoriaId": s["sc_categoriaId"],
"variante": s["sc_variante"],
"activo": s["sc_activo"],
"orden": s["sc_orden"],
"createdAt": s["sc_createdAt"],
"updatedAt": s["sc_updatedAt"],
"categoriaRel": cat,
},
})
servicios_por_fase: dict[str, list[dict]] = {}
for s in servicios_rows:
cat = None
if s["cat_id"]:
cat = {
"id": s["cat_id"],
"nombre": s["cat_nombre"],
"descripcion": s["cat_descripcion"],
"color": s["cat_color"],
"activo": s["cat_activo"],
"orden": s["cat_orden"],
"createdAt": s["cat_createdAt"],
"updatedAt": s["cat_updatedAt"],
}
servicios_por_fase.setdefault(s["fasePaqueteId"], []).append({
"id": s["id"],
"servicioCatalogoId": s["servicioCatalogoId"],
"fasePaqueteId": s["fasePaqueteId"],
"createdAt": s["createdAt"],
"updatedAt": s["updatedAt"],
"servicio": {
"id": s["sc_id"],
"nombre": s["sc_nombre"],
"descripcion": s["sc_descripcion"],
"fase": s["sc_fase"],
"tipoPago": s["sc_tipoPago"],
"precioBase": s["sc_precioBase"],
"tiempoEntrega": s["sc_tiempoEntrega"],
"entregablesDefault": s["sc_entregablesDefault"],
"categoriaId": s["sc_categoriaId"],
"variante": s["sc_variante"],
"activo": s["sc_activo"],
"orden": s["sc_orden"],
"createdAt": s["sc_createdAt"],
"updatedAt": s["sc_updatedAt"],
"categoriaRel": cat,
},
})
fases.append({
fases_por_paquete: dict[str, list[dict]] = {}
for fase in fases_rows:
fases_por_paquete.setdefault(fase["paqueteId"], []).append({
"id": fase["id"],
"paqueteId": fase["paqueteId"],
"nombre": fase["nombre"],
"orden": fase["orden"],
"createdAt": fase["createdAt"],
"updatedAt": fase["updatedAt"],
"servicios": servicios,
"servicios": servicios_por_fase.get(fase["id"], []),
})
return {
"id": paquete_row["id"],
"nombre": paquete_row["nombre"],
"descripcion": paquete_row["descripcion"],
"activo": paquete_row["activo"],
"createdAt": paquete_row["createdAt"],
"updatedAt": paquete_row["updatedAt"],
"fases": fases,
}
return [
{
"id": p["id"],
"nombre": p["nombre"],
"descripcion": p["descripcion"],
"activo": p["activo"],
"createdAt": p["createdAt"],
"updatedAt": p["updatedAt"],
"fases": fases_por_paquete.get(p["id"], []),
}
for p in paquete_rows
]
async def _build_paquete_response(conn, paquete_row: dict) -> dict:
"""Build a PaqueteResponse dict with nested fases and servicios."""
results = await _build_paquetes_responses(conn, [paquete_row])
return results[0]
@router.get("", response_model=list[PaqueteResponse])
@@ -120,10 +135,7 @@ async def list_paquetes(
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
' FROM "Paquete" WHERE activo = true ORDER BY "createdAt" ASC'
)
results: list[dict] = []
for row in rows:
results.append(await _build_paquete_response(conn, dict(row)))
return results
return await _build_paquetes_responses(conn, [dict(row) for row in rows])
@router.post(
+60
View File
@@ -5,6 +5,19 @@ from datetime import date, datetime
IVA_RATE = 0.16
# Tarifa por hora sugerida para partidas personalizadas (Hora Centinela).
TARIFA_HORA_DEFAULT = 700
# Modelos de cobro de una partida (espejo del frontend TS).
MODELOS_COBRO: dict[str, str] = {
"fijo": "Precio fijo",
"horas": "Por horas",
"retainer": "Retainer (minimo + adicionales)",
}
# Doble propuesta: cada partida va en la Opcion 1, la 2 o en ambas (espejo del TS).
OPCIONES = ("1", "2", "ambas")
FASES: dict[int, str] = {
0: "FASE 0 - Auditoria / Acompanamiento",
1: "FASE 1 - Setup e Infraestructura",
@@ -45,6 +58,53 @@ BONOS = [
]
def calcular_precio_horas(horas: float, tarifa_hora: float) -> float:
return round((horas or 0) * (tarifa_hora or 0), 2)
def describir_retainer(monto_minimo: float, horas_incluidas: float, tarifa_hora: float) -> str:
"""Texto descriptivo de un retainer (espejo de describirRetainer en TS)."""
partes = [f"{format_currency(monto_minimo or 0)}/mes"]
if horas_incluidas:
partes.append(f"incluye {horas_incluidas:g} hr")
if tarifa_hora:
partes.append(f"adicional {format_currency(tarifa_hora)}/hr (se factura aparte)")
return " · ".join(partes)
def detalle_modelo(serv: dict) -> str:
"""Sub-linea de desglose por modelo de cobro para PDF/Excel."""
modelo = serv.get("modeloCobro")
if modelo == "retainer":
return describir_retainer(
serv.get("montoMinimo") or 0,
serv.get("horasIncluidas") or 0,
serv.get("tarifaHora") or 0,
)
horas = serv.get("horas")
tarifa = serv.get("tarifaHora")
if (modelo == "horas" or serv.get("esPersonalizado")) and horas and tarifa:
return f"{horas:g} h x {format_currency(tarifa)}/hr"
return ""
def calcular_totales_opcion(servicios: list[dict], opcion: str) -> dict:
"""Totales de una opcion: suma sus partidas + las marcadas 'ambas'.
Espejo de calcularTotalesOpcion en TS. Respeta la regla de retainer: el total
usa `precio`, no `horas x tarifa`. Las horas son informativas.
"""
rel = [s for s in servicios if s.get("opcion") == "ambas" or s.get("opcion") == opcion]
total_unico = sum((s.get("precio") or 0) for s in rel if s.get("tipoPago") == "unico")
total_mensual = sum((s.get("precio") or 0) for s in rel if s.get("tipoPago") == "mensual")
horas = sum((s.get("horas") or 0) for s in rel)
return {
"totalUnico": round(total_unico, 2),
"totalMensual": round(total_mensual, 2),
"horas": round(horas, 2),
}
def bucefalo_precio(nivel: str) -> float:
for p in PLANES_BUCEFALO:
if p["nivel"] == nivel:
+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)
+134 -19
View File
@@ -13,7 +13,7 @@ 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
from app.services.calculators import FASES_SHORT, PLANES_BUCEFALO, bucefalo_precio, detalle_modelo, calcular_totales_opcion
PRIMARY_DEFAULT = "#2563eb"
DARK_DEFAULT = "#1e293b"
@@ -40,6 +40,23 @@ def _fmt_currency(n: float) -> str:
return f"${n:,.2f}"
def _wrap_text(c, text: str, font: str, size: float, max_width: float) -> list[str]:
"""Envuelve texto en lineas que caben en max_width (respeta saltos de linea)."""
lines: list[str] = []
for paragraph in str(text or "").split("\n"):
words = paragraph.split()
line = ""
for word in words:
test = f"{line} {word}".strip()
if c.stringWidth(test, font, size) > max_width and line:
lines.append(line)
line = word
else:
line = test
lines.append(line)
return lines
def _fmt_date(d: datetime) -> str:
months = [
"enero", "febrero", "marzo", "abril", "mayo", "junio",
@@ -187,21 +204,23 @@ def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
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
es_doble = bool(data.get("esDoble"))
opciones_meta = data.get("opcionesMetadata") or {}
c.setStrokeColor(border_rgb)
c.setLineWidth(0.3)
c.line(MARGIN_LEFT, y, MARGIN_LEFT + CONTENT_W, y)
y -= 14
def _draw_table_header(y_pos):
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7)
c.setFillColor(dark_rgb)
c.drawString(col_nombre + 6, y_pos + 3, "Servicio")
c.drawString(col_tipo + 4, y_pos + 3, "Tipo")
c.drawString(col_tiempo + 4, y_pos + 3, "Entrega")
c.drawRightString(col_precio + 60, y_pos + 3, "Precio")
y_pos -= 4
c.setStrokeColor(border_rgb)
c.setLineWidth(0.3)
c.line(MARGIN_LEFT, y_pos, MARGIN_LEFT + CONTENT_W, y_pos)
return y_pos - 14
unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
@@ -239,6 +258,13 @@ def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
c.drawRightString(col_precio + 60, y_pos - 10, _fmt_currency(serv.get("precio", 0)))
y_pos -= 14
detalle = detalle_modelo(serv)
if detalle:
c.setFont("Helvetica-Oblique", 6.5)
c.setFillColor(muted_rgb)
c.drawString(col_nombre + 10, y_pos - 7, detalle[:90])
y_pos -= 9
entregables = serv.get("entregables", [])
if entregables:
half = (len(entregables) + 1) // 2
@@ -272,10 +298,99 @@ def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
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)
def _draw_opcion_header(op, y_pos):
meta = opciones_meta.get(op) or {}
max_y = PAGE_H - MARGIN_BOTTOM - 5
if y_pos - 30 < max_y:
c.showPage()
y_pos = PAGE_H - MARGIN_TOP
titulo = meta.get("titulo")
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y_pos - 18, CONTENT_W, 18, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 10)
c.setFillColor(white_rgb)
c.drawString(MARGIN_LEFT + 8, y_pos - 13, f"OPCION {op}" + (f": {titulo}" if titulo else ""))
y_pos -= 24
desc = meta.get("descripcion")
if desc:
c.setFont("Helvetica", 7.5)
c.setFillColor(dark_rgb)
for line in _wrap_text(c, desc, "Helvetica", 7.5, CONTENT_W - 12):
c.drawString(MARGIN_LEFT + 6, y_pos - 8, line)
y_pos -= 10
y_pos -= 2
no_incluye = meta.get("noIncluye")
if no_incluye:
c.setFont("Helvetica-Oblique", 7)
c.setFillColor(muted_rgb)
for line in _wrap_text(c, f"No incluye: {no_incluye}", "Helvetica-Oblique", 7, CONTENT_W - 12):
c.drawString(MARGIN_LEFT + 6, y_pos - 8, line)
y_pos -= 10
y_pos -= 4
return y_pos
def _draw_comparativa(y_pos):
t1 = calcular_totales_opcion(servicios, "1")
t2 = calcular_totales_opcion(servicios, "2")
max_y = PAGE_H - MARGIN_BOTTOM - 5
if y_pos - 90 < max_y:
c.showPage()
y_pos = PAGE_H - MARGIN_TOP
y_pos -= 6
c.setFillColor(primary_rgb)
c.rect(MARGIN_LEFT, y_pos - 10, 3, 10, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 10)
c.setFillColor(dark_rgb)
c.drawString(MARGIN_LEFT + 10, y_pos - 8, "Comparativa de opciones")
y_pos -= 22
c_label = MARGIN_LEFT
c_op1 = MARGIN_LEFT + CONTENT_W * 0.45
c_op2 = MARGIN_LEFT + CONTENT_W * 0.72
t1_tit = (opciones_meta.get("1") or {}).get("titulo")
t2_tit = (opciones_meta.get("2") or {}).get("titulo")
c.setFillColor(light_rgb)
c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 16, fill=1, stroke=0)
c.setFont("Helvetica-Bold", 7.5)
c.setFillColor(dark_rgb)
c.drawString(c_label + 6, y_pos + 3, "Concepto")
c.drawString(c_op1, y_pos + 3, f"Opcion 1" + (f" - {t1_tit}" if t1_tit else ""))
c.drawString(c_op2, y_pos + 3, f"Opcion 2" + (f" - {t2_tit}" if t2_tit else ""))
y_pos -= 18
filas = [
("Total unico (c/IVA)", _fmt_currency(t1["totalUnico"] * 1.16), _fmt_currency(t2["totalUnico"] * 1.16)),
("Total mensual (c/IVA)", _fmt_currency(t1["totalMensual"] * 1.16), _fmt_currency(t2["totalMensual"] * 1.16)),
("Horas estimadas", f"{t1['horas']:g} h", f"{t2['horas']:g} h"),
]
for lab, v1, v2 in filas:
c.setFont("Helvetica", 8)
c.setFillColor(dark_rgb)
c.drawString(c_label + 6, y_pos - 8, lab)
c.setFont("Helvetica-Bold", 8)
c.drawString(c_op1, y_pos - 8, v1)
c.drawString(c_op2, y_pos - 8, v2)
c.setStrokeColor(_hex_to_rgb("#e5e7eb"))
c.setLineWidth(0.2)
c.line(c_label + 6, y_pos - 12, MARGIN_LEFT + CONTENT_W, y_pos - 12)
y_pos -= 14
return y_pos - 8
if es_doble:
for op in ("1", "2"):
y = _draw_opcion_header(op, y)
y = _draw_table_header(y)
u = [s for s in unicos if s.get("opcion") in (op, "ambas")]
me = [s for s in mensuales if s.get("opcion") in (op, "ambas")]
if u:
y = _draw_section(u, "Unico", f"Total Pago Unico - Opcion {op}", y)
if me:
y = _draw_section(me, "Mensual", f"Total Pago Mensual - Opcion {op}", y)
y = _draw_comparativa(y)
else:
y = _draw_table_header(y)
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()