- Recupera el trabajo revertido (doble propuesta, retainer, niveles, API Python) desde el stash de GitHub Desktop - Dockerfile multi-stage para Next.js (migraciones automáticas al arrancar, seed opcional via RUN_SEED) - docker-compose.coolify.yml: postgres + web + api con healthchecks y SERVICE_FQDN_* - Endpoint público /api/health (verifica BD) para healthchecks - seed.ts parametrizado: conexión DB_* y credenciales SEED_* por entorno (sin passwords hardcodeadas) - .env.example, .dockerignore, uvicorn con --proxy-headers - Limpieza: .xlsx y __pycache__ fuera del repo Co-Authored-By: Claude Fable 5 <[email protected]>
645 lines
24 KiB
Python
645 lines
24 KiB
Python
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]
|
|
|
|
|
|
async def _insert_servicio_cotizado(conn, cotizacion_id: str, svc, bucefalo_servicio_id, now, es_doble: bool = False) -> None:
|
|
"""Inserta un ServicioCotizado desde un ServicioCotizadoInput, manejando
|
|
partidas personalizadas (por horas / retainer) en paridad con el backend Next.js."""
|
|
es_personalizado = bool(getattr(svc, "esPersonalizado", False)) or svc.catalogoId.startswith("custom-")
|
|
|
|
if es_personalizado:
|
|
catalogo_id = None
|
|
elif svc.catalogoId.startswith("bucefalo-") and bucefalo_servicio_id:
|
|
catalogo_id = bucefalo_servicio_id
|
|
else:
|
|
catalogo_id = svc.catalogoId
|
|
|
|
modelo_cobro = svc.modeloCobro or ("horas" if es_personalizado else "fijo")
|
|
# Doble propuesta: por defecto "ambas"; None en cotizaciones normales.
|
|
opcion = (svc.opcion or "ambas") if es_doble else None
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO "ServicioCotizado"
|
|
(id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado",
|
|
horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas",
|
|
opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas,
|
|
seleccionado, "createdAt", "updatedAt")
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$18)
|
|
""",
|
|
_cuid(),
|
|
cotizacion_id,
|
|
catalogo_id,
|
|
svc.nombre if es_personalizado else None,
|
|
es_personalizado,
|
|
svc.horas if es_personalizado else None,
|
|
svc.tarifaHora if es_personalizado else None,
|
|
modelo_cobro,
|
|
svc.montoMinimo if es_personalizado else None,
|
|
svc.horasIncluidas if es_personalizado else None,
|
|
opcion,
|
|
svc.fase,
|
|
svc.tipoPago,
|
|
svc.precio,
|
|
svc.tiempoEntrega,
|
|
json.dumps(svc.entregables),
|
|
None,
|
|
now,
|
|
)
|
|
|
|
|
|
def _parse_json(val):
|
|
"""asyncpg devuelve columnas jsonb como str; las parsea a dict/list."""
|
|
if isinstance(val, str):
|
|
try:
|
|
return json.loads(val)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
return val
|
|
|
|
|
|
def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict:
|
|
d = dict(cot)
|
|
if "opcionesMetadata" in d:
|
|
d["opcionesMetadata"] = _parse_json(d["opcionesMetadata"])
|
|
d["cliente"] = dict(cliente) if cliente else None
|
|
d["asesor"] = dict(asesor) if asesor else None
|
|
d["servicios"] = [dict(s) for s in servicios] if servicios else []
|
|
d["planBucefalo"] = dict(plan) if plan else None
|
|
return d
|
|
|
|
|
|
# LEFT JOIN: los servicios personalizados no tienen servicioCatalogoId y un JOIN
|
|
# interno los dejaría fuera de la respuesta. COALESCE conserva el nombre custom.
|
|
_SERVICIOS_SQL = """
|
|
SELECT sc.*, sc."servicioCatalogoId" as "catalogoId",
|
|
COALESCE(s.nombre, sc.nombre) AS nombre, s.descripcion, s."categoriaId"
|
|
FROM "ServicioCotizado" sc
|
|
LEFT JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId"
|
|
WHERE sc."cotizacionId" = {}
|
|
ORDER BY sc.fase, sc.opcion, sc.id
|
|
"""
|
|
|
|
|
|
async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None:
|
|
cot = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
|
|
if not cot:
|
|
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(_SERVICIOS_SQL.format("$1"), cot_id)
|
|
plan = await conn.fetchrow('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id)
|
|
|
|
return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan)
|
|
|
|
|
|
async def _fetch_cotizaciones_full(conn, cot_rows) -> list[dict]:
|
|
"""Versión batch de _fetch_cotizacion_full: 4 queries en total para toda la
|
|
página en lugar de 4 por cotización (evita N+1 en el listado)."""
|
|
if not cot_rows:
|
|
return []
|
|
|
|
cot_ids = [r["id"] for r in cot_rows]
|
|
cliente_ids = list({r["clienteId"] for r in cot_rows})
|
|
asesor_ids = list({r["asesorId"] for r in cot_rows})
|
|
|
|
clientes = await conn.fetch('SELECT * FROM "Cliente" WHERE id = ANY($1::text[])', cliente_ids)
|
|
asesores = await conn.fetch('SELECT id, email, name, role FROM "User" WHERE id = ANY($1::text[])', asesor_ids)
|
|
servicios = await conn.fetch(_SERVICIOS_SQL.format("ANY($1::text[])"), cot_ids)
|
|
planes = await conn.fetch('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = ANY($1::text[])', cot_ids)
|
|
|
|
clientes_map = {c["id"]: c for c in clientes}
|
|
asesores_map = {a["id"]: a for a in asesores}
|
|
planes_map = {p["cotizacionId"]: p for p in planes}
|
|
servicios_map: dict[str, list] = {}
|
|
for s in servicios:
|
|
servicios_map.setdefault(s["cotizacionId"], []).append(s)
|
|
|
|
return [
|
|
_build_cotizacion_dict(
|
|
cot,
|
|
clientes_map.get(cot["clienteId"]),
|
|
asesores_map.get(cot["asesorId"]),
|
|
servicios_map.get(cot["id"], []),
|
|
planes_map.get(cot["id"]),
|
|
)
|
|
for cot in cot_rows
|
|
]
|
|
|
|
|
|
async def _resolve_bucefalo_servicio(conn) -> str | None:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
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 = await _fetch_cotizaciones_full(pool, rows)
|
|
|
|
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)
|
|
opciones_json = None
|
|
if body.esDoble and body.opciones:
|
|
opciones_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()})
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO "Cotizacion"
|
|
(id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
|
|
estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata",
|
|
observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16)
|
|
""",
|
|
cot_id,
|
|
body.numero,
|
|
body.fecha,
|
|
body.vigencia,
|
|
body.moneda,
|
|
body.tipoCambio,
|
|
body.proyecto,
|
|
body.esquemaPago,
|
|
body.incluirBonos,
|
|
body.incluirFinanciamiento,
|
|
body.esDoble,
|
|
opciones_json,
|
|
body.observaciones or None,
|
|
cliente_id,
|
|
body.asesorId,
|
|
now,
|
|
)
|
|
|
|
bucefalo_servicio_id = await _resolve_bucefalo_servicio(conn)
|
|
|
|
for svc in body.servicios:
|
|
await _insert_servicio_cotizado(conn, cot_id, svc, bucefalo_servicio_id, now, body.esDoble)
|
|
|
|
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)
|
|
result = dict(row)
|
|
result["opcionesMetadata"] = _parse_json(result.get("opcionesMetadata"))
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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("esDoble", '"esDoble"', body.esDoble)
|
|
_add("observaciones", "observaciones", body.observaciones)
|
|
_add("estado", "estado", body.estado)
|
|
|
|
es_doble_final = body.esDoble if body.esDoble is not None else existing["esDoble"]
|
|
if body.esDoble is not None:
|
|
# opcionesMetadata se sincroniza con esDoble: dict (json) si doble, NULL si no.
|
|
meta_json = None
|
|
if es_doble_final and body.opciones:
|
|
meta_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()})
|
|
updates.append(f'"opcionesMetadata" = ${idx}')
|
|
params.append(meta_json)
|
|
idx += 1
|
|
|
|
if cliente_id != existing["clienteId"]:
|
|
updates.append(f'"clienteId" = ${idx}')
|
|
params.append(cliente_id)
|
|
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:
|
|
await _insert_servicio_cotizado(conn, cotizacion_id, svc, bucefalo_servicio_id, now, es_doble_final)
|
|
|
|
if body.planBucefalo is not None:
|
|
existing_plan = await conn.fetchrow(
|
|
'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", "esDoble", "opcionesMetadata",
|
|
observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16)
|
|
""",
|
|
new_id,
|
|
new_numero,
|
|
original["fecha"],
|
|
original["vigencia"],
|
|
original["moneda"],
|
|
original["tipoCambio"],
|
|
original["proyecto"],
|
|
original["esquemaPago"],
|
|
original["incluirBonos"],
|
|
original["incluirFinanciamiento"],
|
|
original["esDoble"],
|
|
original["opcionesMetadata"],
|
|
original["observaciones"],
|
|
original["clienteId"],
|
|
original["asesorId"],
|
|
now,
|
|
)
|
|
|
|
for svc in servicios:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO "ServicioCotizado"
|
|
(id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado",
|
|
horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas",
|
|
opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas,
|
|
seleccionado, "createdAt", "updatedAt")
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19)
|
|
""",
|
|
_cuid(),
|
|
new_id,
|
|
svc["servicioCatalogoId"],
|
|
svc["nombre"],
|
|
svc["esPersonalizado"],
|
|
svc["horas"],
|
|
svc["tarifaHora"],
|
|
svc["modeloCobro"],
|
|
svc["montoMinimo"],
|
|
svc["horasIncluidas"],
|
|
svc["opcion"],
|
|
svc["fase"],
|
|
svc["tipoPago"],
|
|
svc["precio"],
|
|
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
|