1
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
)
|
||||
Reference in New Issue
Block a user