This commit is contained in:
2026-06-10 02:04:37 -06:00
parent 1ba3d8dea4
commit 31cdce03a0
131 changed files with 15922 additions and 106 deletions
View File
+71
View File
@@ -0,0 +1,71 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.database import get_pool
from app.auth import verify_password, create_access_token, require_auth, get_password_hash
from app.models.common import LoginRequest, LoginResponse, OkResponse, ErrorResponse
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest):
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
'SELECT id, email, password, name, role FROM "User" WHERE email = $1',
body.email,
)
if not row:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas",
)
if not verify_password(body.password, row["password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas",
)
token = create_access_token({"sub": str(row["id"]), "email": row["email"], "role": row["role"]})
return LoginResponse(
access_token=token,
user={
"id": row["id"],
"email": row["email"],
"name": row["name"],
"role": row["role"],
},
)
@router.post("/logout", response_model=OkResponse)
async def logout():
return OkResponse(ok=True)
@router.get("/me")
async def me(current_user: dict = Depends(require_auth)):
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
'SELECT id, email, name, role, "createdAt", "updatedAt" FROM "User" WHERE id = $1',
current_user["sub"],
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Usuario no encontrado",
)
return {
"id": row["id"],
"email": row["email"],
"name": row["name"],
"role": row["role"],
"createdAt": row["createdAt"].isoformat() if row["createdAt"] else None,
"updatedAt": row["updatedAt"].isoformat() if row["updatedAt"] else None,
}
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.auth import require_auth
from app.models.export_ import BonoResponse
from app.services.calculators import BONOS
router = APIRouter(prefix="/bonos", tags=["Bonos"])
@router.get("", response_model=list[BonoResponse])
async def get_bonos(_auth: dict = Depends(require_auth)):
return [BonoResponse(**b) for b in BONOS]
+285
View File
@@ -0,0 +1,285 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Query, status
from asyncpg import Connection
from app.auth import require_auth
from app.dependencies import get_db
from app.models.catalogo import (
ServicioCatalogoCreate,
ServicioCatalogoUpdate,
ServicioCatalogoResponse,
CategoriaResponse,
)
from app.models.common import ArchivedResponse, ErrorResponse, OkResponse
router = APIRouter(prefix="/catalogo", tags=["Catalogo"])
def _row_to_response(row, categoria_row=None) -> ServicioCatalogoResponse:
entregables = row["entregablesDefault"]
if isinstance(entregables, str):
entregables = json.loads(entregables)
categoria = None
if categoria_row:
categoria = CategoriaResponse(
id=categoria_row["id"],
nombre=categoria_row["nombre"],
descripcion=categoria_row["descripcion"],
color=categoria_row["color"],
orden=categoria_row["orden"],
activo=categoria_row["activo"],
createdAt=categoria_row["createdAt"],
updatedAt=categoria_row["updatedAt"],
)
return ServicioCatalogoResponse(
id=row["id"],
nombre=row["nombre"],
descripcion=row["descripcion"],
fase=row["fase"],
tipoPago=row["tipoPago"],
precioBase=float(row["precioBase"]),
tiempoEntrega=row["tiempoEntrega"],
entregablesDefault=entregables,
categoriaId=row["categoriaId"],
variante=row["variante"],
activo=row["activo"],
orden=row["orden"],
createdAt=row["createdAt"],
updatedAt=row["updatedAt"],
categoriaRel=categoria,
)
@router.get(
"",
response_model=list[ServicioCatalogoResponse],
summary="List active catalog services with filters",
)
async def list_catalogo(
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
fase: int | None = Query(None, ge=0, le=3),
tipoPago: str | None = Query(None, pattern="^(unico|mensual)$"),
categoriaId: str | None = Query(None),
q: str | None = Query(None, description="Search by nombre or descripcion"),
):
where_clauses: list[str] = ['activo = true']
params: list = []
idx = 1
if fase is not None:
where_clauses.append(f"fase = ${idx}")
params.append(fase)
idx += 1
if tipoPago:
where_clauses.append(f'"tipoPago" = ${idx}')
params.append(tipoPago)
idx += 1
if categoriaId:
where_clauses.append(f'"categoriaId" = ${idx}')
params.append(categoriaId)
idx += 1
if q:
where_clauses.append(f"(nombre ILIKE ${idx} OR descripcion ILIKE ${idx})")
params.append(f"%{q}%")
idx += 1
where_sql = f"WHERE {' AND '.join(where_clauses)}"
rows = await db.fetch(
f'SELECT * FROM "ServicioCatalogo" {where_sql} ORDER BY fase ASC, orden ASC',
*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))
return result
@router.post(
"",
response_model=ServicioCatalogoResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new catalog service",
)
async def create_catalogo(
body: ServicioCatalogoCreate,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
if body.categoriaId:
cat = await db.fetchrow(
'SELECT id FROM "Categoria" WHERE id = $1', body.categoriaId
)
if not cat:
raise HTTPException(status_code=400, detail="Categoria no encontrada")
entregables_json = json.dumps(body.entregablesDefault)
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 *",
body.nombre,
body.descripcion,
body.fase,
body.tipoPago,
body.precioBase,
body.tiempoEntrega,
entregables_json,
body.categoriaId,
body.variante,
body.orden,
)
cat_row = None
if row["categoriaId"]:
cat_row = await db.fetchrow(
'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"]
)
return _row_to_response(row, cat_row)
@router.get(
"/{servicio_id}",
response_model=ServicioCatalogoResponse,
responses={404: {"model": ErrorResponse}},
summary="Get catalog service by ID with categoria join",
)
async def get_catalogo(
servicio_id: str,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
row = await db.fetchrow(
'SELECT * FROM "ServicioCatalogo" WHERE id = $1', servicio_id
)
if not row:
raise HTTPException(status_code=404, detail="Servicio no encontrado")
cat_row = None
if row["categoriaId"]:
cat_row = await db.fetchrow(
'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"]
)
return _row_to_response(row, cat_row)
@router.put(
"/{servicio_id}",
response_model=ServicioCatalogoResponse,
responses={404: {"model": ErrorResponse}},
summary="Update a catalog service",
)
async def update_catalogo(
servicio_id: str,
body: ServicioCatalogoUpdate,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
existing = await db.fetchrow(
'SELECT id FROM "ServicioCatalogo" WHERE id = $1', servicio_id
)
if not existing:
raise HTTPException(status_code=404, detail="Servicio no encontrado")
if body.categoriaId:
cat = await db.fetchrow(
'SELECT id FROM "Categoria" WHERE id = $1', body.categoriaId
)
if not cat:
raise HTTPException(status_code=400, detail="Categoria no encontrada")
field_map = {
"nombre": "nombre",
"descripcion": "descripcion",
"fase": "fase",
"tipoPago": '"tipoPago"',
"precioBase": '"precioBase"',
"tiempoEntrega": '"tiempoEntrega"',
"entregablesDefault": '"entregablesDefault"',
"categoriaId": '"categoriaId"',
"variante": "variante",
"activo": "activo",
"orden": "orden",
}
updates: list[str] = []
params: list = []
idx = 1
for field, col in field_map.items():
value = getattr(body, field, None)
if value is not None:
if field == "entregablesDefault":
value = json.dumps(value)
updates.append(f"{col} = ${idx}")
params.append(value)
idx += 1
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(servicio_id)
row = await db.fetchrow(
f'UPDATE "ServicioCatalogo" SET {", ".join(updates)} WHERE id = ${idx} RETURNING *',
*params,
)
cat_row = None
if row["categoriaId"]:
cat_row = await db.fetchrow(
'SELECT * FROM "Categoria" WHERE id = $1', row["categoriaId"]
)
return _row_to_response(row, cat_row)
@router.delete(
"/{servicio_id}",
response_model=ArchivedResponse,
responses={404: {"model": ErrorResponse}},
summary="Delete or soft-delete a catalog service",
)
async def delete_catalogo(
servicio_id: str,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
existing = await db.fetchrow(
'SELECT id FROM "ServicioCatalogo" WHERE id = $1', servicio_id
)
if not existing:
raise HTTPException(status_code=404, detail="Servicio no encontrado")
ref_count = await db.fetchval(
'SELECT COUNT(*) FROM "ServicioCotizado" WHERE "servicioCatalogoId" = $1',
servicio_id,
)
if ref_count > 0:
await db.execute(
'UPDATE "ServicioCatalogo" SET activo = false WHERE id = $1', servicio_id
)
return ArchivedResponse(ok=True, archived=True)
await db.execute('DELETE FROM "ServicioCatalogo" WHERE id = $1', servicio_id)
return ArchivedResponse(ok=True, archived=False)
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from app.database import get_pool
from app.dependencies import get_db
from app.auth import require_auth
from app.models.catalogo import CategoriaCreate, CategoriaUpdate, CategoriaResponse
from app.models.common import ErrorResponse, OkResponse
router = APIRouter(prefix="/categorias", tags=["Categorias"])
@router.get("", response_model=list[CategoriaResponse])
async def list_categorias(
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
rows = await conn.fetch(
'SELECT id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"'
' FROM "Categoria" ORDER BY orden ASC'
)
return [dict(r) for r in rows]
@router.post(
"",
response_model=CategoriaResponse,
status_code=status.HTTP_201_CREATED,
responses={409: {"model": ErrorResponse}},
)
async def create_categoria(
body: CategoriaCreate,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
now = datetime.now(timezone.utc)
try:
row = await conn.fetchrow(
'INSERT INTO "Categoria" (id, nombre, descripcion, color, orden, "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $5)"
' RETURNING id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"',
body.nombre,
body.descripcion,
body.color,
body.orden,
now,
)
except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe una categoría con el nombre '{body.nombre}'",
)
raise
return dict(row)
@router.get(
"/{categoria_id}",
responses={404: {"model": ErrorResponse}},
)
async def get_categoria(
categoria_id: str,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
row = await conn.fetchrow(
'SELECT c.id, c.nombre, c.descripcion, c.color, c.activo, c.orden,'
' c."createdAt", c."updatedAt",'
' COUNT(s.id)::int AS "serviciosCount"'
' FROM "Categoria" c'
' LEFT JOIN "ServicioCatalogo" s ON s."categoriaId" = c.id'
" WHERE c.id = $1"
" GROUP BY c.id",
categoria_id,
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Categoría no encontrada",
)
return dict(row)
@router.put(
"/{categoria_id}",
response_model=CategoriaResponse,
responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
async def update_categoria(
categoria_id: str,
body: CategoriaUpdate,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
existing = await conn.fetchrow(
'SELECT id FROM "Categoria" WHERE id = $1', categoria_id
)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Categoría no encontrada",
)
updates: list[str] = []
params: list = []
idx = 1
if body.nombre is not None:
updates.append(f"nombre = ${idx}")
params.append(body.nombre)
idx += 1
if body.descripcion is not None:
updates.append(f"descripcion = ${idx}")
params.append(body.descripcion)
idx += 1
if body.color is not None:
updates.append(f"color = ${idx}")
params.append(body.color)
idx += 1
if body.orden is not None:
updates.append(f"orden = ${idx}")
params.append(body.orden)
idx += 1
if body.activo is not None:
updates.append(f"activo = ${idx}")
params.append(body.activo)
idx += 1
if not updates:
row = await conn.fetchrow(
'SELECT id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"'
' FROM "Categoria" WHERE id = $1',
categoria_id,
)
return dict(row)
updates.append(f'"updatedAt" = ${idx}')
params.append(datetime.now(timezone.utc))
idx += 1
params.append(categoria_id)
try:
row = await conn.fetchrow(
f'UPDATE "Categoria" SET {", ".join(updates)} WHERE id = ${idx}'
' RETURNING id, nombre, descripcion, color, activo, orden, "createdAt", "updatedAt"',
*params,
)
except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Ya existe una categoría con el nombre '{body.nombre}'",
)
raise
return dict(row)
@router.delete(
"/{categoria_id}",
response_model=OkResponse,
responses={404: {"model": ErrorResponse}, 409: {"model": ErrorResponse}},
)
async def delete_categoria(
categoria_id: str,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
existing = await conn.fetchrow(
'SELECT id FROM "Categoria" WHERE id = $1', categoria_id
)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Categoría no encontrada",
)
count = await conn.fetchval(
'SELECT COUNT(*)::int FROM "ServicioCatalogo" WHERE "categoriaId" = $1',
categoria_id,
)
if count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Tiene {count} servicios asociados",
)
await conn.execute('DELETE FROM "Categoria" WHERE id = $1', categoria_id)
return OkResponse(ok=True)
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
import math
from fastapi import APIRouter, Depends, HTTPException, Query, status
from asyncpg import Connection
from app.auth import require_auth
from app.dependencies import PaginationParams, get_db
from app.models.cliente import ClienteCreate, ClienteUpdate, ClienteResponse
from app.models.common import PaginatedResponse, Meta, ErrorResponse, OkResponse
router = APIRouter(prefix="/clientes", tags=["Clientes"])
@router.get(
"",
response_model=PaginatedResponse[ClienteResponse],
summary="List clients with pagination and search",
)
async def list_clientes(
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
pagination: PaginationParams = Depends(PaginationParams),
q: str | None = Query(None, description="Search by nombre, empresa, or email"),
):
where_clauses: list[str] = []
params: list = []
idx = 1
if q:
where_clauses.append(
f"(nombre ILIKE ${idx} OR empresa ILIKE ${idx} OR email ILIKE ${idx})"
)
params.append(f"%{q}%")
idx += 1
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
count_row = await db.fetchrow(
f"SELECT COUNT(*) AS total FROM \"Cliente\" {where_sql}", *params
)
total = count_row["total"]
pages = math.ceil(total / pagination.limit) if total > 0 else 0
rows = await db.fetch(
f'SELECT * FROM "Cliente" {where_sql} ORDER BY "createdAt" DESC '
f"LIMIT ${idx} OFFSET ${idx + 1}",
*params,
pagination.limit,
pagination.offset,
)
data = [
ClienteResponse(
id=r["id"],
nombre=r["nombre"],
empresa=r["empresa"],
email=r["email"],
telefono=r["telefono"],
createdAt=r["createdAt"],
updatedAt=r["updatedAt"],
)
for r in rows
]
return PaginatedResponse(
data=data,
meta=Meta(total=total, page=pagination.page, limit=pagination.limit, pages=pages),
)
@router.post(
"",
response_model=ClienteResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new client",
)
async def create_cliente(
body: ClienteCreate,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
row = await db.fetchrow(
'INSERT INTO "Cliente" (nombre, empresa, email, telefono) '
"VALUES ($1, $2, $3, $4) RETURNING *",
body.nombre,
body.empresa,
body.email,
body.telefono,
)
return ClienteResponse(
id=row["id"],
nombre=row["nombre"],
empresa=row["empresa"],
email=row["email"],
telefono=row["telefono"],
createdAt=row["createdAt"],
updatedAt=row["updatedAt"],
)
@router.get(
"/{cliente_id}",
response_model=ClienteResponse,
responses={404: {"model": ErrorResponse}},
summary="Get client by ID with recent cotizaciones",
)
async def get_cliente(
cliente_id: str,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
row = await db.fetchrow('SELECT * FROM "Cliente" WHERE id = $1', cliente_id)
if not row:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
cotizaciones = await db.fetch(
'SELECT * FROM "Cotizacion" WHERE "clienteId" = $1 '
'ORDER BY "createdAt" DESC LIMIT 10',
cliente_id,
)
cliente = ClienteResponse(
id=row["id"],
nombre=row["nombre"],
empresa=row["empresa"],
email=row["email"],
telefono=row["telefono"],
createdAt=row["createdAt"],
updatedAt=row["updatedAt"],
)
return cliente.model_copy(
update={"cotizaciones": [dict(c) for c in cotizaciones]}
)
@router.put(
"/{cliente_id}",
response_model=ClienteResponse,
responses={404: {"model": ErrorResponse}},
summary="Update a client",
)
async def update_cliente(
cliente_id: str,
body: ClienteUpdate,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
existing = await db.fetchrow('SELECT id FROM "Cliente" WHERE id = $1', cliente_id)
if not existing:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
updates: list[str] = []
params: list = []
idx = 1
for field in ("nombre", "empresa", "email", "telefono"):
value = getattr(body, field, None)
if value is not None:
updates.append(f"{field} = ${idx}")
params.append(value)
idx += 1
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(cliente_id)
row = await db.fetchrow(
f'UPDATE "Cliente" SET {", ".join(updates)} WHERE id = ${idx} RETURNING *',
*params,
)
return ClienteResponse(
id=row["id"],
nombre=row["nombre"],
empresa=row["empresa"],
email=row["email"],
telefono=row["telefono"],
createdAt=row["createdAt"],
updatedAt=row["updatedAt"],
)
@router.delete(
"/{cliente_id}",
response_model=OkResponse,
responses={409: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
summary="Delete a client (fails if cotizaciones exist)",
)
async def delete_cliente(
cliente_id: str,
db: Connection = Depends(get_db),
auth: dict = Depends(require_auth),
):
existing = await db.fetchrow('SELECT id FROM "Cliente" WHERE id = $1', cliente_id)
if not existing:
raise HTTPException(status_code=404, detail="Cliente no encontrado")
cot_count = await db.fetchval(
'SELECT COUNT(*) FROM "Cotizacion" WHERE "clienteId" = $1', cliente_id
)
if cot_count > 0:
raise HTTPException(
status_code=409,
detail="No se puede eliminar: el cliente tiene cotizaciones asociadas",
)
await db.execute('DELETE FROM "Cliente" WHERE id = $1', cliente_id)
return OkResponse()
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from asyncpg import Connection
from app.dependencies import get_db
from app.models.configuracion import ConfigUpdate, ConfigResponse, ALLOWED_CONFIG_KEYS
from app.models.common import ErrorResponse, OkResponse
from app.auth import require_auth
router = APIRouter(prefix="/configuracion", tags=["Configuración"])
@router.get("", response_model=ConfigResponse)
async def get_configuracion(
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
rows = await db.fetch("SELECT clave, valor FROM Configuracion")
config = {row["clave"]: row["valor"] for row in rows}
return ConfigResponse(config=config)
@router.put("", response_model=OkResponse, responses={400: {"model": ErrorResponse}})
async def update_configuracion(
body: ConfigUpdate,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
allowed = body.get_allowed()
if not allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No valid config keys provided",
)
async with db.transaction():
for clave, valor in allowed.items():
await db.execute(
"""
INSERT INTO "Configuracion" (id, clave, valor, "updatedAt")
VALUES (gen_random_uuid(), $1, $2, now())
ON CONFLICT (clave) DO UPDATE SET valor = $2, "updatedAt" = now()
""",
clave,
valor,
)
return OkResponse()
+565
View File
@@ -0,0 +1,565 @@
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.auth import require_auth
from app.database import get_pool
from app.dependencies import PaginationParams, parse_date
from app.models.common import Meta, OkResponse, PaginatedResponse
from app.models.cotizacion import (
ActualizarPrecioRequest,
CambiarEstadoRequest,
CotizacionCreate,
CotizacionResponse,
CotizacionUpdate,
)
from app.services.calculators import ESTADOS_COTIZACION
router = APIRouter(prefix="/cotizaciones", tags=["Cotizaciones"])
def _cuid() -> str:
return uuid.uuid4().hex[:25]
def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict:
d = dict(cot)
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 []
d["planBucefalo"] = dict(plan) if plan else None
return d
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:
return 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,
)
plan = await conn.fetchrow('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id)
return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan)
async def _resolve_bucefalo_servicio(conn) -> str | None:
row = await conn.fetchrow(
"""
SELECT s.id FROM "ServicioCatalogo" s
JOIN "Categoria" c ON c.id = s."categoriaId"
WHERE c.nombre = 'CRM' AND s."tipoPago" = 'mensual' AND s.activo = true
ORDER BY s.orden LIMIT 1
"""
)
return row["id"] if row else None
async def _find_or_create_cliente(conn, nombre: str, empresa: str, email: str, telefono: str) -> str:
row = await conn.fetchrow(
'SELECT id FROM "Cliente" WHERE nombre = $1 AND COALESCE(empresa, \'\') = $2',
nombre,
empresa or "",
)
if row:
if email or telefono:
await conn.execute(
'UPDATE "Cliente" SET email = COALESCE($1, email), telefono = COALESCE($2, telefono), "updatedAt" = NOW() WHERE id = $3',
email or None,
telefono or None,
row["id"],
)
return row["id"]
new_id = _cuid()
await conn.execute(
'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES ($1, $2, $3, $4, $5, NOW(), NOW())',
new_id,
nombre,
empresa or None,
email or None,
telefono or None,
)
return new_id
# ---------------------------------------------------------------------------
# 1. GET /cotizaciones — List with filters + pagination
# ---------------------------------------------------------------------------
@router.get("", response_model=PaginatedResponse[CotizacionResponse])
async def list_cotizaciones(
pagination: PaginationParams = Depends(),
estado: str | None = Query(None),
asesorId: str | None = Query(None),
clienteId: str | None = Query(None),
q: str | None = Query(None),
desde: str | None = Query(None),
hasta: str | None = Query(None),
_auth: dict = Depends(require_auth),
):
pool = await get_pool()
conditions: list[str] = []
params: list = []
idx = 1
if estado:
conditions.append(f'c.estado = ${idx}')
params.append(estado)
idx += 1
if asesorId:
conditions.append(f'c."asesorId" = ${idx}')
params.append(asesorId)
idx += 1
if clienteId:
conditions.append(f'c."clienteId" = ${idx}')
params.append(clienteId)
idx += 1
if q:
conditions.append(
f'(c.numero ILIKE ${idx} OR cl.nombre ILIKE ${idx} OR c.proyecto ILIKE ${idx})'
)
params.append(f"%{q}%")
idx += 1
desde_dt = parse_date(desde)
if desde_dt:
conditions.append(f'c.fecha >= ${idx}')
params.append(desde_dt)
idx += 1
hasta_dt = parse_date(hasta)
if hasta_dt:
conditions.append(f'c.fecha <= ${idx}')
params.append(hasta_dt)
idx += 1
where = "WHERE " + " AND ".join(conditions) if conditions else ""
count_row = await pool.fetchrow(
f'SELECT COUNT(*) FROM "Cotizacion" c LEFT JOIN "Cliente" cl ON cl.id = c."clienteId" {where}',
*params,
)
total = count_row["count"]
rows = await pool.fetch(
f"""
SELECT c.* FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON cl.id = c."clienteId"
{where}
ORDER BY c."createdAt" DESC
LIMIT ${idx} OFFSET ${idx + 1}
""",
*params,
pagination.limit,
pagination.offset,
)
cotizaciones = []
for r in rows:
full = await _fetch_cotizacion_full(pool, r["id"])
if full:
cotizaciones.append(full)
pages = (total + pagination.limit - 1) // pagination.limit if total > 0 else 0
return PaginatedResponse(
data=cotizaciones,
meta=Meta(total=total, page=pagination.page, limit=pagination.limit, pages=pages),
)
# ---------------------------------------------------------------------------
# 2. POST /cotizaciones — Create
# ---------------------------------------------------------------------------
@router.post("", response_model=CotizacionResponse, status_code=status.HTTP_201_CREATED)
async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(require_auth)):
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
cliente_id = await _find_or_create_cliente(
conn,
body.cliente.nombre,
body.cliente.empresa,
body.cliente.email,
body.cliente.telefono,
)
cot_id = _cuid()
now = datetime.now(timezone.utc)
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)
""",
cot_id,
body.numero,
body.fecha,
body.vigencia,
body.moneda,
body.tipoCambio,
body.proyecto,
body.esquemaPago,
body.incluirBonos,
body.incluirFinanciamiento,
body.observaciones or None,
cliente_id,
body.asesorId,
now,
)
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,
)
if body.planBucefalo:
await conn.execute(
"""
INSERT INTO "PlanBucefaloCotizacion"
(id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,true,$5,$5)
""",
_cuid(),
cot_id,
body.planBucefalo.nivel,
body.planBucefalo.precio,
now,
)
row = await pool.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
return dict(row)
# ---------------------------------------------------------------------------
# 3. GET /cotizaciones/{id} — Get with relations
# ---------------------------------------------------------------------------
@router.get("/{cotizacion_id}", response_model=CotizacionResponse)
async def get_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)):
pool = await get_pool()
full = await _fetch_cotizacion_full(pool, cotizacion_id)
if not full:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
return full
# ---------------------------------------------------------------------------
# 4. PUT /cotizaciones/{id} — Update
# ---------------------------------------------------------------------------
@router.put("/{cotizacion_id}", response_model=CotizacionResponse)
async def update_cotizacion(
cotizacion_id: str,
body: CotizacionUpdate,
_auth: dict = Depends(require_auth),
):
pool = await get_pool()
async with pool.acquire() as conn:
existing = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cotizacion_id)
if not existing:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
async with conn.transaction():
cliente_id = existing["clienteId"]
if body.cliente:
cliente_id = await _find_or_create_cliente(
conn,
body.cliente.nombre,
body.cliente.empresa,
body.cliente.email,
body.cliente.telefono,
)
updates: list[str] = []
params: list = []
idx = 1
def _add(field: str, col: str, val):
nonlocal idx
if val is not None:
updates.append(f'{col} = ${idx}')
params.append(val)
idx += 1
_add("fecha", "fecha", body.fecha)
_add("vigencia", "vigencia", body.vigencia)
_add("moneda", "moneda", body.moneda)
_add("tipoCambio", '"tipoCambio"', body.tipoCambio)
_add("proyecto", "proyecto", body.proyecto)
_add("esquemaPago", '"esquemaPago"', body.esquemaPago)
_add("incluirBonos", '"incluirBonos"', body.incluirBonos)
_add("incluirFinanciamiento", '"incluirFinanciamiento"', body.incluirFinanciamiento)
_add("observaciones", "observaciones", body.observaciones)
_add("estado", "estado", body.estado)
if cliente_id != existing["clienteId"]:
updates.append(f'"clienteId" = ${idx}')
params.append(cliente_id)
idx += 1
if updates:
updates.append(f'"updatedAt" = ${idx}')
params.append(datetime.now(timezone.utc))
idx += 1
params.append(cotizacion_id)
await conn.execute(
f'UPDATE "Cotizacion" SET {", ".join(updates)} WHERE id = ${idx}',
*params,
)
if body.servicios is not None:
await conn.execute('DELETE FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cotizacion_id)
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,
)
if body.planBucefalo is not None:
existing_plan = await conn.fetchrow(
'SELECT id FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
now = datetime.now(timezone.utc)
if existing_plan:
await conn.execute(
'UPDATE "PlanBucefaloCotizacion" SET nivel = $1, precio = $2, "updatedAt" = $3 WHERE id = $4',
body.planBucefalo.nivel,
body.planBucefalo.precio,
now,
existing_plan["id"],
)
else:
await conn.execute(
"""
INSERT INTO "PlanBucefaloCotizacion"
(id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,true,$5,$5)
""",
_cuid(),
cotizacion_id,
body.planBucefalo.nivel,
body.planBucefalo.precio,
now,
)
elif body.planBucefalo is None and "planBucefalo" in body.model_fields_set:
await conn.execute(
'DELETE FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
full = await _fetch_cotizacion_full(pool, cotizacion_id)
return full
# ---------------------------------------------------------------------------
# 5. DELETE /cotizaciones/{id}
# ---------------------------------------------------------------------------
@router.delete("/{cotizacion_id}", response_model=OkResponse)
async def delete_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)):
pool = await get_pool()
existing = await pool.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id)
if not existing:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
await pool.execute('DELETE FROM "Cotizacion" WHERE id = $1', cotizacion_id)
return OkResponse(ok=True)
# ---------------------------------------------------------------------------
# 6. PATCH /cotizaciones/{id}/precio
# ---------------------------------------------------------------------------
@router.patch("/{cotizacion_id}/precio", response_model=OkResponse)
async def update_precio_servicio(
cotizacion_id: str,
body: ActualizarPrecioRequest,
_auth: dict = Depends(require_auth),
):
pool = await get_pool()
cot = await pool.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id)
if not cot:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
svc = await pool.fetchrow(
'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2',
body.servicioId,
cotizacion_id,
)
if not svc:
raise HTTPException(status_code=404, detail="Servicio no pertenece a esta cotización")
await pool.execute(
'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2',
body.precio,
body.servicioId,
)
return OkResponse(ok=True)
# ---------------------------------------------------------------------------
# 7. PATCH /cotizaciones/{id}/estado
# ---------------------------------------------------------------------------
@router.patch("/{cotizacion_id}/estado", response_model=OkResponse)
async def cambiar_estado(
cotizacion_id: str,
body: CambiarEstadoRequest,
_auth: dict = Depends(require_auth),
):
if body.estado not in ESTADOS_COTIZACION:
raise HTTPException(
status_code=422,
detail=f"Estado inválido. Permitidos: {', '.join(ESTADOS_COTIZACION)}",
)
pool = await get_pool()
result = await pool.execute(
'UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2',
body.estado,
cotizacion_id,
)
if result == "UPDATE 0":
raise HTTPException(status_code=404, detail="Cotización no encontrada")
return OkResponse(ok=True)
# ---------------------------------------------------------------------------
# 8. POST /cotizaciones/{id}/duplicate
# ---------------------------------------------------------------------------
@router.post("/{cotizacion_id}/duplicate", response_model=CotizacionResponse, status_code=status.HTTP_201_CREATED)
async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require_auth)):
pool = await get_pool()
async with pool.acquire() as conn:
original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cotizacion_id)
if not original:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
servicios = await conn.fetch(
'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1',
cotizacion_id,
)
plan = await conn.fetchrow(
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
async with conn.transaction():
now = datetime.now(timezone.utc)
new_id = _cuid()
new_numero = original["numero"] + "-COPY"
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)
""",
new_id,
new_numero,
original["fecha"],
original["vigencia"],
original["moneda"],
original["tipoCambio"],
original["proyecto"],
original["esquemaPago"],
original["incluirBonos"],
original["incluirFinanciamiento"],
original["observaciones"],
original["clienteId"],
original["asesorId"],
now,
)
for svc in servicios:
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)
""",
_cuid(),
new_id,
svc["servicioCatalogoId"],
svc["fase"],
svc["tipoPago"],
svc["precio"],
svc["tiempoEntrega"],
svc["entregables"],
svc["notas"],
svc["seleccionado"],
now,
)
if plan:
await conn.execute(
"""
INSERT INTO "PlanBucefaloCotizacion"
(id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
VALUES ($1,$2,$3,$4,$5,$6,$6)
""",
_cuid(),
new_id,
plan["nivel"],
plan["precio"],
plan["seleccionado"],
now,
)
full = await _fetch_cotizacion_full(pool, new_id)
return full
+331
View File
@@ -0,0 +1,331 @@
"""Export endpoints — PDF, Excel, CSV generation."""
from __future__ import annotations
import csv
import io
import json
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import PlainTextResponse, Response
from asyncpg import Connection
from app.dependencies import get_db
from app.models.export_ import ExportDraft
from app.models.common import ErrorResponse
from app.auth import require_auth
from app.services.calculators import bucefalo_precio, calcular_vigencia, sanitize_filename
from app.services.pdf_generator import generate_cotizacion_pdf
from app.services.excel_generator import generate_cotizacion_excel
router = APIRouter(prefix="/export", tags=["Export"])
async def _load_branding(db: Connection) -> dict[str, str]:
rows = await db.fetch('SELECT clave, valor FROM "Configuracion"')
return {r["clave"]: r["valor"] for r in rows}
def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) -> dict[str, Any]:
fecha = datetime.now()
if draft.fecha:
try:
fecha = datetime.fromisoformat(draft.fecha.replace("Z", "+00:00"))
except ValueError:
try:
fecha = datetime.strptime(draft.fecha, "%Y-%m-%d")
except ValueError:
pass
vigencia = calcular_vigencia(fecha)
logo_raw = branding.get("logo_base64", "")
logo_mime = None
logo_b64 = None
if logo_raw and ":" in logo_raw:
parts = logo_raw.split(":", 1)
logo_mime = parts[0]
logo_b64 = parts[1]
elif logo_raw:
logo_b64 = logo_raw
return {
"numero": "BORRADOR",
"clienteNombre": draft.clienteNombre,
"clienteEmpresa": draft.clienteEmpresa,
"asesorNombre": draft.asesorNombre,
"fecha": fecha,
"vigencia": vigencia,
"moneda": draft.moneda,
"tipoCambio": draft.tipoCambio,
"proyecto": draft.proyecto,
"esquemaPago": draft.esquemaPago,
"servicios": [
{
"nombre": s.nombre,
"fase": s.fase,
"tipoPago": s.tipoPago,
"precio": s.precio,
"tiempoEntrega": s.tiempoEntrega,
"entregables": s.entregables,
}
for s in draft.servicios
],
"planBucefaloNivel": draft.planBucefaloNivel,
"planBucefaloPrecio": bucefalo_precio(draft.planBucefaloNivel) if draft.planBucefaloNivel else 0,
"incluirBonos": draft.incluirBonos,
"colorPrimario": branding.get("color_primario", "#2563eb"),
"colorSecundario": branding.get("color_secundario", "#1e293b"),
"logoBase64": logo_b64,
"logoMime": logo_mime,
"configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"),
}
async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[str, Any] | None:
cot = await db.fetchrow(
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
u.name as asesor_nombre
FROM "Cotizacion" c
LEFT JOIN "Cliente" cl ON cl.id = c."clienteId"
LEFT JOIN "User" u ON u.id = c."asesorId"
WHERE c.id = $1""",
cotizacion_id,
)
if not cot:
return None
servicios = await db.fetch(
"""SELECT sc.fase, sc."tipoPago", sc.precio, sc."tiempoEntrega", sc.entregables,
s.nombre
FROM "ServicioCotizado" sc
JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
WHERE sc."cotizacionId" = $1 AND sc.seleccionado = true
ORDER BY sc.fase, sc.id""",
cotizacion_id,
)
plan = await db.fetchrow(
'SELECT nivel, precio FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
branding = await _load_branding(db)
logo_raw = branding.get("logo_base64", "")
logo_mime = None
logo_b64 = None
if logo_raw and ":" in logo_raw:
parts = logo_raw.split(":", 1)
logo_mime = parts[0]
logo_b64 = parts[1]
elif logo_raw:
logo_b64 = logo_raw
servicios_list = []
for s in servicios:
ent = s["entregables"]
if isinstance(ent, str):
try:
ent = json.loads(ent)
except (json.JSONDecodeError, TypeError):
ent = []
servicios_list.append({
"nombre": s["nombre"],
"fase": s["fase"],
"tipoPago": s["tipoPago"],
"precio": float(s["precio"]),
"tiempoEntrega": s["tiempoEntrega"],
"entregables": ent or [],
})
plan_nivel = plan["nivel"] if plan else None
return {
"numero": cot["numero"],
"clienteNombre": cot["cliente_nombre"] or "",
"clienteEmpresa": cot["cliente_empresa"] or "",
"asesorNombre": cot["asesor_nombre"] or "",
"fecha": cot["fecha"],
"vigencia": cot["vigencia"],
"moneda": cot["moneda"],
"tipoCambio": cot["tipoCambio"],
"proyecto": cot["proyecto"],
"esquemaPago": cot["esquemaPago"],
"servicios": servicios_list,
"planBucefaloNivel": plan_nivel,
"planBucefaloPrecio": float(plan["precio"]) if plan else 0,
"incluirBonos": cot["incluirBonos"],
"colorPrimario": branding.get("color_primario", "#2563eb"),
"colorSecundario": branding.get("color_secundario", "#1e293b"),
"logoBase64": logo_b64,
"logoMime": logo_mime,
"configBancaria": branding,
"razonSocial": branding.get("razon_social", "Cotizador E3"),
}
# ── PDF ENDPOINTS ──────────────────────────────────
@router.post("/pdf")
async def export_pdf_draft(
body: ExportDraft,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
branding = await _load_branding(db)
pdf_data = _build_pdf_data_from_draft(body, branding)
pdf_bytes = generate_cotizacion_pdf(pdf_data)
empresa = body.clienteEmpresa or body.clienteNombre or "Cotizacion"
filename = f"{sanitize_filename(empresa)} - BORRADOR.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/pdf/{cotizacion_id}")
async def export_pdf_saved(
cotizacion_id: str,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
pdf_data = await _build_pdf_data_from_db(db, cotizacion_id)
if not pdf_data:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
pdf_bytes = generate_cotizacion_pdf(pdf_data)
empresa = pdf_data["clienteEmpresa"] or pdf_data["clienteNombre"]
filename = f"{sanitize_filename(empresa)} - {sanitize_filename(pdf_data['numero'])}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ── EXCEL ENDPOINTS ────────────────────────────────
@router.post("/excel")
async def export_excel_draft(
body: ExportDraft,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
branding = await _load_branding(db)
excel_data = _build_pdf_data_from_draft(body, branding)
excel_bytes = generate_cotizacion_excel(excel_data, saved=False)
empresa = body.clienteEmpresa or body.clienteNombre or "Cotizacion"
filename = f"{sanitize_filename(empresa)} - {sanitize_filename(body.clienteNombre)} - BORRADOR.xlsx"
return Response(
content=excel_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/excel/{cotizacion_id}")
async def export_excel_saved(
cotizacion_id: str,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
excel_data = await _build_pdf_data_from_db(db, cotizacion_id)
if not excel_data:
raise HTTPException(status_code=404, detail="Cotización no encontrada")
excel_bytes = generate_cotizacion_excel(excel_data, saved=True)
empresa = excel_data["clienteEmpresa"] or excel_data["clienteNombre"]
filename = f"{sanitize_filename(empresa)} - {sanitize_filename(excel_data['numero'])}.xlsx"
return Response(
content=excel_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ── CSV ENDPOINTS ──────────────────────────────────
@router.get("/catalogo", response_class=PlainTextResponse)
async def export_catalogo_csv(
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
rows = await db.fetch(
"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
s.variante, s.orden, c.nombre AS categoria
FROM "ServicioCatalogo" s
LEFT JOIN "Categoria" c ON c.id = s."categoriaId"
WHERE s.activo = true
ORDER BY s.fase, s.orden"""
)
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([
"Nombre", "Categoria", "Fase", "Tipo de Pago", "Precio Base",
"Tiempo de Entrega", "Entregables", "Variante",
])
for r in rows:
entregables = r["entregablesDefault"] or []
if isinstance(entregables, list):
entregables = " | ".join(entregables)
elif isinstance(entregables, str):
try:
ent_list = json.loads(entregables)
entregables = " | ".join(ent_list) if isinstance(ent_list, list) else entregables
except (json.JSONDecodeError, TypeError):
pass
writer.writerow([
r["nombre"], r["categoria"], r["fase"], r["tipoPago"],
r["precioBase"], r["tiempoEntrega"], entregables, r["variante"],
])
return PlainTextResponse(
content=buf.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=catalogo-servicios.csv"},
)
@router.get("/catalogo/plantilla", response_class=PlainTextResponse)
async def export_catalogo_template(db: Connection = Depends(get_db)):
cats = await db.fetch(
'SELECT nombre FROM "Categoria" WHERE activo = true ORDER BY orden'
)
cat_names = [r["nombre"] for r in cats]
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([
"nombre", "descripcion", "fase", "tipoPago",
"precioBase", "tiempoEntrega", "entregablesDefault", "variante", "categoria",
])
writer.writerow([
"SEO On-Page", "Optimización on-page para buscadores", "Contenido y SEO", "mensual",
"2900", "7 - 14 dias", "Keyword research | On-page optimization", "", "SEO",
])
writer.writerow([
"", "", "", "", "", "", "", "", "",
])
writer.writerow([f"CATEGORIAS: {', '.join(cat_names)}"])
writer.writerow(["FASES: Auditoria, Setup e Infraestructura, Publicidad y Manejo, Contenido y SEO"])
writer.writerow(["TIPOS DE PAGO: unico, mensual"])
return PlainTextResponse(
content=buf.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=plantilla-catalogo.csv"},
)
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from asyncpg import Connection
from app.dependencies import get_db
from app.models.export_ import (
FinanciamientoRequest,
FinanciamientoResponse,
BonoResponse,
PlanBucefaloResponse,
)
from app.models.common import ErrorResponse
from app.auth import require_auth
from app.services.calculators import (
calcular_financiamiento,
FINANCIAMIENTO_PLANES,
PLANES_BUCEFALO,
)
router = APIRouter(prefix="/financiamiento", tags=["Financiamiento"])
class PlanResponse(FinanciamientoResponse):
meses: int
tasa: float
comision: float
@router.get("/planes", response_model=list[PlanResponse])
async def get_planes(_auth: dict = Depends(require_auth)):
return [
PlanResponse(
meses=p["meses"],
tasa=p["tasa"],
comision=p["comision"],
**calcular_financiamiento(1000, p["meses"], p["tasa"], p["comision"]),
)
for p in sorted(FINANCIAMIENTO_PLANES, key=lambda x: x["meses"])
]
@router.post(
"/calcular",
response_model=FinanciamientoResponse,
responses={400: {"model": ErrorResponse}},
)
async def calcular(
body: FinanciamientoRequest,
_auth: dict = Depends(require_auth),
):
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == body.meses), None)
if not plan:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"No financing plan available for {body.meses} months",
)
if body.monto < plan["montoMinimo"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Minimum amount for {body.meses} months is ${plan['montoMinimo']}",
)
result = calcular_financiamiento(body.monto, body.meses, plan["tasa"], plan["comision"])
return FinanciamientoResponse(
**result,
meses=body.meses,
tasa=plan["tasa"],
comision=plan["comision"],
)
@router.get(
"/simulacion/{cotizacion_id}",
response_model=list[FinanciamientoResponse],
responses={404: {"model": ErrorResponse}},
)
async def simulacion(
cotizacion_id: str,
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
cot = await db.fetchrow(
'SELECT id FROM "Cotizacion" WHERE id = $1', cotizacion_id
)
if not cot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cotización not found",
)
rows = await db.fetch(
'SELECT precio FROM "ServicioCotizado" WHERE "cotizacionId" = $1 AND seleccionado = true',
cotizacion_id,
)
total = sum(float(r["precio"]) for r in rows)
plan_buc = await db.fetchrow(
'SELECT precio FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
cotizacion_id,
)
if plan_buc:
total += float(plan_buc["precio"])
if total <= 0:
return []
results = []
for plan in FINANCIAMIENTO_PLANES:
if total < plan["montoMinimo"]:
continue
result = calcular_financiamiento(total, plan["meses"], plan["tasa"], plan["comision"])
results.append(
FinanciamientoResponse(
**result,
meses=plan["meses"],
tasa=plan["tasa"],
comision=plan["comision"],
)
)
return results
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from fastapi import APIRouter
from app.database import get_pool
from app.models.common import ErrorResponse, HealthResponse, APIInfoResponse
router = APIRouter(tags=["Health"])
@router.get("/health", response_model=HealthResponse)
async def health_check():
pool = await get_pool()
try:
async with pool.acquire() as conn:
await conn.execute("SELECT 1")
db_status = "connected"
except Exception:
db_status = "disconnected"
return HealthResponse(
status="ok",
timestamp=datetime.now(timezone.utc).isoformat(),
database=db_status,
)
@router.get("/api-info", response_model=APIInfoResponse)
async def api_info():
return APIInfoResponse(
name="Cotizador E3 API",
version="1.0.0",
description="API para el sistema de cotizaciones de Consultoría E3",
capabilities=[
"Gestión de cotizaciones",
"Catálogo de servicios",
"Planes Bucéfalo CRM",
"Exportación PDF/Excel",
"Autenticación JWT",
"Financiamiento Openpay",
],
)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import csv
import io
from fastapi import APIRouter, Depends, File, UploadFile
from asyncpg import Connection
from app.dependencies import get_db
from app.auth import require_auth
router = APIRouter(prefix="/import", tags=["Import"])
REQUIRED_COLUMNS = {"nombre", "fase", "tipoPago", "precioBase", "categoria"}
@router.post("/catalogo")
async def import_catalogo_csv(
file: UploadFile = File(...),
_auth: dict = Depends(require_auth),
db: Connection = Depends(get_db),
):
content = await file.read()
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
if not reader.fieldnames or not REQUIRED_COLUMNS.issubset(set(reader.fieldnames)):
missing = REQUIRED_COLUMNS - set(reader.fieldnames or [])
return {
"creados": 0,
"omitidos": 0,
"errores": [f"Missing required columns: {', '.join(missing)}"],
}
categorias = await db.fetch('SELECT id, nombre FROM "Categoria" WHERE activo = true')
cat_map = {r["nombre"]: r["id"] for r in categorias}
existing = await db.fetch('SELECT nombre FROM "ServicioCatalogo" WHERE activo = true')
existing_names = {r["nombre"] for r in existing}
creados = 0
omitidos = 0
errores: list[str] = []
async with db.transaction():
for i, row in enumerate(reader, start=2):
nombre = (row.get("nombre") or "").strip()
if not nombre:
omitidos += 1
continue
if nombre in existing_names:
omitidos += 1
continue
cat_nombre = (row.get("categoria") or "").strip()
cat_id = cat_map.get(cat_nombre)
if not cat_id:
errores.append(f"Row {i}: category '{cat_nombre}' not found")
continue
try:
fase = int(row.get("fase", "0"))
if fase not in (0, 1, 2, 3):
raise ValueError
except (ValueError, TypeError):
errores.append(f"Row {i}: invalid fase '{row.get('fase')}'")
continue
tipo_pago = (row.get("tipoPago") or "").strip()
if tipo_pago not in ("unico", "mensual"):
errores.append(f"Row {i}: invalid tipoPago '{tipo_pago}'")
continue
try:
precio = float(row.get("precioBase", "0"))
except (ValueError, TypeError):
errores.append(f"Row {i}: invalid precioBase '{row.get('precioBase')}'")
continue
tiempo = (row.get("tiempoEntrega") or "7 - 14 dias").strip()
variante = (row.get("variante") or "").strip() or None
try:
orden = int(row.get("orden", "0"))
except (ValueError, TypeError):
orden = 0
entregables_raw = (row.get("entregablesDefault") or "").strip()
entregables = [e.strip() for e in entregables_raw.split(";") if e.strip()] if entregables_raw else []
await db.execute(
"""
INSERT INTO "ServicioCatalogo"
(id, nombre, descripcion, fase, "tipoPago", "precioBase",
"tiempoEntrega", "entregablesDefault", "categoriaId",
variante, orden, activo, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true, now(), now())
""",
nombre,
(row.get("descripcion") or "").strip() or None,
fase,
tipo_pago,
precio,
tiempo,
entregables if entregables else None,
cat_id,
variante,
orden,
)
existing_names.add(nombre)
creados += 1
return {"creados": creados, "omitidos": omitidos, "errores": errores}
+425
View File
@@ -0,0 +1,425 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from app.database import get_pool
from app.dependencies import get_db
from app.auth import require_auth
from app.models.paquete import (
PaqueteCreate,
PaqueteUpdate,
PaqueteResponse,
FasePaqueteResponse,
ManageAction,
)
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"]
fases_rows = await conn.fetch(
'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"'
' FROM "FasePaquete" WHERE "paqueteId" = $1 ORDER BY orden ASC',
paquete_id,
)
fases: list[dict] = []
for fase in fases_rows:
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'
' 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"],
)
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,
},
})
fases.append({
"id": fase["id"],
"paqueteId": fase["paqueteId"],
"nombre": fase["nombre"],
"orden": fase["orden"],
"createdAt": fase["createdAt"],
"updatedAt": fase["updatedAt"],
"servicios": servicios,
})
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,
}
@router.get("", response_model=list[PaqueteResponse])
async def list_paquetes(
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
rows = await conn.fetch(
'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
@router.post(
"",
response_model=PaqueteResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_paquete(
body: PaqueteCreate,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
now = datetime.now(timezone.utc)
paquete_row = await conn.fetchrow(
'INSERT INTO "Paquete" (id, nombre, descripcion, "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $3)"
' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"',
body.nombre,
body.descripcion,
now,
)
paquete_id = paquete_row["id"]
for fase in body.fases:
await conn.execute(
'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)",
paquete_id,
fase.nombre,
fase.orden,
now,
)
return await _build_paquete_response(conn, dict(paquete_row))
@router.get(
"/{paquete_id}",
response_model=PaqueteResponse,
responses={404: {"model": ErrorResponse}},
)
async def get_paquete(
paquete_id: str,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
row = await conn.fetchrow(
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
' FROM "Paquete" WHERE id = $1',
paquete_id,
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Paquete no encontrado",
)
return await _build_paquete_response(conn, dict(row))
@router.put(
"/{paquete_id}",
response_model=PaqueteResponse,
responses={404: {"model": ErrorResponse}},
)
async def update_paquete(
paquete_id: str,
body: PaqueteUpdate,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
existing = await conn.fetchrow(
'SELECT id FROM "Paquete" WHERE id = $1', paquete_id
)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Paquete no encontrado",
)
updates: list[str] = []
params: list = []
idx = 1
if body.nombre is not None:
updates.append(f"nombre = ${idx}")
params.append(body.nombre)
idx += 1
if body.descripcion is not None:
updates.append(f"descripcion = ${idx}")
params.append(body.descripcion)
idx += 1
if body.activo is not None:
updates.append(f"activo = ${idx}")
params.append(body.activo)
idx += 1
if not updates:
row = await conn.fetchrow(
'SELECT id, nombre, descripcion, activo, "createdAt", "updatedAt"'
' FROM "Paquete" WHERE id = $1',
paquete_id,
)
return await _build_paquete_response(conn, dict(row))
updates.append(f'"updatedAt" = ${idx}')
params.append(datetime.now(timezone.utc))
idx += 1
params.append(paquete_id)
row = await conn.fetchrow(
f'UPDATE "Paquete" SET {", ".join(updates)} WHERE id = ${idx}'
' RETURNING id, nombre, descripcion, activo, "createdAt", "updatedAt"',
*params,
)
return await _build_paquete_response(conn, dict(row))
@router.delete(
"/{paquete_id}",
response_model=OkResponse,
responses={404: {"model": ErrorResponse}},
)
async def delete_paquete(
paquete_id: str,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
result = await conn.execute(
'DELETE FROM "Paquete" WHERE id = $1', paquete_id
)
if result == "DELETE 0":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Paquete no encontrado",
)
return OkResponse(ok=True)
@router.post(
"/{paquete_id}/manage",
responses={400: {"model": ErrorResponse}, 404: {"model": ErrorResponse}},
)
async def manage_paquete(
paquete_id: str,
body: ManageAction,
_auth: dict = Depends(require_auth),
conn=Depends(get_db),
):
paquete = await conn.fetchrow(
'SELECT id FROM "Paquete" WHERE id = $1', paquete_id
)
if not paquete:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Paquete no encontrado",
)
now = datetime.now(timezone.utc)
action = body.action
if action == "addFase":
if not body.nombre:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El campo 'nombre' es requerido para addFase",
)
row = await conn.fetchrow(
'INSERT INTO "FasePaquete" (id, "paqueteId", nombre, orden, "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $4, $4)"
' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"',
paquete_id,
body.nombre,
body.orden or 0,
now,
)
return dict(row), status.HTTP_201_CREATED
if action == "updateFase":
if not body.faseId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El campo 'faseId' es requerido para updateFase",
)
updates: list[str] = []
params: list = []
idx = 1
if body.nombre is not None:
updates.append(f"nombre = ${idx}")
params.append(body.nombre)
idx += 1
if body.orden is not None:
updates.append(f"orden = ${idx}")
params.append(body.orden)
idx += 1
if not updates:
row = await conn.fetchrow(
'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"'
' FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2',
body.faseId,
paquete_id,
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Fase no encontrada",
)
return dict(row)
updates.append(f'"updatedAt" = ${idx}')
params.append(now)
idx += 1
params.extend([body.faseId, paquete_id])
row = await conn.fetchrow(
f'UPDATE "FasePaquete" SET {", ".join(updates)}'
f" WHERE id = ${idx} AND \"paqueteId\" = ${idx + 1}"
' RETURNING id, "paqueteId", nombre, orden, "createdAt", "updatedAt"',
*params,
)
if not row:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Fase no encontrada",
)
return dict(row)
if action == "deleteFase":
if not body.faseId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El campo 'faseId' es requerido para deleteFase",
)
result = await conn.execute(
'DELETE FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2',
body.faseId,
paquete_id,
)
if result == "DELETE 0":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Fase no encontrada",
)
return {"ok": True}
if action == "addServicio":
if not body.servicioCatalogoId or not body.fasePaqueteId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para addServicio",
)
fase = await conn.fetchrow(
'SELECT id FROM "FasePaquete" WHERE id = $1 AND "paqueteId" = $2',
body.fasePaqueteId,
paquete_id,
)
if not fase:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Fase no encontrada en este paquete",
)
try:
row = await conn.fetchrow(
'INSERT INTO "ServicioPaquete" (id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt")'
" VALUES (gen_random_uuid(), $1, $2, $3, $3)"
' RETURNING id, "servicioCatalogoId", "fasePaqueteId", "createdAt", "updatedAt"',
body.servicioCatalogoId,
body.fasePaqueteId,
now,
)
except Exception as e:
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Este servicio ya está asignado a esta fase",
)
raise
return dict(row), status.HTTP_201_CREATED
if action == "removeServicio":
if not body.servicioCatalogoId or not body.fasePaqueteId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Los campos 'servicioCatalogoId' y 'fasePaqueteId' son requeridos para removeServicio",
)
result = await conn.execute(
'DELETE FROM "ServicioPaquete"'
' WHERE "servicioCatalogoId" = $1 AND "fasePaqueteId" = $2',
body.servicioCatalogoId,
body.fasePaqueteId,
)
if result == "DELETE 0":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Servicio no encontrado en esta fase",
)
return {"ok": True}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Acción desconocida: '{action}'. Acciones válidas: addFase, updateFase, deleteFase, addServicio, removeServicio",
)