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:
+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(
|
||||
|
||||
Reference in New Issue
Block a user