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:
+2
-2
@@ -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
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user