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