Restaurar funcionalidades desde stash + setup de despliegue Coolify

- Recupera el trabajo revertido (doble propuesta, retainer, niveles, API Python) desde el stash de GitHub Desktop
- Dockerfile multi-stage para Next.js (migraciones automáticas al arrancar, seed opcional via RUN_SEED)
- docker-compose.coolify.yml: postgres + web + api con healthchecks y SERVICE_FQDN_*
- Endpoint público /api/health (verifica BD) para healthchecks
- seed.ts parametrizado: conexión DB_* y credenciales SEED_* por entorno (sin passwords hardcodeadas)
- .env.example, .dockerignore, uvicorn con --proxy-headers
- Limpieza: .xlsx y __pycache__ fuera del repo

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-06-10 02:21:18 -06:00
co-authored by Claude Fable 5
parent c3f4449296
commit 69c74faabb
43 changed files with 3536 additions and 1139 deletions
+14 -11
View File
@@ -47,6 +47,7 @@ def _row_to_response(row, categoria_row=None) -> ServicioCatalogoResponse:
entregablesDefault=entregables,
categoriaId=row["categoriaId"],
variante=row["variante"],
nivel=row["nivel"],
activo=row["activo"],
orden=row["orden"],
createdAt=row["createdAt"],
@@ -99,16 +100,16 @@ async def list_catalogo(
*params,
)
result: list[ServicioCatalogoResponse] = []
for r in rows:
cat_row = None
if r["categoriaId"]:
cat_row = await db.fetchrow(
'SELECT * FROM "Categoria" WHERE id = $1', r["categoriaId"]
)
result.append(_row_to_response(r, cat_row))
# Una sola query para todas las categorías referenciadas (evita N+1).
cat_ids = list({r["categoriaId"] for r in rows if r["categoriaId"]})
cat_map: dict = {}
if cat_ids:
cat_rows = await db.fetch(
'SELECT * FROM "Categoria" WHERE id = ANY($1::text[])', cat_ids
)
cat_map = {c["id"]: c for c in cat_rows}
return result
return [_row_to_response(r, cat_map.get(r["categoriaId"])) for r in rows]
@router.post(
@@ -134,8 +135,8 @@ async def create_catalogo(
row = await db.fetchrow(
'INSERT INTO "ServicioCatalogo" '
'(nombre, descripcion, fase, "tipoPago", "precioBase", "tiempoEntrega", '
'"entregablesDefault", "categoriaId", variante, activo, orden) '
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, $10) RETURNING *",
'"entregablesDefault", "categoriaId", variante, nivel, activo, orden) '
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true, $11) RETURNING *",
body.nombre,
body.descripcion,
body.fase,
@@ -145,6 +146,7 @@ async def create_catalogo(
entregables_json,
body.categoriaId,
body.variante,
body.nivel,
body.orden,
)
@@ -218,6 +220,7 @@ async def update_catalogo(
"entregablesDefault": '"entregablesDefault"',
"categoriaId": '"categoriaId"',
"variante": "variante",
"nivel": "nivel",
"activo": "activo",
"orden": "orden",
}
+148 -69
View File
@@ -26,8 +26,66 @@ def _cuid() -> str:
return uuid.uuid4().hex[:25]
async def _insert_servicio_cotizado(conn, cotizacion_id: str, svc, bucefalo_servicio_id, now, es_doble: bool = False) -> None:
"""Inserta un ServicioCotizado desde un ServicioCotizadoInput, manejando
partidas personalizadas (por horas / retainer) en paridad con el backend Next.js."""
es_personalizado = bool(getattr(svc, "esPersonalizado", False)) or svc.catalogoId.startswith("custom-")
if es_personalizado:
catalogo_id = None
elif svc.catalogoId.startswith("bucefalo-") and bucefalo_servicio_id:
catalogo_id = bucefalo_servicio_id
else:
catalogo_id = svc.catalogoId
modelo_cobro = svc.modeloCobro or ("horas" if es_personalizado else "fijo")
# Doble propuesta: por defecto "ambas"; None en cotizaciones normales.
opcion = (svc.opcion or "ambas") if es_doble else None
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado",
horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas",
opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas,
seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$18)
""",
_cuid(),
cotizacion_id,
catalogo_id,
svc.nombre if es_personalizado else None,
es_personalizado,
svc.horas if es_personalizado else None,
svc.tarifaHora if es_personalizado else None,
modelo_cobro,
svc.montoMinimo if es_personalizado else None,
svc.horasIncluidas if es_personalizado else None,
opcion,
svc.fase,
svc.tipoPago,
svc.precio,
svc.tiempoEntrega,
json.dumps(svc.entregables),
None,
now,
)
def _parse_json(val):
"""asyncpg devuelve columnas jsonb como str; las parsea a dict/list."""
if isinstance(val, str):
try:
return json.loads(val)
except (ValueError, TypeError):
return None
return val
def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict:
d = dict(cot)
if "opcionesMetadata" in d:
d["opcionesMetadata"] = _parse_json(d["opcionesMetadata"])
d["cliente"] = dict(cliente) if cliente else None
d["asesor"] = dict(asesor) if asesor else None
d["servicios"] = [dict(s) for s in servicios] if servicios else []
@@ -35,6 +93,18 @@ def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=
return d
# LEFT JOIN: los servicios personalizados no tienen servicioCatalogoId y un JOIN
# interno los dejaría fuera de la respuesta. COALESCE conserva el nombre custom.
_SERVICIOS_SQL = """
SELECT sc.*, sc."servicioCatalogoId" as "catalogoId",
COALESCE(s.nombre, sc.nombre) AS nombre, s.descripcion, s."categoriaId"
FROM "ServicioCotizado" sc
LEFT JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = {}
ORDER BY sc.fase, sc.opcion, sc.id
"""
async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None:
cot = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
if not cot:
@@ -42,22 +112,46 @@ async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None:
cliente = await conn.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cot["clienteId"])
asesor = await conn.fetchrow('SELECT id, email, name, role FROM "User" WHERE id = $1', cot["asesorId"])
servicios = await conn.fetch(
"""
SELECT sc.*, sc."servicioCatalogoId" as "catalogoId",
s.nombre, s.descripcion, s."categoriaId"
FROM "ServicioCotizado" sc
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = $1
ORDER BY sc.fase, sc.id
""",
cot_id,
)
servicios = await conn.fetch(_SERVICIOS_SQL.format("$1"), cot_id)
plan = await conn.fetchrow('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id)
return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan)
async def _fetch_cotizaciones_full(conn, cot_rows) -> list[dict]:
"""Versión batch de _fetch_cotizacion_full: 4 queries en total para toda la
página en lugar de 4 por cotización (evita N+1 en el listado)."""
if not cot_rows:
return []
cot_ids = [r["id"] for r in cot_rows]
cliente_ids = list({r["clienteId"] for r in cot_rows})
asesor_ids = list({r["asesorId"] for r in cot_rows})
clientes = await conn.fetch('SELECT * FROM "Cliente" WHERE id = ANY($1::text[])', cliente_ids)
asesores = await conn.fetch('SELECT id, email, name, role FROM "User" WHERE id = ANY($1::text[])', asesor_ids)
servicios = await conn.fetch(_SERVICIOS_SQL.format("ANY($1::text[])"), cot_ids)
planes = await conn.fetch('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = ANY($1::text[])', cot_ids)
clientes_map = {c["id"]: c for c in clientes}
asesores_map = {a["id"]: a for a in asesores}
planes_map = {p["cotizacionId"]: p for p in planes}
servicios_map: dict[str, list] = {}
for s in servicios:
servicios_map.setdefault(s["cotizacionId"], []).append(s)
return [
_build_cotizacion_dict(
cot,
clientes_map.get(cot["clienteId"]),
asesores_map.get(cot["asesorId"]),
servicios_map.get(cot["id"], []),
planes_map.get(cot["id"]),
)
for cot in cot_rows
]
async def _resolve_bucefalo_servicio(conn) -> str | None:
row = await conn.fetchrow(
"""
@@ -168,11 +262,7 @@ async def list_cotizaciones(
pagination.offset,
)
cotizaciones = []
for r in rows:
full = await _fetch_cotizacion_full(pool, r["id"])
if full:
cotizaciones.append(full)
cotizaciones = await _fetch_cotizaciones_full(pool, rows)
pages = (total + pagination.limit - 1) // pagination.limit if total > 0 else 0
@@ -200,13 +290,16 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
cot_id = _cuid()
now = datetime.now(timezone.utc)
opciones_json = None
if body.esDoble and body.opciones:
opciones_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()})
await conn.execute(
"""
INSERT INTO "Cotizacion"
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones,
"clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14)
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata",
observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16)
""",
cot_id,
body.numero,
@@ -218,6 +311,8 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
body.esquemaPago,
body.incluirBonos,
body.incluirFinanciamiento,
body.esDoble,
opciones_json,
body.observaciones or None,
cliente_id,
body.asesorId,
@@ -227,28 +322,7 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
for svc in body.servicios:
catalogo_id = svc.catalogoId
if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id:
catalogo_id = bucefalo_servicio_id
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10)
""",
_cuid(),
cot_id,
catalogo_id,
svc.fase,
svc.tipoPago,
svc.precio,
svc.tiempoEntrega,
json.dumps(svc.entregables),
None,
now,
)
await _insert_servicio_cotizado(conn, cot_id, svc, bucefalo_servicio_id, now, body.esDoble)
if body.planBucefalo:
await conn.execute(
@@ -265,7 +339,9 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir
)
row = await pool.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
return dict(row)
result = dict(row)
result["opcionesMetadata"] = _parse_json(result.get("opcionesMetadata"))
return result
# ---------------------------------------------------------------------------
@@ -325,9 +401,20 @@ async def update_cotizacion(
_add("esquemaPago", '"esquemaPago"', body.esquemaPago)
_add("incluirBonos", '"incluirBonos"', body.incluirBonos)
_add("incluirFinanciamiento", '"incluirFinanciamiento"', body.incluirFinanciamiento)
_add("esDoble", '"esDoble"', body.esDoble)
_add("observaciones", "observaciones", body.observaciones)
_add("estado", "estado", body.estado)
es_doble_final = body.esDoble if body.esDoble is not None else existing["esDoble"]
if body.esDoble is not None:
# opcionesMetadata se sincroniza con esDoble: dict (json) si doble, NULL si no.
meta_json = None
if es_doble_final and body.opciones:
meta_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()})
updates.append(f'"opcionesMetadata" = ${idx}')
params.append(meta_json)
idx += 1
if cliente_id != existing["clienteId"]:
updates.append(f'"clienteId" = ${idx}')
params.append(cliente_id)
@@ -348,27 +435,7 @@ async def update_cotizacion(
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
now = datetime.now(timezone.utc)
for svc in body.servicios:
catalogo_id = svc.catalogoId
if catalogo_id.startswith("bucefalo-") and bucefalo_servicio_id:
catalogo_id = bucefalo_servicio_id
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,true,$10,$10)
""",
_cuid(),
cotizacion_id,
catalogo_id,
svc.fase,
svc.tipoPago,
svc.precio,
svc.tiempoEntrega,
json.dumps(svc.entregables),
None,
now,
)
await _insert_servicio_cotizado(conn, cotizacion_id, svc, bucefalo_servicio_id, now, es_doble_final)
if body.planBucefalo is not None:
existing_plan = await conn.fetchrow(
@@ -505,9 +572,9 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require
"""
INSERT INTO "Cotizacion"
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
estado, "incluirBonos", "incluirFinanciamiento", observaciones,
"clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14)
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata",
observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16)
""",
new_id,
new_numero,
@@ -519,6 +586,8 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require
original["esquemaPago"],
original["incluirBonos"],
original["incluirFinanciamiento"],
original["esDoble"],
original["opcionesMetadata"],
original["observaciones"],
original["clienteId"],
original["asesorId"],
@@ -529,13 +598,23 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require
await conn.execute(
"""
INSERT INTO "ServicioCotizado"
(id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago", precio,
"tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$11)
(id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado",
horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas",
opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas,
seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19)
""",
_cuid(),
new_id,
svc["servicioCatalogoId"],
svc["nombre"],
svc["esPersonalizado"],
svc["horas"],
svc["tarifaHora"],
svc["modeloCobro"],
svc["montoMinimo"],
svc["horasIncluidas"],
svc["opcion"],
svc["fase"],
svc["tipoPago"],
svc["precio"],
+35 -4
View File
@@ -69,9 +69,20 @@ def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) ->
"precio": s.precio,
"tiempoEntrega": s.tiempoEntrega,
"entregables": s.entregables,
"esPersonalizado": s.esPersonalizado,
"horas": s.horas,
"tarifaHora": s.tarifaHora,
"modeloCobro": s.modeloCobro,
"montoMinimo": s.montoMinimo,
"horasIncluidas": s.horasIncluidas,
"opcion": s.opcion,
}
for s in draft.servicios
],
"esDoble": draft.esDoble,
"opcionesMetadata": (
{k: v.model_dump() for k, v in draft.opciones.items()} if draft.esDoble and draft.opciones else None
),
"planBucefaloNivel": draft.planBucefaloNivel,
"planBucefaloPrecio": bucefalo_precio(draft.planBucefaloNivel) if draft.planBucefaloNivel else 0,
"incluirBonos": draft.incluirBonos,
@@ -81,6 +92,7 @@ def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) ->
"logoMime": logo_mime,
"configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"),
"domicilioFiscal": branding.get("domicilio_fiscal"),
}
@@ -99,11 +111,13 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
servicios = await db.fetch(
"""SELECT sc.fase, sc."tipoPago", sc.precio, sc."tiempoEntrega", sc.entregables,
s.nombre
sc."esPersonalizado", sc.horas, sc."tarifaHora", sc."modeloCobro",
sc."montoMinimo", sc."horasIncluidas", sc.opcion,
COALESCE(s.nombre, sc.nombre) AS nombre
FROM "ServicioCotizado" sc
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
LEFT JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = $1 AND sc.seleccionado = true
ORDER BY sc.fase, sc.id""",
ORDER BY sc.fase, sc.opcion, sc.id""",
cotizacion_id,
)
@@ -133,14 +147,28 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
except (json.JSONDecodeError, TypeError):
ent = []
servicios_list.append({
"nombre": s["nombre"],
"nombre": s["nombre"] or "Servicio",
"fase": s["fase"],
"tipoPago": s["tipoPago"],
"precio": float(s["precio"]),
"tiempoEntrega": s["tiempoEntrega"],
"entregables": ent or [],
"esPersonalizado": s["esPersonalizado"],
"horas": float(s["horas"]) if s["horas"] is not None else None,
"tarifaHora": float(s["tarifaHora"]) if s["tarifaHora"] is not None else None,
"modeloCobro": s["modeloCobro"],
"montoMinimo": float(s["montoMinimo"]) if s["montoMinimo"] is not None else None,
"horasIncluidas": float(s["horasIncluidas"]) if s["horasIncluidas"] is not None else None,
"opcion": s["opcion"],
})
opciones_meta = cot["opcionesMetadata"]
if isinstance(opciones_meta, str):
try:
opciones_meta = json.loads(opciones_meta)
except (json.JSONDecodeError, TypeError):
opciones_meta = None
plan_nivel = plan["nivel"] if plan else None
return {
@@ -155,6 +183,8 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
"proyecto": cot["proyecto"],
"esquemaPago": cot["esquemaPago"],
"servicios": servicios_list,
"esDoble": cot["esDoble"],
"opcionesMetadata": opciones_meta,
"planBucefaloNivel": plan_nivel,
"planBucefaloPrecio": float(plan["precio"]) if plan else 0,
"incluirBonos": cot["incluirBonos"],
@@ -164,6 +194,7 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st
"logoMime": logo_mime,
"configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"),
"domicilioFiscal": branding.get("domicilio_fiscal"),
}
+84 -72
View File
@@ -19,96 +19,111 @@ from app.models.common import ErrorResponse, OkResponse
router = APIRouter(prefix="/paquetes", tags=["Paquetes"])
async def _build_paquete_response(conn, paquete_row: dict) -> dict:
"""Build a PaqueteResponse dict with nested fases and servicios."""
paquete_id = paquete_row["id"]
async def _build_paquetes_responses(conn, paquete_rows: list[dict]) -> list[dict]:
"""Build PaqueteResponse dicts for several paquetes with 2 queries in total
(instead of 1 + N fases per paquete)."""
if not paquete_rows:
return []
paquete_ids = [p["id"] for p in paquete_rows]
fases_rows = await conn.fetch(
'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"'
' FROM "FasePaquete" WHERE "paqueteId" = $1 ORDER BY orden ASC',
paquete_id,
' FROM "FasePaquete" WHERE "paqueteId" = ANY($1::text[]) ORDER BY orden ASC',
paquete_ids,
)
fases: list[dict] = []
for fase in fases_rows:
fase_ids = [f["id"] for f in fases_rows]
servicios_rows = []
if fase_ids:
servicios_rows = await conn.fetch(
'SELECT sp.id, sp."servicioCatalogoId", sp."fasePaqueteId",'
' sp."createdAt", sp."updatedAt",'
' sc.id AS sc_id, sc.nombre AS sc_nombre, sc.descripcion AS sc_descripcion,'
' sc.fase AS sc_fase, sc."tipoPago" AS sc_tipoPago,'
' sc."precioBase" AS sc_precioBase, sc."tiempoEntrega" AS sc_tiempoEntrega,'
' sc."entregablesDefault" AS sc_entregablesDefault,'
' sc."categoriaId" AS sc_categoriaId, sc.variante AS sc_variante,'
' sc.activo AS sc_activo, sc.orden AS sc_orden,'
' sc."createdAt" AS sc_createdAt, sc."updatedAt" AS sc_updatedAt,'
' cat.id AS cat_id, cat.nombre AS cat_nombre, cat.descripcion AS cat_descripcion,'
' cat.color AS cat_color, cat.activo AS cat_activo, cat.orden AS cat_orden,'
' cat."createdAt" AS cat_createdAt, cat."updatedAt" AS cat_updatedAt'
' sc.id AS "sc_id", sc.nombre AS "sc_nombre", sc.descripcion AS "sc_descripcion",'
' sc.fase AS "sc_fase", sc."tipoPago" AS "sc_tipoPago",'
' sc."precioBase" AS "sc_precioBase", sc."tiempoEntrega" AS "sc_tiempoEntrega",'
' sc."entregablesDefault" AS "sc_entregablesDefault",'
' sc."categoriaId" AS "sc_categoriaId", sc.variante AS "sc_variante",'
' sc.activo AS "sc_activo", sc.orden AS "sc_orden",'
' sc."createdAt" AS "sc_createdAt", sc."updatedAt" AS "sc_updatedAt",'
' cat.id AS "cat_id", cat.nombre AS "cat_nombre", cat.descripcion AS "cat_descripcion",'
' cat.color AS "cat_color", cat.activo AS "cat_activo", cat.orden AS "cat_orden",'
' cat."createdAt" AS "cat_createdAt", cat."updatedAt" AS "cat_updatedAt"'
' FROM "ServicioPaquete" sp'
' JOIN "ServicioCatalogo" sc ON sc.id = sp."servicioCatalogoId"'
' LEFT JOIN "Categoria" cat ON cat.id = sc."categoriaId"'
' WHERE sp."fasePaqueteId" = $1',
fase["id"],
' WHERE sp."fasePaqueteId" = ANY($1::text[])',
fase_ids,
)
servicios: list[dict] = []
for s in servicios_rows:
cat = None
if s["cat_id"]:
cat = {
"id": s["cat_id"],
"nombre": s["cat_nombre"],
"descripcion": s["cat_descripcion"],
"color": s["cat_color"],
"activo": s["cat_activo"],
"orden": s["cat_orden"],
"createdAt": s["cat_createdAt"],
"updatedAt": s["cat_updatedAt"],
}
servicios.append({
"id": s["id"],
"servicioCatalogoId": s["servicioCatalogoId"],
"fasePaqueteId": s["fasePaqueteId"],
"createdAt": s["createdAt"],
"updatedAt": s["updatedAt"],
"servicio": {
"id": s["sc_id"],
"nombre": s["sc_nombre"],
"descripcion": s["sc_descripcion"],
"fase": s["sc_fase"],
"tipoPago": s["sc_tipoPago"],
"precioBase": s["sc_precioBase"],
"tiempoEntrega": s["sc_tiempoEntrega"],
"entregablesDefault": s["sc_entregablesDefault"],
"categoriaId": s["sc_categoriaId"],
"variante": s["sc_variante"],
"activo": s["sc_activo"],
"orden": s["sc_orden"],
"createdAt": s["sc_createdAt"],
"updatedAt": s["sc_updatedAt"],
"categoriaRel": cat,
},
})
servicios_por_fase: dict[str, list[dict]] = {}
for s in servicios_rows:
cat = None
if s["cat_id"]:
cat = {
"id": s["cat_id"],
"nombre": s["cat_nombre"],
"descripcion": s["cat_descripcion"],
"color": s["cat_color"],
"activo": s["cat_activo"],
"orden": s["cat_orden"],
"createdAt": s["cat_createdAt"],
"updatedAt": s["cat_updatedAt"],
}
servicios_por_fase.setdefault(s["fasePaqueteId"], []).append({
"id": s["id"],
"servicioCatalogoId": s["servicioCatalogoId"],
"fasePaqueteId": s["fasePaqueteId"],
"createdAt": s["createdAt"],
"updatedAt": s["updatedAt"],
"servicio": {
"id": s["sc_id"],
"nombre": s["sc_nombre"],
"descripcion": s["sc_descripcion"],
"fase": s["sc_fase"],
"tipoPago": s["sc_tipoPago"],
"precioBase": s["sc_precioBase"],
"tiempoEntrega": s["sc_tiempoEntrega"],
"entregablesDefault": s["sc_entregablesDefault"],
"categoriaId": s["sc_categoriaId"],
"variante": s["sc_variante"],
"activo": s["sc_activo"],
"orden": s["sc_orden"],
"createdAt": s["sc_createdAt"],
"updatedAt": s["sc_updatedAt"],
"categoriaRel": cat,
},
})
fases.append({
fases_por_paquete: dict[str, list[dict]] = {}
for fase in fases_rows:
fases_por_paquete.setdefault(fase["paqueteId"], []).append({
"id": fase["id"],
"paqueteId": fase["paqueteId"],
"nombre": fase["nombre"],
"orden": fase["orden"],
"createdAt": fase["createdAt"],
"updatedAt": fase["updatedAt"],
"servicios": servicios,
"servicios": servicios_por_fase.get(fase["id"], []),
})
return {
"id": paquete_row["id"],
"nombre": paquete_row["nombre"],
"descripcion": paquete_row["descripcion"],
"activo": paquete_row["activo"],
"createdAt": paquete_row["createdAt"],
"updatedAt": paquete_row["updatedAt"],
"fases": fases,
}
return [
{
"id": p["id"],
"nombre": p["nombre"],
"descripcion": p["descripcion"],
"activo": p["activo"],
"createdAt": p["createdAt"],
"updatedAt": p["updatedAt"],
"fases": fases_por_paquete.get(p["id"], []),
}
for p in paquete_rows
]
async def _build_paquete_response(conn, paquete_row: dict) -> dict:
"""Build a PaqueteResponse dict with nested fases and servicios."""
results = await _build_paquetes_responses(conn, [paquete_row])
return results[0]
@router.get("", response_model=list[PaqueteResponse])
@@ -120,10 +135,7 @@ async def list_paquetes(
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
' FROM "Paquete" WHERE activo = true ORDER BY "createdAt" ASC'
)
results: list[dict] = []
for row in rows:
results.append(await _build_paquete_response(conn, dict(row)))
return results
return await _build_paquetes_responses(conn, [dict(row) for row in rows])
@router.post(