1
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import Header, HTTPException, status
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "iat": datetime.now(timezone.utc)})
|
||||
return jwt.encode(to_encode, settings.JWT_SECRET, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.JWT_SECRET, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_api_key(x_api_key: str | None = Header(None)) -> str | None:
|
||||
if x_api_key and x_api_key == settings.API_KEY:
|
||||
return x_api_key
|
||||
return None
|
||||
|
||||
|
||||
async def require_auth(
|
||||
authorization: str | None = Header(None),
|
||||
x_api_key: str | None = Header(None),
|
||||
) -> dict:
|
||||
if x_api_key and x_api_key == settings.API_KEY:
|
||||
return {"auth_type": "api_key"}
|
||||
|
||||
if authorization:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer" and token:
|
||||
payload = decode_token(token)
|
||||
if payload:
|
||||
return {"auth_type": "jwt", **payload}
|
||||
if x_api_key == settings.API_KEY:
|
||||
return {"auth_type": "api_key"}
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or missing authentication credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DB_HOST: str = "localhost"
|
||||
DB_PORT: int = 5432
|
||||
DB_USER: str = "postgres"
|
||||
DB_PASSWORD: str = "postgres"
|
||||
DB_NAME: str = "cotizador_e3"
|
||||
API_KEY: str = "change-me-to-a-secure-random-key"
|
||||
JWT_SECRET: str = "change-me-to-a-secure-jwt-secret"
|
||||
API_HOST: str = "0.0.0.0"
|
||||
API_PORT: int = 8000
|
||||
|
||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncpg
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
global pool
|
||||
pool = await asyncpg.create_pool(
|
||||
host=settings.DB_HOST,
|
||||
port=settings.DB_PORT,
|
||||
user=settings.DB_USER,
|
||||
password=settings.DB_PASSWORD,
|
||||
database=settings.DB_NAME,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
global pool
|
||||
if pool:
|
||||
await pool.close()
|
||||
pool = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
if pool is None:
|
||||
raise RuntimeError("Database pool not initialized. Call init_db() first.")
|
||||
return pool
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Query
|
||||
|
||||
from app.database import get_pool
|
||||
|
||||
|
||||
async def get_db():
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
yield conn
|
||||
|
||||
|
||||
class PaginationParams:
|
||||
def __init__(
|
||||
self,
|
||||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
):
|
||||
self.page = page
|
||||
self.limit = limit
|
||||
self.offset = (page - 1) * limit
|
||||
|
||||
|
||||
def parse_date(date_str: str | None):
|
||||
if not date_str:
|
||||
return None
|
||||
from datetime import datetime
|
||||
try:
|
||||
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return None
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,446 @@
|
||||
"""MCP Server for the Cotizador E3 API.
|
||||
|
||||
Provides MCP tools and resources for AI agents (OpenClaw, Claude, ChatGPT, etc.)
|
||||
to interact with the quotation system.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.types import Resource, TextContent, Tool
|
||||
|
||||
from app.database import get_pool
|
||||
from app.mcp.tools import RESOURCES, TOOLS
|
||||
from app.services.calculators import (
|
||||
BONOS,
|
||||
PLANES_BUCEFALO,
|
||||
calcular_financiamiento,
|
||||
bucefalo_precio,
|
||||
)
|
||||
|
||||
server = Server("cotizador-e3")
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools() -> list[Tool]:
|
||||
return [
|
||||
Tool(
|
||||
name=t["name"],
|
||||
description=t["description"],
|
||||
inputSchema=t["inputSchema"],
|
||||
)
|
||||
for t in TOOLS
|
||||
]
|
||||
|
||||
|
||||
@server.list_resources()
|
||||
async def list_resources() -> list[Resource]:
|
||||
return [
|
||||
Resource(
|
||||
uri=r["uri"],
|
||||
name=r["name"],
|
||||
description=r["description"],
|
||||
mimeType=r["mimeType"],
|
||||
)
|
||||
for r in RESOURCES
|
||||
]
|
||||
|
||||
|
||||
@server.read_resource()
|
||||
async def read_resource(uri: str) -> str:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
if uri == "cotizador://servicios":
|
||||
rows = await conn.fetch(
|
||||
"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
|
||||
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
|
||||
s.variante, s.activo, s.orden,
|
||||
c.nombre as categoria_nombre
|
||||
FROM "ServicioCatalogo" s
|
||||
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
|
||||
WHERE s.activo = true
|
||||
ORDER BY s.fase ASC, s.orden ASC"""
|
||||
)
|
||||
return json.dumps([dict(r) for r in rows], default=str)
|
||||
|
||||
elif uri == "cotizador://categorias":
|
||||
rows = await conn.fetch(
|
||||
'SELECT id, nombre, descripcion, color, activo, orden FROM "Categoria" ORDER BY orden ASC'
|
||||
)
|
||||
return json.dumps([dict(r) for r in rows], default=str)
|
||||
|
||||
elif uri == "cotizador://configuracion":
|
||||
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
|
||||
return json.dumps({r["clave"]: r["valor"] for r in rows})
|
||||
|
||||
return json.dumps({"error": f"Unknown resource: {uri}"})
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await _handle_tool(conn, name, arguments)
|
||||
return [TextContent(type="text", text=json.dumps(result, default=str, ensure_ascii=False))]
|
||||
|
||||
|
||||
async def _handle_tool(conn, name: str, arguments: dict) -> dict:
|
||||
if name == "buscar_servicios":
|
||||
return await _buscar_servicios(conn, arguments)
|
||||
elif name == "crear_cotizacion":
|
||||
return await _crear_cotizacion(conn, arguments)
|
||||
elif name == "obtener_cotizacion":
|
||||
return await _obtener_cotizacion(conn, arguments)
|
||||
elif name == "listar_cotizaciones":
|
||||
return await _listar_cotizaciones(conn, arguments)
|
||||
elif name == "cambiar_estado_cotizacion":
|
||||
return await _cambiar_estado(conn, arguments)
|
||||
elif name == "actualizar_precio_servicio":
|
||||
return await _actualizar_precio(conn, arguments)
|
||||
elif name == "duplicar_cotizacion":
|
||||
return await _duplicar_cotizacion(conn, arguments)
|
||||
elif name == "calcular_financiamiento":
|
||||
return await _calcular_financiamiento(arguments)
|
||||
elif name == "generar_pdf_cotizacion":
|
||||
return await _generar_pdf(conn, arguments)
|
||||
elif name == "obtener_configuracion":
|
||||
return await _obtener_configuracion(conn)
|
||||
elif name == "listar_bonos":
|
||||
return {"bonos": BONOS}
|
||||
elif name == "listar_planes_bucefalo":
|
||||
return {"planes": PLANES_BUCEFALO}
|
||||
else:
|
||||
return {"error": f"Unknown tool: {name}"}
|
||||
|
||||
|
||||
async def _buscar_servicios(conn, args: dict) -> dict:
|
||||
conditions = ['s.activo = true']
|
||||
params = []
|
||||
idx = 1
|
||||
|
||||
if args.get("fase") is not None:
|
||||
conditions.append(f's.fase = ${idx}')
|
||||
params.append(args["fase"])
|
||||
idx += 1
|
||||
if args.get("tipo_pago"):
|
||||
conditions.append(f's."tipoPago" = ${idx}')
|
||||
params.append(args["tipo_pago"])
|
||||
idx += 1
|
||||
if args.get("categoria"):
|
||||
conditions.append(f'LOWER(c.nombre) = LOWER(${idx})')
|
||||
params.append(args["categoria"])
|
||||
idx += 1
|
||||
if args.get("busqueda"):
|
||||
conditions.append(f'(LOWER(s.nombre) LIKE LOWER(${idx}) OR LOWER(s.descripcion) LIKE LOWER(${idx}))')
|
||||
params.append(f"%{args['busqueda']}%")
|
||||
idx += 1
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
query = f"""SELECT s.id, s.nombre, s.descripcion, s.fase, s."tipoPago",
|
||||
s."precioBase", s."tiempoEntrega", s."entregablesDefault",
|
||||
s.variante, c.nombre as categoria
|
||||
FROM "ServicioCatalogo" s
|
||||
LEFT JOIN "Categoria" c ON s."categoriaId" = c.id
|
||||
WHERE {where}
|
||||
ORDER BY s.fase ASC, s.orden ASC"""
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
servicios = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if d.get("entregablesDefault") and isinstance(d["entregablesDefault"], str):
|
||||
try:
|
||||
d["entregablesDefault"] = json.loads(d["entregablesDefault"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
servicios.append(d)
|
||||
|
||||
return {"servicios": servicios, "total": len(servicios)}
|
||||
|
||||
|
||||
async def _crear_cotizacion(conn, args: dict) -> dict:
|
||||
cliente_data = args["cliente"]
|
||||
servicios_data = args["servicios"]
|
||||
plan_bucefalo = args.get("plan_bucefalo")
|
||||
moneda = args.get("moneda", "MXN")
|
||||
proyecto = args.get("proyecto", "MKT Digital")
|
||||
esquema = args.get("esquema_pago", "Pago Unico/Mensual")
|
||||
|
||||
cliente = await conn.fetchrow(
|
||||
'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2',
|
||||
cliente_data["nombre"],
|
||||
cliente_data.get("empresa", ""),
|
||||
)
|
||||
if not cliente:
|
||||
cliente = await conn.fetchrow(
|
||||
'INSERT INTO "Cliente" (id, nombre, empresa, email, telefono, "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW()) RETURNING id',
|
||||
cliente_data["nombre"],
|
||||
cliente_data.get("empresa", ""),
|
||||
cliente_data.get("email", ""),
|
||||
cliente_data.get("telefono", ""),
|
||||
)
|
||||
|
||||
cliente_id = cliente["id"]
|
||||
|
||||
now = datetime.now()
|
||||
numero = f"UJ{str(now.year)[-2:]}{now.month:02d}AGENT001"
|
||||
|
||||
from app.services.calculators import calcular_vigencia
|
||||
vigencia = calcular_vigencia(now)
|
||||
|
||||
async with conn.transaction():
|
||||
cot = await conn.fetchrow(
|
||||
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
|
||||
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, '', $7, $8, NOW(), NOW())
|
||||
RETURNING id, numero""",
|
||||
numero, now, vigencia, moneda, proyecto, esquema, cliente_id, "agent",
|
||||
)
|
||||
cot_id = cot["id"]
|
||||
|
||||
for srv in servicios_data:
|
||||
catalogo_id = srv["servicio_id"]
|
||||
cat_row = await conn.fetchrow(
|
||||
'SELECT id, "precioBase", "tiempoEntrega", "entregablesDefault", fase, "tipoPago" FROM "ServicioCatalogo" WHERE id = $1',
|
||||
catalogo_id,
|
||||
)
|
||||
if not cat_row:
|
||||
continue
|
||||
|
||||
precio = srv.get("precio_personalizado") or cat_row["precioBase"]
|
||||
entregables = cat_row["entregablesDefault"]
|
||||
if isinstance(entregables, str):
|
||||
try:
|
||||
entregables = json.loads(entregables)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
entregables = []
|
||||
|
||||
await conn.fetchrow(
|
||||
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
|
||||
precio, "tiempoEntrega", entregables, seleccionado, "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, true, NOW(), NOW())""",
|
||||
cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"],
|
||||
precio, cat_row["tiempoEntrega"], json.dumps(entregables or []),
|
||||
)
|
||||
|
||||
if plan_bucefalo:
|
||||
nivel = plan_bucefalo if isinstance(plan_bucefalo, str) else plan_bucefalo.get("nivel", "basico")
|
||||
precio_bp = bucefalo_precio(nivel)
|
||||
await conn.fetchrow(
|
||||
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, true, NOW(), NOW())""",
|
||||
cot_id, nivel, precio_bp,
|
||||
)
|
||||
|
||||
return {"cotizacion_id": str(cot_id), "numero": numero, "estado": "borrador", "cliente_id": str(cliente_id)}
|
||||
|
||||
|
||||
async def _obtener_cotizacion(conn, args: dict) -> dict:
|
||||
cot = await conn.fetchrow(
|
||||
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
|
||||
cl.email as cliente_email, cl.telefono as cliente_telefono
|
||||
FROM "Cotizacion" c
|
||||
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
||||
WHERE c.id = $1""",
|
||||
args["cotizacion_id"],
|
||||
)
|
||||
if not cot:
|
||||
return {"error": "Cotización no encontrada"}
|
||||
|
||||
servicios = await conn.fetch(
|
||||
"""SELECT sc.*, s.nombre as servicio_nombre, s.fase as servicio_fase, s."tipoPago" as "servicio_tipoPago"
|
||||
FROM "ServicioCotizado" sc
|
||||
LEFT JOIN "ServicioCatalogo" s ON sc."servicioCatalogoId" = s.id
|
||||
WHERE sc."cotizacionId" = $1""",
|
||||
args["cotizacion_id"],
|
||||
)
|
||||
|
||||
plan = await conn.fetchrow(
|
||||
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
|
||||
args["cotizacion_id"],
|
||||
)
|
||||
|
||||
result = dict(cot)
|
||||
result["cliente"] = {
|
||||
"nombre": cot["cliente_nombre"],
|
||||
"empresa": cot["cliente_empresa"],
|
||||
"email": cot["cliente_email"],
|
||||
"telefono": cot["cliente_telefono"],
|
||||
}
|
||||
result["servicios"] = [dict(s) for s in servicios]
|
||||
result["planBucefalo"] = dict(plan) if plan else None
|
||||
|
||||
for k in ["cliente_nombre", "cliente_empresa", "cliente_email", "cliente_telefono"]:
|
||||
result.pop(k, None)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _listar_cotizaciones(conn, args: dict) -> dict:
|
||||
conditions = []
|
||||
params = []
|
||||
idx = 1
|
||||
|
||||
if args.get("estado"):
|
||||
conditions.append(f'c.estado = ${idx}')
|
||||
params.append(args["estado"])
|
||||
idx += 1
|
||||
if args.get("cliente_nombre"):
|
||||
conditions.append(f'LOWER(cl.nombre) LIKE LOWER(${idx})')
|
||||
params.append(f"%{args['cliente_nombre']}%")
|
||||
idx += 1
|
||||
if args.get("busqueda"):
|
||||
conditions.append(
|
||||
f'(LOWER(c.numero) LIKE LOWER(${idx}) OR LOWER(c.proyecto) LIKE LOWER(${idx}) OR LOWER(cl.nombre) LIKE LOWER(${idx}))'
|
||||
)
|
||||
params.append(f"%{args['busqueda']}%")
|
||||
idx += 1
|
||||
|
||||
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||
query = f"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.estado, c.proyecto,
|
||||
c.moneda, c."esquemaPago",
|
||||
cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
|
||||
FROM "Cotizacion" c
|
||||
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
||||
{where}
|
||||
ORDER BY c."createdAt" DESC
|
||||
LIMIT 50"""
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
cotizaciones = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["cliente"] = {"nombre": d.pop("cliente_nombre"), "empresa": d.pop("cliente_empresa")}
|
||||
cotizaciones.append(d)
|
||||
|
||||
return {"cotizaciones": cotizaciones, "total": len(cotizaciones)}
|
||||
|
||||
|
||||
async def _cambiar_estado(conn, args: dict) -> dict:
|
||||
cot_id = args["cotizacion_id"]
|
||||
estado = args["estado"]
|
||||
|
||||
cot = await conn.fetchrow('SELECT id FROM "Cotizacion" WHERE id = $1', cot_id)
|
||||
if not cot:
|
||||
return {"error": "Cotización no encontrada"}
|
||||
|
||||
await conn.execute('UPDATE "Cotizacion" SET estado = $1, "updatedAt" = NOW() WHERE id = $2', estado, cot_id)
|
||||
return {"ok": True, "cotizacion_id": cot_id, "nuevo_estado": estado}
|
||||
|
||||
|
||||
async def _actualizar_precio(conn, args: dict) -> dict:
|
||||
cot_id = args["cotizacion_id"]
|
||||
servicio_id = args["servicio_id"]
|
||||
nuevo_precio = args["nuevo_precio"]
|
||||
|
||||
srv = await conn.fetchrow(
|
||||
'SELECT id FROM "ServicioCotizado" WHERE id = $1 AND "cotizacionId" = $2',
|
||||
servicio_id, cot_id,
|
||||
)
|
||||
if not srv:
|
||||
return {"error": "Servicio no encontrado en esta cotización"}
|
||||
|
||||
await conn.execute(
|
||||
'UPDATE "ServicioCotizado" SET precio = $1, "updatedAt" = NOW() WHERE id = $2',
|
||||
nuevo_precio, servicio_id,
|
||||
)
|
||||
return {"ok": True, "servicio_id": servicio_id, "nuevo_precio": nuevo_precio}
|
||||
|
||||
|
||||
async def _duplicar_cotizacion(conn, args: dict) -> dict:
|
||||
cot_id = args["cotizacion_id"]
|
||||
|
||||
original = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id)
|
||||
if not original:
|
||||
return {"error": "Cotización no encontrada"}
|
||||
|
||||
now = datetime.now()
|
||||
new_numero = f"{original['numero']}-COPY"
|
||||
|
||||
async with conn.transaction():
|
||||
new_cot = await conn.fetchrow(
|
||||
"""INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago",
|
||||
estado, "incluirBonos", "incluirFinanciamiento", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, NOW(), NOW())
|
||||
RETURNING id, numero""",
|
||||
new_numero, now, original["vigencia"], original["moneda"], original["tipoCambio"],
|
||||
original["proyecto"], original["esquemaPago"], original["incluirBonos"],
|
||||
original["incluirFinanciamiento"], original["observaciones"],
|
||||
original["clienteId"], original["asesorId"],
|
||||
)
|
||||
new_id = new_cot["id"]
|
||||
|
||||
servicios = await conn.fetch(
|
||||
'SELECT * FROM "ServicioCotizado" WHERE "cotizacionId" = $1', cot_id
|
||||
)
|
||||
for s in servicios:
|
||||
await conn.fetchrow(
|
||||
"""INSERT INTO "ServicioCotizado" (id, "cotizacionId", "servicioCatalogoId", fase, "tipoPago",
|
||||
precio, "tiempoEntrega", entregables, notas, seleccionado, "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())""",
|
||||
new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"],
|
||||
s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["seleccionado"],
|
||||
)
|
||||
|
||||
plan = await conn.fetchrow(
|
||||
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id
|
||||
)
|
||||
if plan:
|
||||
await conn.fetchrow(
|
||||
"""INSERT INTO "PlanBucefaloCotizacion" (id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())""",
|
||||
new_id, plan["nivel"], plan["precio"], plan["seleccionado"],
|
||||
)
|
||||
|
||||
return {"cotizacion_id": str(new_id), "numero": new_numero, "estado": "borrador"}
|
||||
|
||||
|
||||
async def _calcular_financiamiento(args: dict) -> dict:
|
||||
monto = args["monto"]
|
||||
meses = args["meses"]
|
||||
|
||||
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
|
||||
if not plan:
|
||||
from app.services.calculators import FINANCIAMIENTO_PLANES
|
||||
plan = next((p for p in FINANCIAMIENTO_PLANES if p["meses"] == meses), None)
|
||||
|
||||
if not plan:
|
||||
return {"error": f"Plan de {meses} meses no disponible"}
|
||||
|
||||
result = calcular_financiamiento(monto, meses, plan["tasa"], plan["comision"])
|
||||
result["meses"] = meses
|
||||
result["tasa"] = plan["tasa"]
|
||||
result["comision"] = plan["comision"]
|
||||
return result
|
||||
|
||||
|
||||
async def _generar_pdf(conn, args: dict) -> dict:
|
||||
cot_id = args["cotizacion_id"]
|
||||
cot = await conn.fetchrow(
|
||||
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa
|
||||
FROM "Cotizacion" c
|
||||
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
||||
WHERE c.id = $1""",
|
||||
cot_id,
|
||||
)
|
||||
if not cot:
|
||||
return {"error": "Cotización no encontrada"}
|
||||
|
||||
return {
|
||||
"status": "pdf_generated",
|
||||
"cotizacion_id": cot_id,
|
||||
"numero": cot["numero"],
|
||||
"filename": f"{cot['cliente_nombre']} - {cot['numero']}.pdf",
|
||||
"message": "PDF generation will be implemented with reportlab",
|
||||
}
|
||||
|
||||
|
||||
async def _obtener_configuracion(conn) -> dict:
|
||||
rows = await conn.fetch('SELECT clave, valor FROM "Configuracion"')
|
||||
return {"config": {r["clave"]: r["valor"] for r in rows}}
|
||||
|
||||
|
||||
from app.services.calculators import FINANCIAMIENTO_PLANES
|
||||
@@ -0,0 +1,289 @@
|
||||
"""MCP (Model Context Protocol) tool definitions for the Cotizador API.
|
||||
|
||||
Each tool is designed to be semantically clear for AI agents like OpenClaw, Claude, and ChatGPT.
|
||||
"""
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "buscar_servicios",
|
||||
"description": (
|
||||
"Busca servicios del catálogo de marketing digital de Consultoría E3. "
|
||||
"Útil cuando el cliente pregunta por servicios disponibles, precios, o por fase del proyecto. "
|
||||
"Las fases son: 0=Auditoría (diagnóstico inicial), 1=Setup (infraestructura y configuración), "
|
||||
"2=Publicidad (anuncios y manejo de redes), 3=Contenido/SEO (producción de contenido y posicionamiento). "
|
||||
"Tipos de pago: 'unico' (pago único) o 'mensual' (recurso recurrente)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fase": {
|
||||
"type": "integer",
|
||||
"enum": [0, 1, 2, 3],
|
||||
"description": "Fase del proyecto: 0=Auditoría/Acompañamiento, 1=Setup/Infraestructura, 2=Publicidad/Manejo, 3=Contenido/SEO",
|
||||
},
|
||||
"tipo_pago": {
|
||||
"type": "string",
|
||||
"enum": ["unico", "mensual"],
|
||||
"description": "Tipo de pago: 'unico' para pago único, 'mensual' para recurrente",
|
||||
},
|
||||
"categoria": {
|
||||
"type": "string",
|
||||
"description": "Nombre de categoría: SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado",
|
||||
},
|
||||
"busqueda": {
|
||||
"type": "string",
|
||||
"description": "Texto libre para buscar en nombre y descripción del servicio",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "crear_cotizacion",
|
||||
"description": (
|
||||
"Crea una cotización completa de servicios de marketing digital para un cliente. "
|
||||
"El cliente se crea automáticamente si no existe (busca por nombre+empresa). "
|
||||
"Incluye servicios del catálogo con precios personalizables, plan CRM Bucefalo opcional, "
|
||||
"y configuración de moneda y esquema de pago. "
|
||||
"La cotización se crea en estado 'borrador'. "
|
||||
"Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cliente": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nombre": {"type": "string", "description": "Nombre completo del contacto"},
|
||||
"empresa": {"type": "string", "description": "Nombre de la empresa (opcional)"},
|
||||
"email": {"type": "string", "description": "Email de contacto"},
|
||||
"telefono": {"type": "string", "description": "Teléfono de contacto"},
|
||||
},
|
||||
"required": ["nombre"],
|
||||
},
|
||||
"servicios": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"servicio_id": {"type": "string", "description": "ID del servicio del catálogo (obtener con buscar_servicios)"},
|
||||
"precio_personalizado": {"type": "number", "description": "Precio personalizado (opcional, usa precio base si no se especifica)"},
|
||||
},
|
||||
"required": ["servicio_id"],
|
||||
},
|
||||
"description": "Lista de servicios a incluir en la cotización",
|
||||
},
|
||||
"plan_bucefalo": {
|
||||
"type": "string",
|
||||
"enum": ["basico", "estandar", "premium", "empresarial"],
|
||||
"description": "Nivel del plan CRM Bucefalo (opcional)",
|
||||
},
|
||||
"proyecto": {
|
||||
"type": "string",
|
||||
"description": "Nombre o descripción del proyecto (default: 'MKT Digital')",
|
||||
},
|
||||
"moneda": {
|
||||
"type": "string",
|
||||
"enum": ["MXN", "USD"],
|
||||
"description": "Moneda de la cotización (default: MXN)",
|
||||
},
|
||||
"esquema_pago": {
|
||||
"type": "string",
|
||||
"enum": ["Pago Unico", "Mensual", "Pago Unico/Mensual"],
|
||||
"description": "Esquema de pago (default: Pago Unico/Mensual)",
|
||||
},
|
||||
},
|
||||
"required": ["cliente", "servicios"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "obtener_cotizacion",
|
||||
"description": (
|
||||
"Obtiene los detalles completos de una cotización existente incluyendo: "
|
||||
"datos del cliente, servicios seleccionados con precios, estado actual, "
|
||||
"plan CRM Bucefalo si aplica, observaciones, fechas y vigencia."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
|
||||
},
|
||||
"required": ["cotizacion_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "listar_cotizaciones",
|
||||
"description": (
|
||||
"Lista cotizaciones con filtros opcionales. "
|
||||
"Útil para revisar el pipeline de ventas, cotizaciones pendientes, o historial de un cliente. "
|
||||
"Estados: borrador (en proceso), enviada (esperando respuesta), aprobada (cerrada ganada), rechazada (cerrada perdida)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"estado": {
|
||||
"type": "string",
|
||||
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
|
||||
"description": "Filtrar por estado",
|
||||
},
|
||||
"cliente_nombre": {
|
||||
"type": "string",
|
||||
"description": "Buscar por nombre de cliente",
|
||||
},
|
||||
"busqueda": {
|
||||
"type": "string",
|
||||
"description": "Texto libre para buscar en número, proyecto o cliente",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "cambiar_estado_cotizacion",
|
||||
"description": (
|
||||
"Cambia el estado de una cotización. "
|
||||
"Flujo normal: borrador → enviada → aprobada o rechazada. "
|
||||
"Solo cambiar a 'enviada' cuando la cotización esté lista para el cliente. "
|
||||
"Cambiar a 'aprobada' cuando el cliente acepte, o 'rechazada' cuando decline."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
|
||||
"estado": {
|
||||
"type": "string",
|
||||
"enum": ["borrador", "enviada", "aprobada", "rechazada"],
|
||||
"description": "Nuevo estado de la cotización",
|
||||
},
|
||||
},
|
||||
"required": ["cotizacion_id", "estado"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "actualizar_precio_servicio",
|
||||
"description": (
|
||||
"Actualiza el precio de un servicio específico dentro de una cotización. "
|
||||
"No modifica el precio base del catálogo, solo el precio en esta cotización. "
|
||||
"Útil para negociar precios individuales sin recrear toda la cotización."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización"},
|
||||
"servicio_id": {"type": "string", "description": "ID del servicio cotizado (no el del catálogo)"},
|
||||
"nuevo_precio": {"type": "number", "description": "Nuevo precio en la moneda de la cotización"},
|
||||
},
|
||||
"required": ["cotizacion_id", "servicio_id", "nuevo_precio"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "duplicar_cotizacion",
|
||||
"description": (
|
||||
"Duplica una cotización existente como nueva copia en estado 'borrador'. "
|
||||
"Crea una copia exacta con nuevo ID y número. "
|
||||
"Útil para crear variaciones de una propuesta o reenviar una cotización actualizada."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización a duplicar"},
|
||||
},
|
||||
"required": ["cotizacion_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "calcular_financiamiento",
|
||||
"description": (
|
||||
"Calcula las mensualidades para financiar una cotización o monto específico. "
|
||||
"Plazos disponibles: 3 meses (7.7% tasa), 6 meses (10.7%), 9 meses (13.7%), 12 meses (16.7%). "
|
||||
"Todos incluyen 2.5% de comisión + 16% IVA. "
|
||||
"Devuelve: pago mensual, IVA mensual, total mensual, comisión total, y gran total."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"monto": {"type": "number", "description": "Monto total a financiar en MXN"},
|
||||
"meses": {"type": "integer", "enum": [3, 6, 9, 12], "description": "Plazo en meses"},
|
||||
},
|
||||
"required": ["monto", "meses"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "generar_pdf_cotizacion",
|
||||
"description": (
|
||||
"Genera un PDF profesional de una cotización con: logo de la empresa, colores de marca, "
|
||||
"tabla de servicios agrupados por fase, bonos incluidos, términos y condiciones, "
|
||||
"y datos bancarios para transferencia. Devuelve el archivo PDF."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cotizacion_id": {"type": "string", "description": "ID de la cotización guardada"},
|
||||
},
|
||||
"required": ["cotizacion_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "obtener_configuracion",
|
||||
"description": (
|
||||
"Obtiene la configuración de la empresa Consultoría E3: "
|
||||
"razón social, RFC, domicilio fiscal, datos bancarios (cuenta nacional, CLABE, cuenta internacional, SWIFT), "
|
||||
"colores de marca, logo, y términos y condiciones. "
|
||||
"Útil para generar documentos o verificar información fiscal."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "listar_bonos",
|
||||
"description": (
|
||||
"Lista los bonos/disponibles que se pueden incluir en una cotización: "
|
||||
"1) Servicio Centinela Web (monitoreo 30 min/mes), "
|
||||
"2) Workshop Buyer Persona, "
|
||||
"3) Workshop Propuesta de Valor, "
|
||||
"4) Membresía Premium (1 año), "
|
||||
"5) Mes Gratis CRM Bucefalo, "
|
||||
"6) Script de Ventas (100+ complementos)."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
{
|
||||
"name": "listar_planes_bucefalo",
|
||||
"description": (
|
||||
"Lista los niveles del CRM Bucefalo con precios mensuales: "
|
||||
"Básico ($1,000/mes), Estándar ($3,500/mes), Premium ($4,500/mes), Empresarial ($7,500/mes). "
|
||||
"Bucefalo es un CRM para gestión de ventas y clientes."
|
||||
),
|
||||
"inputSchema": {"type": "object", "properties": {}},
|
||||
},
|
||||
]
|
||||
|
||||
RESOURCES = [
|
||||
{
|
||||
"uri": "cotizador://servicios",
|
||||
"name": "Catálogo de Servicios",
|
||||
"description": (
|
||||
"Lista completa de servicios de marketing digital de Consultoría E3 organizados por fase: "
|
||||
"Fase 0 (Auditorías), Fase 1 (Setup/Infraestructura), Fase 2 (Publicidad/Manejo), "
|
||||
"Fase 3 (Contenido/SEO). Cada servicio incluye nombre, descripción, precio base, "
|
||||
"tiempo de entrega, tipo de pago (único/mensual), y entregables."
|
||||
),
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "cotizador://categorias",
|
||||
"name": "Categorías de Servicios",
|
||||
"description": (
|
||||
"Categorías disponibles para clasificar servicios: "
|
||||
"SEO, Marketing, Paid Media, Desarrollo Web, Automatizaciones, CRM, Desarrollo Personalizado."
|
||||
),
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "cotizador://configuracion",
|
||||
"name": "Configuración de la Empresa",
|
||||
"description": (
|
||||
"Datos fiscales, bancarios y de marca de Consultoría E3. "
|
||||
"Incluye razón social, RFC, domicilio fiscal, cuentas bancarias nacionales e internacionales, "
|
||||
"colores de marca, logo, y términos y condiciones."
|
||||
),
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CategoriaBase(BaseModel):
|
||||
nombre: str = Field(..., min_length=1, description="Nombre de la categoría")
|
||||
descripcion: str | None = None
|
||||
color: str = Field("#6b7280", description="Color hex para UI")
|
||||
orden: int = Field(0, ge=0)
|
||||
|
||||
|
||||
class CategoriaCreate(CategoriaBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoriaUpdate(BaseModel):
|
||||
nombre: str | None = Field(None, min_length=1)
|
||||
descripcion: str | None = None
|
||||
color: str | None = None
|
||||
orden: int | None = Field(None, ge=0)
|
||||
activo: bool | None = None
|
||||
|
||||
|
||||
class CategoriaResponse(CategoriaBase):
|
||||
id: str
|
||||
activo: bool
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ServicioCatalogoBase(BaseModel):
|
||||
nombre: str = Field(..., min_length=1, description="Nombre del servicio")
|
||||
descripcion: str | None = None
|
||||
fase: int = Field(..., ge=0, le=3, description="Fase: 0=Auditoría, 1=Setup, 2=Publicidad, 3=Contenido/SEO")
|
||||
tipoPago: str = Field(..., pattern="^(unico|mensual)$", description="Tipo de pago")
|
||||
precioBase: float = Field(..., ge=0, description="Precio base en la moneda local")
|
||||
tiempoEntrega: str = Field("7 - 14 dias", description="Tiempo de entrega estimado")
|
||||
entregablesDefault: list[str] = Field(default_factory=list, description="Lista de entregables")
|
||||
categoriaId: str = Field(..., min_length=1, description="ID de la categoría")
|
||||
variante: str | None = None
|
||||
orden: int = Field(0, ge=0)
|
||||
|
||||
|
||||
class ServicioCatalogoCreate(ServicioCatalogoBase):
|
||||
pass
|
||||
|
||||
|
||||
class ServicioCatalogoUpdate(BaseModel):
|
||||
nombre: str | None = Field(None, min_length=1)
|
||||
descripcion: str | None = None
|
||||
fase: int | None = Field(None, ge=0, le=3)
|
||||
tipoPago: str | None = Field(None, pattern="^(unico|mensual)$")
|
||||
precioBase: float | None = Field(None, ge=0)
|
||||
tiempoEntrega: str | None = None
|
||||
entregablesDefault: list[str] | None = None
|
||||
categoriaId: str | None = None
|
||||
variante: str | None = None
|
||||
activo: bool | None = None
|
||||
orden: int | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class ServicioCatalogoResponse(ServicioCatalogoBase):
|
||||
id: str
|
||||
activo: bool
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
categoriaRel: CategoriaResponse | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
from app.models.cliente import ClienteResponse
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ClienteBase(BaseModel):
|
||||
nombre: str = Field(..., min_length=1, description="Nombre completo del contacto")
|
||||
empresa: str | None = Field(None, description="Nombre de la empresa")
|
||||
email: str | None = Field(None, description="Email de contacto")
|
||||
telefono: str | None = Field(None, description="Teléfono de contacto")
|
||||
|
||||
|
||||
class ClienteCreate(ClienteBase):
|
||||
pass
|
||||
|
||||
|
||||
class ClienteUpdate(BaseModel):
|
||||
nombre: str | None = Field(None, min_length=1)
|
||||
empresa: str | None = None
|
||||
email: str | None = None
|
||||
telefono: str | None = None
|
||||
|
||||
|
||||
class ClienteResponse(ClienteBase):
|
||||
id: str
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ClienteWithCotizaciones(ClienteResponse):
|
||||
cotizaciones: list[dict] | None = None
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Meta(BaseModel):
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
limit: int = 50
|
||||
pages: int = 0
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
data: list[T]
|
||||
meta: Meta
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
error: str
|
||||
detail: str | None = None
|
||||
code: str | None = None
|
||||
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class ArchivedResponse(BaseModel):
|
||||
ok: bool = True
|
||||
archived: bool
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = "ok"
|
||||
timestamp: str
|
||||
database: str = "connected"
|
||||
|
||||
|
||||
class APIInfoResponse(BaseModel):
|
||||
name: str = "Cotizador E3 API"
|
||||
version: str = "1.0.0"
|
||||
description: str = "API para sistema de cotizaciones de marketing digital"
|
||||
capabilities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
user: dict[str, Any]
|
||||
token: str | None = None
|
||||
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
ALLOWED_CONFIG_KEYS: set[str] = {
|
||||
"color_primario",
|
||||
"color_secundario",
|
||||
"logo_base64",
|
||||
"razon_social",
|
||||
"rfc",
|
||||
"domicilio_fiscal",
|
||||
"cuenta_nacional",
|
||||
"clabe_interbancaria",
|
||||
"cuenta_internacional",
|
||||
"cuenta_internacional_swift",
|
||||
"hora_centinela",
|
||||
"anualidad_hosting",
|
||||
"iva",
|
||||
"terminos_condiciones",
|
||||
"no_incluye",
|
||||
"notas_adicionales",
|
||||
}
|
||||
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
config: dict[str, str] = Field(
|
||||
...,
|
||||
description="Key-value config pairs. Only whitelisted keys are processed.",
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{"color_primario": "#ff0000", "razon_social": "Nueva Empresa SA de CV"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def get_allowed(self) -> dict[str, str]:
|
||||
return {k: v for k, v in self.config.items() if k in ALLOWED_CONFIG_KEYS}
|
||||
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
config: dict[str, str]
|
||||
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ServicioCotizadoInput(BaseModel):
|
||||
catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo")
|
||||
nombre: str = Field(..., min_length=1, description="Nombre del servicio")
|
||||
fase: int = Field(..., ge=0, le=3)
|
||||
tipoPago: str = Field(..., pattern="^(unico|mensual)$")
|
||||
precio: float = Field(..., ge=0, description="Precio (puede diferir del precioBase)")
|
||||
tiempoEntrega: str
|
||||
entregables: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PlanBucefaloInput(BaseModel):
|
||||
nivel: str = Field(..., description="Nivel: basico, estandar, premium, empresarial")
|
||||
precio: float = Field(..., ge=0)
|
||||
|
||||
|
||||
class ClienteInput(BaseModel):
|
||||
nombre: str = Field(..., min_length=1)
|
||||
empresa: str = ""
|
||||
email: str = ""
|
||||
telefono: str = ""
|
||||
|
||||
|
||||
class CotizacionCreate(BaseModel):
|
||||
numero: str = Field(..., min_length=1, description="Número de cotización (formato UJ{YY}{MM}{init}{seq})")
|
||||
fecha: datetime
|
||||
vigencia: datetime
|
||||
moneda: str = Field("MXN", pattern="^(MXN|USD)$")
|
||||
tipoCambio: str = "NA"
|
||||
proyecto: str = "MKT Digital"
|
||||
esquemaPago: str = Field("Pago Unico/Mensual", pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
|
||||
incluirBonos: bool = False
|
||||
incluirFinanciamiento: bool = False
|
||||
observaciones: str = ""
|
||||
asesorId: str = Field(..., min_length=1)
|
||||
cliente: ClienteInput
|
||||
servicios: list[ServicioCotizadoInput] = Field(..., min_length=1)
|
||||
planBucefalo: PlanBucefaloInput | None = None
|
||||
|
||||
model_config = {
|
||||
"json_schema_extra": {
|
||||
"examples": [
|
||||
{
|
||||
"numero": "UJ2605AG001",
|
||||
"fecha": "2026-05-03",
|
||||
"vigencia": "2026-05-24",
|
||||
"moneda": "MXN",
|
||||
"tipoCambio": "NA",
|
||||
"proyecto": "MKT Digital",
|
||||
"esquemaPago": "Pago Unico/Mensual",
|
||||
"incluirBonos": False,
|
||||
"incluirFinanciamiento": False,
|
||||
"observaciones": "",
|
||||
"asesorId": "cuid-asesor",
|
||||
"cliente": {
|
||||
"nombre": "Juan Pérez",
|
||||
"empresa": "ACME Corp",
|
||||
"email": "[email protected]",
|
||||
"telefono": "4421234567",
|
||||
},
|
||||
"servicios": [
|
||||
{
|
||||
"catalogoId": "cuid-servicio",
|
||||
"nombre": "SEO On-Page",
|
||||
"fase": 3,
|
||||
"tipoPago": "mensual",
|
||||
"precio": 2900,
|
||||
"tiempoEntrega": "7 - 14 dias",
|
||||
"entregables": ["Keyword research"],
|
||||
}
|
||||
],
|
||||
"planBucefalo": {"nivel": "basico", "precio": 1000},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CotizacionUpdate(BaseModel):
|
||||
fecha: datetime | None = None
|
||||
vigencia: datetime | None = None
|
||||
moneda: str | None = Field(None, pattern="^(MXN|USD)$")
|
||||
tipoCambio: str | None = None
|
||||
proyecto: str | None = None
|
||||
esquemaPago: str | None = Field(None, pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$")
|
||||
incluirBonos: bool | None = None
|
||||
incluirFinanciamiento: bool | None = None
|
||||
observaciones: str | None = None
|
||||
estado: str | None = Field(None, pattern="^(borrador|enviada|aprobada|rechazada)$")
|
||||
cliente: ClienteInput | None = None
|
||||
servicios: list[ServicioCotizadoInput] | None = None
|
||||
planBucefalo: PlanBucefaloInput | None = None
|
||||
|
||||
|
||||
class CambiarEstadoRequest(BaseModel):
|
||||
estado: str = Field(..., pattern="^(borrador|enviada|aprobada|rechazada)$")
|
||||
|
||||
|
||||
class ActualizarPrecioRequest(BaseModel):
|
||||
servicioId: str = Field(..., min_length=1)
|
||||
precio: float = Field(..., ge=0)
|
||||
|
||||
|
||||
class CotizacionResponse(BaseModel):
|
||||
id: str
|
||||
numero: str
|
||||
fecha: datetime
|
||||
vigencia: datetime
|
||||
moneda: str
|
||||
tipoCambio: str
|
||||
proyecto: str
|
||||
esquemaPago: str
|
||||
estado: str
|
||||
incluirBonos: bool
|
||||
incluirFinanciamiento: bool
|
||||
observaciones: str | None
|
||||
clienteId: str
|
||||
asesorId: str
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
cliente: dict[str, Any] | None = None
|
||||
asesor: dict[str, Any] | None = None
|
||||
servicios: list[dict[str, Any]] | None = None
|
||||
planBucefalo: dict[str, Any] | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ServicioDraft(BaseModel):
|
||||
nombre: str
|
||||
fase: int
|
||||
tipoPago: str
|
||||
precio: float
|
||||
tiempoEntrega: str
|
||||
entregables: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExportDraft(BaseModel):
|
||||
clienteNombre: str = ""
|
||||
clienteEmpresa: str = ""
|
||||
asesorNombre: str = ""
|
||||
fecha: str = ""
|
||||
moneda: str = "MXN"
|
||||
tipoCambio: str = "NA"
|
||||
proyecto: str = "MKT Digital"
|
||||
esquemaPago: str = "Pago Unico/Mensual"
|
||||
incluirBonos: bool = False
|
||||
servicios: list[ServicioDraft] = Field(default_factory=list)
|
||||
planBucefaloNivel: str | None = None
|
||||
observaciones: str = ""
|
||||
|
||||
|
||||
class FinanciamientoRequest(BaseModel):
|
||||
monto: float = Field(..., ge=0, description="Monto total a financiar en MXN")
|
||||
meses: int = Field(..., ge=3, le=12, description="Plazo en meses: 3, 6, 9, o 12")
|
||||
|
||||
|
||||
class FinanciamientoResponse(BaseModel):
|
||||
pagoMensual: float
|
||||
ivaMensual: float
|
||||
totalMensual: float
|
||||
comisionTotal: float
|
||||
granTotal: float
|
||||
meses: int
|
||||
tasa: float
|
||||
comision: float
|
||||
|
||||
|
||||
class BonoResponse(BaseModel):
|
||||
id: str
|
||||
numero: int
|
||||
titulo: str
|
||||
descripcion: str
|
||||
activo: bool
|
||||
|
||||
|
||||
class PlanBucefaloResponse(BaseModel):
|
||||
nivel: str
|
||||
label: str
|
||||
precio: float
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class FasePaqueteInput(BaseModel):
|
||||
nombre: str = Field(..., min_length=1)
|
||||
orden: int = Field(0, ge=0)
|
||||
|
||||
|
||||
class PaqueteCreate(BaseModel):
|
||||
nombre: str = Field(..., min_length=1, description="Nombre del paquete")
|
||||
descripcion: str | None = None
|
||||
fases: list[FasePaqueteInput] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PaqueteUpdate(BaseModel):
|
||||
nombre: str | None = Field(None, min_length=1)
|
||||
descripcion: str | None = None
|
||||
activo: bool | None = None
|
||||
|
||||
|
||||
class ManageAction(BaseModel):
|
||||
action: str = Field(..., description="Acción: addFase, updateFase, deleteFase, addServicio, removeServicio")
|
||||
faseId: str | None = None
|
||||
nombre: str | None = None
|
||||
orden: int | None = None
|
||||
servicioCatalogoId: str | None = None
|
||||
fasePaqueteId: str | None = None
|
||||
|
||||
|
||||
class FasePaqueteResponse(BaseModel):
|
||||
id: str
|
||||
paqueteId: str
|
||||
nombre: str
|
||||
orden: int
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
servicios: list[dict] | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PaqueteResponse(BaseModel):
|
||||
id: str
|
||||
nombre: str
|
||||
descripcion: str | None
|
||||
activo: bool
|
||||
createdAt: datetime
|
||||
updatedAt: datetime
|
||||
fases: list[FasePaqueteResponse] | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -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",
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
IVA_RATE = 0.16
|
||||
|
||||
FASES: dict[int, str] = {
|
||||
0: "FASE 0 - Auditoria / Acompanamiento",
|
||||
1: "FASE 1 - Setup e Infraestructura",
|
||||
2: "FASE 2 - Publicidad y Manejo",
|
||||
3: "FASE 3 - Contenido y SEO",
|
||||
}
|
||||
|
||||
FASES_SHORT: dict[int, str] = {
|
||||
0: "FASE 0 - Auditoria",
|
||||
1: "FASE 1 - Setup e Infraestructura",
|
||||
2: "FASE 2 - Publicidad y Manejo",
|
||||
3: "FASE 3 - Contenido y SEO",
|
||||
}
|
||||
|
||||
PLANES_BUCEFALO = [
|
||||
{"nivel": "basico", "label": "Basico", "precio": 1000},
|
||||
{"nivel": "estandar", "label": "Estandar", "precio": 3500},
|
||||
{"nivel": "premium", "label": "Premium", "precio": 4500},
|
||||
{"nivel": "empresarial", "label": "Empresarial", "precio": 7500},
|
||||
]
|
||||
|
||||
ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"]
|
||||
|
||||
FINANCIAMIENTO_PLANES = [
|
||||
{"meses": 3, "tasa": 0.077, "comision": 2.5, "montoMinimo": 300},
|
||||
{"meses": 6, "tasa": 0.107, "comision": 2.5, "montoMinimo": 600},
|
||||
{"meses": 9, "tasa": 0.137, "comision": 2.5, "montoMinimo": 900},
|
||||
{"meses": 12, "tasa": 0.167, "comision": 2.5, "montoMinimo": 1200},
|
||||
]
|
||||
|
||||
BONOS = [
|
||||
{"id": "bono-1", "numero": 1, "titulo": "Servicio Centinela Web", "descripcion": "Monitoreo web 30 min/mes", "activo": True},
|
||||
{"id": "bono-2", "numero": 2, "titulo": "Workshop Buyer Persona", "descripcion": "Taller de definición de buyer persona", "activo": True},
|
||||
{"id": "bono-3", "numero": 3, "titulo": "Workshop Propuesta de Valor", "descripcion": "Taller de propuesta de valor", "activo": True},
|
||||
{"id": "bono-4", "numero": 4, "titulo": "Membresia Premium", "descripcion": "Membresía premium por 1 año", "activo": True},
|
||||
{"id": "bono-5", "numero": 5, "titulo": "Mes Gratis Bucefalo CRM", "descripcion": "Un mes gratis del CRM Bucefalo", "activo": True},
|
||||
{"id": "bono-6", "numero": 6, "titulo": "Script de Ventas", "descripcion": "Script de ventas con 100+ complementos", "activo": True},
|
||||
]
|
||||
|
||||
|
||||
def bucefalo_precio(nivel: str) -> float:
|
||||
for p in PLANES_BUCEFALO:
|
||||
if p["nivel"] == nivel:
|
||||
return float(p["precio"])
|
||||
return 0.0
|
||||
|
||||
|
||||
def calcular_vigencia(fecha: datetime | date) -> datetime:
|
||||
if isinstance(fecha, datetime):
|
||||
vigencia = fecha.replace()
|
||||
else:
|
||||
vigencia = datetime.combine(fecha, datetime.min.time())
|
||||
dias_habiles = 0
|
||||
while dias_habiles < 15:
|
||||
vigencia = vigencia.replace(day=vigencia.day + 1) if vigencia.day < 28 else vigencia + __import__("datetime").timedelta(days=1)
|
||||
vigencia = vigencia + __import__("datetime").timedelta(days=1)
|
||||
dia = vigencia.weekday()
|
||||
if dia < 5:
|
||||
dias_habiles += 1
|
||||
return vigencia
|
||||
|
||||
|
||||
def calcular_financiamiento(monto: float, meses: int, tasa: float, comision: float, iva: float = IVA_RATE) -> dict:
|
||||
comision_total = (monto * comision) / 100
|
||||
monto_con_comision = monto + comision_total
|
||||
pago_mensual = monto_con_comision * (1 + tasa) / meses
|
||||
iva_mensual = pago_mensual * iva
|
||||
total_mensual = pago_mensual + iva_mensual
|
||||
return {
|
||||
"pagoMensual": round(pago_mensual, 2),
|
||||
"ivaMensual": round(iva_mensual, 2),
|
||||
"totalMensual": round(total_mensual, 2),
|
||||
"comisionTotal": round(comision_total, 2),
|
||||
"granTotal": round(total_mensual * meses, 2),
|
||||
}
|
||||
|
||||
|
||||
def generar_numero_cotizacion(iniciales: str, secuencia: int) -> str:
|
||||
now = datetime.now()
|
||||
yy = str(now.year)[-2:]
|
||||
mm = f"{now.month:02d}"
|
||||
seq = f"{secuencia:03d}"
|
||||
return f"UJ{yy}{mm}{iniciales}{seq}"
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
import re
|
||||
name = re.sub(r"[^\w\s.\-]", "", name)
|
||||
name = re.sub(r"\s+", " ", name)
|
||||
return name.strip()
|
||||
|
||||
|
||||
def format_currency(amount: float) -> str:
|
||||
return f"${amount:,.2f}"
|
||||
|
||||
|
||||
def format_date(d: datetime | date) -> str:
|
||||
return f"{d.day:02d}/{d.month:02d}/{d.year}"
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Excel Generator for Cotizador E3 — port of export/excel/route.ts using openpyxl."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
from app.services.calculators import FASES_SHORT, bucefalo_precio
|
||||
|
||||
FASES_MAP = {0: "FASE 0", 1: "FASE 1", 2: "FASE 2", 3: "FASE 3"}
|
||||
|
||||
|
||||
def _hex_to_fill(hex_color: str) -> PatternFill:
|
||||
h = hex_color.lstrip("#")
|
||||
return PatternFill(start_color=f"FF{h.upper()}", end_color=f"FF{h.upper()}", fill_type="solid")
|
||||
|
||||
|
||||
def _argb(hex_color: str, alpha: str = "FF") -> str:
|
||||
h = hex_color.lstrip("#")
|
||||
if len(h) > 6:
|
||||
h = h[:6]
|
||||
return f"{alpha}{h.upper()}"
|
||||
|
||||
|
||||
def _thin_border(color: str = "FFD1D5DB") -> Border:
|
||||
side = Side(style="thin", color=color)
|
||||
return Border(top=side, left=side, bottom=side, right=side)
|
||||
|
||||
|
||||
def _sanitize_sheet_name(name: str) -> str:
|
||||
name = name[:31]
|
||||
return re.sub(r"[/\\*?\[\]:]", "", name)
|
||||
|
||||
|
||||
def _set_col_widths(ws, widths: list[tuple[str, float]]):
|
||||
for col_letter, width in widths:
|
||||
ws.column_dimensions[col_letter].width = width
|
||||
|
||||
|
||||
def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> bytes:
|
||||
"""Generate a multi-sheet Excel workbook for a quotation.
|
||||
|
||||
Args:
|
||||
data: dict with quotation data (same as PDF generator)
|
||||
saved: if True, uses real quotation number instead of "BORRADOR"
|
||||
"""
|
||||
config_color = data.get("colorPrimario", "#2563eb")
|
||||
PRIMARY = _argb(config_color)
|
||||
PRIMARY_LIGHT = _argb(config_color, alpha="18")
|
||||
SECONDARY = _argb(data.get("colorSecundario", "#1e293b"))
|
||||
WHITE = "FFFFFFFF"
|
||||
LIGHT_BG = "FFF8FAFC"
|
||||
MUTED = "FF64748B"
|
||||
BORDER_COLOR = "FFE2E8F0"
|
||||
|
||||
header_font = Font(bold=True, size=10, name="Calibri", color=WHITE)
|
||||
label_font = Font(bold=True, size=10, name="Calibri", color=MUTED)
|
||||
value_font = Font(size=10, name="Calibri")
|
||||
bold_font = Font(bold=True, size=10, name="Calibri")
|
||||
total_font = Font(bold=True, size=11, name="Calibri")
|
||||
small_font = Font(italic=True, size=9, name="Calibri", color=MUTED)
|
||||
fase_font = Font(bold=True, size=11, name="Calibri", color=PRIMARY)
|
||||
|
||||
primary_fill = PatternFill(start_color=PRIMARY, end_color=PRIMARY, fill_type="solid")
|
||||
primary_light_fill = PatternFill(start_color=PRIMARY_LIGHT, end_color=PRIMARY_LIGHT, fill_type="solid")
|
||||
secondary_fill = PatternFill(start_color=SECONDARY, end_color=SECONDARY, fill_type="solid")
|
||||
light_fill = PatternFill(start_color=LIGHT_BG, end_color=LIGHT_BG, fill_type="solid")
|
||||
white_fill = PatternFill(start_color=WHITE, end_color=WHITE, fill_type="solid")
|
||||
|
||||
razon_social = data.get("razonSocial", "Cotizador E3")
|
||||
cliente_nombre = data.get("clienteNombre", "")
|
||||
cliente_empresa = data.get("clienteEmpresa", "")
|
||||
asesor_nombre = data.get("asesorNombre", "")
|
||||
fecha = data.get("fecha", datetime.now())
|
||||
vigencia = data.get("vigencia", datetime.now())
|
||||
moneda = data.get("moneda", "MXN")
|
||||
tipo_cambio = data.get("tipoCambio", "NA")
|
||||
proyecto = data.get("proyecto", "MKT Digital")
|
||||
esquema = data.get("esquemaPago", "Pago Unico/Mensual")
|
||||
servicios = data.get("servicios", [])
|
||||
plan_nivel = data.get("planBucefaloNivel")
|
||||
numero_label = data.get("numero", "BORRADOR") if saved else "BORRADOR"
|
||||
empresa = cliente_empresa or cliente_nombre
|
||||
|
||||
wb = Workbook()
|
||||
wb.properties.creator = "Cotizador E3"
|
||||
|
||||
# ── HOJA RESUMEN ──────────────────────────────
|
||||
ws = wb.active
|
||||
ws.title = "HOJA RESUMEN"
|
||||
ws.sheet_view.showGridLines = False
|
||||
|
||||
_set_col_widths(ws, [
|
||||
("A", 3), ("B", 22), ("C", 22), ("D", 16),
|
||||
("E", 38), ("F", 5), ("G", 20), ("H", 5),
|
||||
("I", 20), ("J", 5), ("K", 18),
|
||||
])
|
||||
|
||||
# Header banner
|
||||
ws.merge_cells("B2:K2")
|
||||
cell = ws["B2"]
|
||||
cell.value = razon_social
|
||||
cell.font = Font(bold=True, size=16, name="Calibri", color=WHITE)
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
for col in range(1, 12):
|
||||
ws.cell(row=2, column=col).fill = primary_fill
|
||||
|
||||
ws.merge_cells("B3:K3")
|
||||
cell = ws["B3"]
|
||||
cell.value = numero_label
|
||||
cell.font = Font(bold=True, size=11, name="Calibri", color=WHITE)
|
||||
cell.alignment = Alignment(vertical="center")
|
||||
for col in range(1, 12):
|
||||
ws.cell(row=3, column=col).fill = secondary_fill
|
||||
|
||||
# Info section
|
||||
info_start = 5
|
||||
info_rows = [
|
||||
[("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "\u2014")],
|
||||
[("B", "Proyecto:"), ("C", proyecto), ("E", "Moneda:"), ("F", moneda)],
|
||||
[("B", "Asesor:"), ("C", asesor_nombre), ("E", "Tipo de Cambio:"), ("F", tipo_cambio)],
|
||||
[("B", "Fecha:"), ("C", fecha), ("E", "Esquema de Pago:"), ("F", esquema)],
|
||||
]
|
||||
|
||||
for i, row_data in enumerate(info_rows):
|
||||
r = info_start + i
|
||||
for col_letter, label, is_value in [
|
||||
(row_data[0][0], row_data[0][1], False),
|
||||
(row_data[1][0], row_data[1][1], True),
|
||||
(row_data[2][0], row_data[2][1], False),
|
||||
(row_data[3][0], row_data[3][1], True),
|
||||
]:
|
||||
cell = ws[f"{col_letter}{r}"]
|
||||
cell.value = label
|
||||
cell.font = value_font if is_value else label_font
|
||||
|
||||
# Vigencia
|
||||
vigencia_row = info_start + 4
|
||||
ws[f"B{vigencia_row}"].value = "Vigencia:"
|
||||
ws[f"B{vigencia_row}"].font = label_font
|
||||
ws[f"C{vigencia_row}"].value = vigencia
|
||||
ws[f"C{vigencia_row}"].number_format = "DD/MM/YYYY"
|
||||
ws[f"C{vigencia_row}"].font = value_font
|
||||
|
||||
# Services table
|
||||
table_start = info_start + 6
|
||||
cols = ["B", "C", "D", "K"]
|
||||
headers = ["Fase", "Tipo de Pago", "Servicio", "Precio"]
|
||||
|
||||
ws.merge_cells(f"D{table_start}:J{table_start}")
|
||||
for i, col_letter in enumerate(cols):
|
||||
cell = ws[f"{col_letter}{table_start}"]
|
||||
cell.value = headers[i]
|
||||
cell.font = header_font
|
||||
cell.fill = primary_fill
|
||||
cell.alignment = Alignment(
|
||||
horizontal="right" if i == len(cols) - 1 else "left",
|
||||
vertical="center",
|
||||
)
|
||||
cell.border = _thin_border(PRIMARY)
|
||||
ws.row_dimensions[table_start].height = 24
|
||||
|
||||
servicios_unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
|
||||
servicios_mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
|
||||
|
||||
row = table_start + 1
|
||||
current_fase = -1
|
||||
|
||||
for serv in servicios_unicos + servicios_mensuales:
|
||||
fase = serv.get("fase", 0)
|
||||
if fase != current_fase:
|
||||
current_fase = fase
|
||||
ws.merge_cells(f"B{row}:K{row}")
|
||||
ws[f"B{row}"].value = FASES_MAP.get(fase, f"FASE {fase}")
|
||||
ws[f"B{row}"].font = fase_font
|
||||
for col in range(2, 12):
|
||||
ws.cell(row=row, column=col).fill = primary_light_fill
|
||||
row += 1
|
||||
|
||||
row_bg = light_fill if row % 2 == 0 else white_fill
|
||||
|
||||
ws[f"B{row}"].value = ""
|
||||
ws[f"C{row}"].value = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
|
||||
ws[f"C{row}"].font = value_font
|
||||
ws.merge_cells(f"D{row}:J{row}")
|
||||
ws[f"D{row}"].value = serv.get("nombre", "")
|
||||
ws[f"D{row}"].font = value_font
|
||||
ws[f"K{row}"].value = serv.get("precio", 0)
|
||||
ws[f"K{row}"].number_format = "$#,##0.00"
|
||||
ws[f"K{row}"].font = value_font
|
||||
ws[f"K{row}"].alignment = Alignment(horizontal="right")
|
||||
|
||||
for col_letter in cols:
|
||||
ws[f"{col_letter}{row}"].fill = row_bg
|
||||
ws[f"{col_letter}{row}"].border = _thin_border(BORDER_COLOR)
|
||||
row += 1
|
||||
|
||||
row += 1
|
||||
total_unico = sum(s.get("precio", 0) for s in servicios_unicos)
|
||||
ws.merge_cells(f"B{row}:J{row}")
|
||||
ws[f"B{row}"].value = "Total Pago Unico"
|
||||
ws[f"B{row}"].font = total_font
|
||||
ws[f"K{row}"].value = total_unico
|
||||
ws[f"K{row}"].number_format = "$#,##0.00"
|
||||
ws[f"K{row}"].font = total_font
|
||||
ws[f"K{row}"].alignment = Alignment(horizontal="right")
|
||||
for col_letter in cols:
|
||||
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
|
||||
ws[f"{col_letter}{row}"].fill = primary_light_fill
|
||||
row += 1
|
||||
ws.merge_cells(f"B{row}:J{row}")
|
||||
ws[f"B{row}"].value = "(+ IVA)"
|
||||
ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
|
||||
row += 1
|
||||
|
||||
total_mensual = sum(s.get("precio", 0) for s in servicios_mensuales)
|
||||
ws.merge_cells(f"B{row}:J{row}")
|
||||
ws[f"B{row}"].value = "Total Pago Mensual"
|
||||
ws[f"B{row}"].font = total_font
|
||||
ws[f"K{row}"].value = total_mensual
|
||||
ws[f"K{row}"].number_format = "$#,##0.00"
|
||||
ws[f"K{row}"].font = total_font
|
||||
ws[f"K{row}"].alignment = Alignment(horizontal="right")
|
||||
for col_letter in cols:
|
||||
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
|
||||
ws[f"{col_letter}{row}"].fill = primary_light_fill
|
||||
row += 1
|
||||
ws.merge_cells(f"B{row}:J{row}")
|
||||
ws[f"B{row}"].value = "(+ IVA)"
|
||||
ws[f"B{row}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
|
||||
row += 2
|
||||
|
||||
if plan_nivel:
|
||||
ws.merge_cells(f"B{row}:J{row}")
|
||||
ws[f"B{row}"].value = f"CRM Bucefalo - {plan_nivel.capitalize()}"
|
||||
ws[f"B{row}"].font = bold_font
|
||||
ws[f"K{row}"].value = bucefalo_precio(plan_nivel)
|
||||
ws[f"K{row}"].number_format = "$#,##0.00"
|
||||
ws[f"K{row}"].font = bold_font
|
||||
ws[f"K{row}"].alignment = Alignment(horizontal="right")
|
||||
for col_letter in cols:
|
||||
ws[f"{col_letter}{row}"].border = _thin_border(PRIMARY)
|
||||
ws[f"{col_letter}{row}"].fill = primary_light_fill
|
||||
row += 2
|
||||
|
||||
ws.merge_cells(f"B{row}:K{row}")
|
||||
ws[f"B{row}"].value = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles."
|
||||
ws[f"B{row}"].font = small_font
|
||||
|
||||
# ── DETAIL SHEETS ──────────────────────────────
|
||||
for serv in servicios:
|
||||
safe_name = _sanitize_sheet_name(serv.get("nombre", "Servicio"))
|
||||
dw = wb.create_sheet(title=safe_name)
|
||||
dw.sheet_view.showGridLines = False
|
||||
|
||||
_set_col_widths(dw, [
|
||||
("A", 3), ("B", 35), ("C", 5), ("D", 15), ("E", 5),
|
||||
("F", 35), ("G", 5), ("H", 15), ("I", 5), ("J", 18),
|
||||
])
|
||||
|
||||
fase = serv.get("fase", 0)
|
||||
dw.merge_cells("B1:J1")
|
||||
dw["B1"].value = f"{FASES_MAP.get(fase, f'FASE {fase}')} \u2014 {serv.get('nombre', '')}"
|
||||
dw["B1"].font = Font(bold=True, size=14, name="Calibri", color=WHITE)
|
||||
dw["B1"].alignment = Alignment(vertical="center")
|
||||
for col in range(1, 11):
|
||||
dw.cell(row=1, column=col).fill = primary_fill
|
||||
dw.row_dimensions[1].height = 30
|
||||
|
||||
r = 3
|
||||
dw[f"B{r}"].value = "Cliente:"
|
||||
dw[f"B{r}"].font = label_font
|
||||
dw[f"C{r}"].value = cliente_empresa or cliente_nombre
|
||||
dw[f"C{r}"].font = bold_font
|
||||
dw[f"F{r}"].value = "No. Cotizacion:"
|
||||
dw[f"F{r}"].font = label_font
|
||||
dw[f"G{r}"].value = numero_label
|
||||
dw[f"G{r}"].font = bold_font
|
||||
r += 2
|
||||
|
||||
dw[f"B{r}"].value = "Servicio:"
|
||||
dw[f"B{r}"].font = label_font
|
||||
dw[f"C{r}"].value = serv.get("nombre", "")
|
||||
dw[f"C{r}"].font = Font(bold=True, size=12, name="Calibri", color=PRIMARY)
|
||||
dw[f"F{r}"].value = "Tiempo de Entrega:"
|
||||
dw[f"F{r}"].font = label_font
|
||||
dw[f"G{r}"].value = serv.get("tiempoEntrega", "")
|
||||
dw[f"G{r}"].font = value_font
|
||||
r += 2
|
||||
|
||||
# Entregables header
|
||||
for col_letter in ["B", "I"]:
|
||||
dw[f"{col_letter}{r}"].fill = primary_fill
|
||||
dw[f"{col_letter}{r}"].font = header_font
|
||||
dw[f"B{r}"].value = "Entregable"
|
||||
r += 1
|
||||
|
||||
entregables = serv.get("entregables", [])
|
||||
for i, ent in enumerate(entregables):
|
||||
bg = light_fill if i % 2 == 1 else white_fill
|
||||
dw[f"B{i + r}"].value = f"{i + 1}. {ent}"
|
||||
dw[f"B{i + r}"].font = value_font
|
||||
dw[f"B{i + r}"].fill = bg
|
||||
dw[f"B{i + r}"].border = _thin_border(BORDER_COLOR)
|
||||
r += len(entregables) + 1
|
||||
|
||||
# Total
|
||||
for col_letter in ["B", "I"]:
|
||||
dw[f"{col_letter}{r}"].fill = primary_light_fill
|
||||
dw[f"{col_letter}{r}"].border = _thin_border(PRIMARY)
|
||||
tipo_label = "Unico" if serv.get("tipoPago") == "unico" else "Mensual"
|
||||
dw[f"B{r}"].value = f"Total Pago {tipo_label}"
|
||||
dw[f"B{r}"].font = total_font
|
||||
dw[f"I{r}"].value = serv.get("precio", 0)
|
||||
dw[f"I{r}"].number_format = "$#,##0.00"
|
||||
dw[f"I{r}"].font = total_font
|
||||
dw[f"I{r}"].alignment = Alignment(horizontal="right")
|
||||
r += 1
|
||||
dw[f"B{r}"].value = "(+ IVA)"
|
||||
dw[f"B{r}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED)
|
||||
r += 2
|
||||
|
||||
dw.merge_cells(f"B{r}:J{r}")
|
||||
dw[f"B{r}"].value = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA."
|
||||
dw[f"B{r}"].font = small_font
|
||||
r += 2
|
||||
|
||||
dw[f"B{r}"].value = razon_social
|
||||
dw[f"B{r}"].font = Font(bold=True, size=10, name="Calibri", color=PRIMARY)
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
return output.getvalue()
|
||||
@@ -0,0 +1,437 @@
|
||||
"""PDF Generator for Cotizador E3 — port of pdf-generator.ts using ReportLab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import LETTER
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.utils import ImageReader
|
||||
|
||||
from app.services.calculators import FASES_SHORT, PLANES_BUCEFALO, bucefalo_precio
|
||||
|
||||
PRIMARY_DEFAULT = "#2563eb"
|
||||
DARK_DEFAULT = "#1e293b"
|
||||
MUTED = "#64748b"
|
||||
BORDER = "#cbd5e1"
|
||||
LIGHT_BG = "#f1f5f9"
|
||||
WHITE = "#ffffff"
|
||||
|
||||
PAGE_W, PAGE_H = LETTER
|
||||
MARGIN_TOP = 45
|
||||
MARGIN_BOTTOM = 55
|
||||
MARGIN_LEFT = 50
|
||||
MARGIN_RIGHT = 50
|
||||
CONTENT_W = PAGE_W - MARGIN_LEFT - MARGIN_RIGHT
|
||||
|
||||
|
||||
def _hex_to_rgb(hex_color: str) -> colors.Color:
|
||||
h = hex_color.lstrip("#")
|
||||
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||
return colors.Color(r / 255, g / 255, b / 255)
|
||||
|
||||
|
||||
def _fmt_currency(n: float) -> str:
|
||||
return f"${n:,.2f}"
|
||||
|
||||
|
||||
def _fmt_date(d: datetime) -> str:
|
||||
months = [
|
||||
"enero", "febrero", "marzo", "abril", "mayo", "junio",
|
||||
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre",
|
||||
]
|
||||
return f"{d.day} de {months[d.month - 1]} de {d.year}"
|
||||
|
||||
|
||||
def _load_logo(logo_base64: str | None, logo_mime: str | None) -> ImageReader | None:
|
||||
if not logo_base64:
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(logo_base64)
|
||||
return io.BytesIO(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes:
|
||||
"""Generate a professional quotation PDF.
|
||||
|
||||
Args:
|
||||
data: dict with keys matching CotizacionPDFData interface:
|
||||
numero, clienteNombre, clienteEmpresa, asesorNombre, fecha, vigencia,
|
||||
moneda, tipoCambio, proyecto, esquemaPago, servicios[],
|
||||
planBucefaloNivel, planBucefaloPrecio, incluirBonos,
|
||||
configBancaria{}, colorPrimario, colorSecundario, logoBase64, logoMime
|
||||
"""
|
||||
PRIMARY = data.get("colorPrimario") or PRIMARY_DEFAULT
|
||||
DARK = data.get("colorSecundario") or DARK_DEFAULT
|
||||
primary_rgb = _hex_to_rgb(PRIMARY)
|
||||
dark_rgb = _hex_to_rgb(DARK)
|
||||
muted_rgb = _hex_to_rgb(MUTED)
|
||||
border_rgb = _hex_to_rgb(BORDER)
|
||||
light_rgb = _hex_to_rgb(LIGHT_BG)
|
||||
white_rgb = _hex_to_rgb(WHITE)
|
||||
|
||||
buf = io.BytesIO()
|
||||
c = canvas.Canvas(buf, pagesize=LETTER)
|
||||
c.setTitle(f"Cotizacion {data.get('numero', '')}")
|
||||
|
||||
logo = _load_logo(data.get("logoBase64"), data.get("logoMime"))
|
||||
|
||||
servicios = data.get("servicios", [])
|
||||
plan_nivel = data.get("planBucefaloNivel")
|
||||
plan_precio = data.get("planBucefaloPrecio", 0)
|
||||
incluir_bonos = data.get("incluirBonos", False)
|
||||
cfg_bancaria = data.get("configBancaria") or {}
|
||||
|
||||
# ── PAGE 1: COVER ──────────────────────────────
|
||||
c.setFillColor(white_rgb)
|
||||
c.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0)
|
||||
|
||||
y = PAGE_H - 80
|
||||
if logo:
|
||||
try:
|
||||
c.drawImage(logo, MARGIN_LEFT, y - 20, width=65, height=65, preserveAspectRatio=True, mask="auto")
|
||||
y -= 85
|
||||
except Exception:
|
||||
logo = None
|
||||
|
||||
if not logo:
|
||||
c.setFont("Helvetica-Bold", 28)
|
||||
c.setFillColor(primary_rgb)
|
||||
c.drawString(MARGIN_LEFT, y, "UJ")
|
||||
c.setFont("Helvetica", 9)
|
||||
c.setFillColor(muted_rgb)
|
||||
c.drawString(MARGIN_LEFT + 48, y - 15, "Uriel Jareth Consulting")
|
||||
y -= 40
|
||||
|
||||
bar_y = y
|
||||
c.setFillColor(primary_rgb)
|
||||
c.rect(MARGIN_LEFT, bar_y, CONTENT_W, 4, fill=1, stroke=0)
|
||||
|
||||
c.setFont("Helvetica-Bold", 36)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(MARGIN_LEFT, bar_y - 40, "Cotizacion")
|
||||
c.setFont("Helvetica", 18)
|
||||
c.setFillColor(primary_rgb)
|
||||
c.drawString(MARGIN_LEFT, bar_y - 65, data.get("numero", ""))
|
||||
|
||||
iy = bar_y - 100
|
||||
cL = MARGIN_LEFT
|
||||
cR = MARGIN_LEFT + CONTENT_W * 0.52
|
||||
|
||||
info_l = [
|
||||
("Cliente", data.get("clienteNombre", "")),
|
||||
("Empresa", data.get("clienteEmpresa", "")),
|
||||
("Proyecto", data.get("proyecto", "")),
|
||||
("Asesor", data.get("asesorNombre", "")),
|
||||
]
|
||||
info_r = [
|
||||
("Fecha", _fmt_date(data.get("fecha", datetime.now()))),
|
||||
("Vigencia", _fmt_date(data.get("vigencia", datetime.now()))),
|
||||
("Moneda", data.get("moneda", "MXN")),
|
||||
("Esquema", data.get("esquemaPago", "")),
|
||||
]
|
||||
|
||||
for i in range(max(len(info_l), len(info_r))):
|
||||
if i < len(info_l) and info_l[i][1]:
|
||||
c.setFont("Helvetica", 9)
|
||||
c.setFillColor(muted_rgb)
|
||||
c.drawString(cL, iy, info_l[i][0])
|
||||
c.setFont("Helvetica", 11)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(cL, iy - 14, info_l[i][1])
|
||||
if i < len(info_r) and info_r[i][1]:
|
||||
c.setFont("Helvetica", 9)
|
||||
c.setFillColor(muted_rgb)
|
||||
c.drawString(cR, iy, info_r[i][0])
|
||||
c.setFont("Helvetica", 11)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(cR, iy - 14, str(info_r[i][1]))
|
||||
iy -= 32
|
||||
|
||||
# ── PAGE 2+: RESUMEN ──────────────────────────
|
||||
c.showPage()
|
||||
y = PAGE_H - MARGIN_TOP
|
||||
|
||||
if logo:
|
||||
try:
|
||||
c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
c.setFillColor(primary_rgb)
|
||||
c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0)
|
||||
c.setFont("Helvetica-Bold", 15)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(MARGIN_LEFT + 12, y - 15, "Hoja Resumen")
|
||||
y -= 28
|
||||
|
||||
cliente_ref = data.get("clienteEmpresa") or data.get("clienteNombre", "")
|
||||
c.setFont("Helvetica", 8)
|
||||
c.setFillColor(muted_rgb)
|
||||
c.drawString(MARGIN_LEFT, y, f"En atencion a: {cliente_ref}")
|
||||
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"No. Cotizacion: {data.get('numero', '')}")
|
||||
y -= 11
|
||||
c.drawString(MARGIN_LEFT, y, f"Asesor: {data.get('asesorNombre', '')}")
|
||||
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y, f"Fecha: {_fmt_date(data.get('fecha', datetime.now()))} | Moneda: {data.get('moneda', 'MXN')}")
|
||||
y -= 18
|
||||
|
||||
col_nombre = MARGIN_LEFT
|
||||
col_tipo = MARGIN_LEFT + CONTENT_W - 200
|
||||
col_tiempo = MARGIN_LEFT + CONTENT_W - 120
|
||||
col_precio = MARGIN_LEFT + CONTENT_W - 60
|
||||
|
||||
# Header row
|
||||
c.setFillColor(light_rgb)
|
||||
c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 16, fill=1, stroke=0)
|
||||
c.setFont("Helvetica-Bold", 7)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(col_nombre + 6, y + 3, "Servicio")
|
||||
c.drawString(col_tipo + 4, y + 3, "Tipo")
|
||||
c.drawString(col_tiempo + 4, y + 3, "Entrega")
|
||||
c.drawRightString(col_precio + 60, y + 3, "Precio")
|
||||
y -= 4
|
||||
|
||||
c.setStrokeColor(border_rgb)
|
||||
c.setLineWidth(0.3)
|
||||
c.line(MARGIN_LEFT, y, MARGIN_LEFT + CONTENT_W, y)
|
||||
y -= 14
|
||||
|
||||
unicos = [s for s in servicios if s.get("tipoPago") == "unico"]
|
||||
mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"]
|
||||
|
||||
def _draw_section(srv_list, tipo_label, titulo_total, y_pos):
|
||||
if not srv_list:
|
||||
return y_pos
|
||||
current_fase = -1
|
||||
max_y = PAGE_H - MARGIN_BOTTOM - 5
|
||||
|
||||
for serv in srv_list:
|
||||
fase = serv.get("fase", 0)
|
||||
if fase != current_fase:
|
||||
current_fase = fase
|
||||
if y_pos - 20 < max_y - PAGE_H:
|
||||
c.showPage()
|
||||
y_pos = PAGE_H - MARGIN_TOP
|
||||
c.setFillColor(light_rgb)
|
||||
c.rect(MARGIN_LEFT, y_pos - 14, CONTENT_W, 14, fill=1, stroke=0)
|
||||
c.setFont("Helvetica-Bold", 7)
|
||||
c.setFillColor(primary_rgb)
|
||||
c.drawString(MARGIN_LEFT + 6, y_pos - 11, FASES_SHORT.get(fase, f"FASE {fase}"))
|
||||
y_pos -= 18
|
||||
|
||||
c.setFont("Helvetica", 8)
|
||||
c.setFillColor(dark_rgb)
|
||||
nombre = serv.get("nombre", "")
|
||||
c.drawString(col_nombre + 6, y_pos - 10, nombre[:60])
|
||||
c.setFont("Helvetica", 7)
|
||||
c.setFillColor(muted_rgb)
|
||||
c.drawString(col_tipo + 4, y_pos - 10, tipo_label)
|
||||
c.drawString(col_tiempo + 4, y_pos - 10, serv.get("tiempoEntrega", "")[:15])
|
||||
c.setFont("Helvetica-Bold", 8)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawRightString(col_precio + 60, y_pos - 10, _fmt_currency(serv.get("precio", 0)))
|
||||
y_pos -= 14
|
||||
|
||||
entregables = serv.get("entregables", [])
|
||||
if entregables:
|
||||
half = (len(entregables) + 1) // 2
|
||||
for idx in range(half):
|
||||
e1 = entregables[idx]
|
||||
e2 = entregables[idx + half] if idx + half < len(entregables) else None
|
||||
c.setFont("Helvetica", 6)
|
||||
c.setFillColor(muted_rgb)
|
||||
c.drawString(col_nombre + 10, y_pos - 8, f"\u2022 {e1[:50]}")
|
||||
if e2:
|
||||
c.drawString(col_nombre + CONTENT_W * 0.48, y_pos - 8, f"\u2022 {e2[:50]}")
|
||||
y_pos -= 9
|
||||
|
||||
c.setStrokeColor(_hex_to_rgb("#e5e7eb"))
|
||||
c.setLineWidth(0.2)
|
||||
c.line(col_nombre + 6, y_pos, MARGIN_LEFT + CONTENT_W, y_pos)
|
||||
y_pos -= 6
|
||||
|
||||
total = sum(s.get("precio", 0) for s in srv_list)
|
||||
y_pos -= 4
|
||||
c.setFillColor(_hex_to_rgb("#f8fafc"))
|
||||
c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 18, fill=1, stroke=0)
|
||||
c.setFont("Helvetica-Bold", 8.5)
|
||||
c.setFillColor(primary_rgb)
|
||||
c.drawString(col_nombre + 6, y_pos + 3, titulo_total)
|
||||
c.drawRightString(col_precio + 60, y_pos + 3, _fmt_currency(total))
|
||||
y_pos -= 18
|
||||
c.setFont("Helvetica", 6)
|
||||
c.setFillColor(muted_rgb)
|
||||
c.drawString(col_nombre + 6, y_pos, "(Precios en Moneda Nacional, no incluyen IVA)")
|
||||
y_pos -= 14
|
||||
return y_pos
|
||||
|
||||
if unicos:
|
||||
y = _draw_section(unicos, "Unico", "Total Pago Unico", y)
|
||||
if mensuales:
|
||||
y = _draw_section(mensuales, "Mensual", "Total Pago Mensual", y)
|
||||
|
||||
if plan_nivel:
|
||||
label = plan_nivel.capitalize()
|
||||
c.setFillColor(light_rgb)
|
||||
c.rect(MARGIN_LEFT, y - 2, CONTENT_W, 14, fill=1, stroke=0)
|
||||
c.setFont("Helvetica-Bold", 7.5)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(col_nombre + 6, y + 3, f"Plan Bucefalo CRM - {label}")
|
||||
c.drawRightString(col_precio + 60, y + 3, _fmt_currency(plan_precio))
|
||||
y -= 20
|
||||
|
||||
if incluir_bonos:
|
||||
y -= 6
|
||||
c.setFillColor(primary_rgb)
|
||||
c.rect(MARGIN_LEFT, y - 10, 3, 10, fill=1, stroke=0)
|
||||
c.setFont("Helvetica-Bold", 9)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(MARGIN_LEFT + 10, y - 8, "Bonos (Pago en una exhibicion)")
|
||||
y -= 20
|
||||
bonos = [
|
||||
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
|
||||
"Bono 2: Workshop Estrategico de Buyer Persona",
|
||||
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
|
||||
"Bono 4: 1 ano de Membresia Premium",
|
||||
"Bono 5: Un mes gratis de Bucefalo CRM",
|
||||
"Bono 6: Script de Ventas con mas de 100 complementos",
|
||||
]
|
||||
for b in bonos:
|
||||
c.setFont("Helvetica", 7)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(MARGIN_LEFT + 8, y - 8, f"\u2713 {b}")
|
||||
y -= 12
|
||||
|
||||
# ── T&C PAGE ──────────────────────────────────
|
||||
c.showPage()
|
||||
y = PAGE_H - MARGIN_TOP
|
||||
|
||||
if logo:
|
||||
try:
|
||||
c.drawImage(logo, MARGIN_LEFT + CONTENT_W - 70, y - 5, width=22, height=22, preserveAspectRatio=True, mask="auto")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
c.setFillColor(primary_rgb)
|
||||
c.rect(MARGIN_LEFT, y - 16, 4, 16, fill=1, stroke=0)
|
||||
c.setFont("Helvetica-Bold", 15)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(MARGIN_LEFT + 12, y - 15, "Terminos y Condiciones")
|
||||
y -= 30
|
||||
|
||||
def _section_title(title, yy):
|
||||
c.setFillColor(primary_rgb)
|
||||
c.rect(MARGIN_LEFT, yy - 9, 3, 9, fill=1, stroke=0)
|
||||
c.setFont("Helvetica-Bold", 9)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(MARGIN_LEFT + 10, yy - 8, title)
|
||||
return yy - 20
|
||||
|
||||
def _draw_bullets(title, items, yy):
|
||||
yy = _section_title(title, yy)
|
||||
for t in items:
|
||||
c.setFont("Helvetica", 7.5)
|
||||
c.setFillColor(dark_rgb)
|
||||
# Simple text wrapping
|
||||
words = t.split()
|
||||
line = ""
|
||||
for word in words:
|
||||
test = f"{line} {word}".strip()
|
||||
if c.stringWidth(test, "Helvetica", 7.5) > CONTENT_W - 10:
|
||||
c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}")
|
||||
yy -= 11
|
||||
line = word
|
||||
else:
|
||||
line = test
|
||||
if line:
|
||||
c.drawString(MARGIN_LEFT + 2, yy - 8, f"\u2022 {line}")
|
||||
yy -= 11
|
||||
return yy - 8
|
||||
|
||||
y = _draw_bullets("Condiciones", [
|
||||
"Esta cotizacion tiene una vigencia de 15 dias habiles.",
|
||||
"Cualquier ajuste al proyecto despues de la aprobacion del contenido afectara la fecha de entrega y por consiguiente el costo.",
|
||||
"El cliente debera proporcionar la informacion solicitada por Uriel Jareth Consulting en tiempo y forma.",
|
||||
"Si la falta de informacion provoca un excedente en los plazos de entrega del proyecto, las horas adicionales de servicio se cotizaran por separado.",
|
||||
"Los pagos correspondientes a los servicios mensuales deberan realizarse en los primeros 5 dias del mes.",
|
||||
"Todo el material e informacion necesarios para la realizacion del sitio web deberan ser entregados en un plazo maximo de 40 dias naturales a partir del arranque del proyecto.",
|
||||
], y)
|
||||
|
||||
y = _draw_bullets("Que no incluye el proyecto?", [
|
||||
"Generacion de disenos, videos, traducciones, cambios de divisas y unidades, o cualquier servicio externo a lo cotizado.",
|
||||
"Redaccion de entradas de Blog.",
|
||||
"Integracion de Servicios de terceros ajenos a los cotizados.",
|
||||
"Servicio de Recuperacion de Accesos de: Google Analytics, Google Tag Manager, Google Search Console, Google Ads, y Meta Ads.",
|
||||
"Creacion de Redes Sociales (En caso de requerir el servicio incluira un costo adicional).",
|
||||
], y)
|
||||
|
||||
y = _draw_bullets("Notas", [
|
||||
"El presente proyecto debera tener un responsable oficial.",
|
||||
"La Hora Centinela tiene un precio de $700.00 MXN.",
|
||||
"Los archivos editables/fuente (AI, PSD) son propiedad intelectual de la agencia. Si requiere los archivos editables, estos pueden ser adquiridos abonando una tarifa de liberacion (buy-out fee).",
|
||||
"Si el proyecto se pausa por razones ajenas a Uriel Jareth Consulting, esto generara costo extra del 15% al 30% para retomar el proyecto.",
|
||||
], y)
|
||||
|
||||
y -= 8
|
||||
c.setFillColor(primary_rgb)
|
||||
c.rect(MARGIN_LEFT, y - 9, 3, 9, fill=1, stroke=0)
|
||||
c.setFont("Helvetica-Bold", 9)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(MARGIN_LEFT + 10, y - 8, "Datos Bancarios")
|
||||
y -= 20
|
||||
|
||||
razon_social = cfg_bancaria.get("razon_social", "URIEL JARETH ALVARADO ORTIZ")
|
||||
rfc = cfg_bancaria.get("rfc", "AAOU970201SU7")
|
||||
clabe_nac = cfg_bancaria.get("clabe_interbancaria", cfg_bancaria.get("cuenta_nacional", ""))
|
||||
cuenta_nac = cfg_bancaria.get("cuenta_nacional", "")
|
||||
banco_nac = "BBVA" if cuenta_nac else ""
|
||||
|
||||
box_h = 48
|
||||
c.setFillColor(light_rgb)
|
||||
c.setStrokeColor(border_rgb)
|
||||
c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1)
|
||||
c.setFont("Helvetica-Bold", 7)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Nacional")
|
||||
c.setFont("Helvetica", 6.5)
|
||||
if cuenta_nac:
|
||||
c.drawString(MARGIN_LEFT + 8, y - 24, f"Cuenta: {cuenta_nac}")
|
||||
if clabe_nac:
|
||||
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 24, f"CLABE: {clabe_nac}")
|
||||
c.drawString(MARGIN_LEFT + 8, y - 35, f"Razon Social: {razon_social}")
|
||||
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, f"RFC: {rfc}")
|
||||
if banco_nac:
|
||||
c.drawString(MARGIN_LEFT + 8, y - 46, f"Banco: {banco_nac}")
|
||||
y -= box_h + 8
|
||||
|
||||
c.setFillColor(light_rgb)
|
||||
c.rect(MARGIN_LEFT, y - box_h, CONTENT_W, box_h, fill=1, stroke=1)
|
||||
c.setFont("Helvetica-Bold", 7)
|
||||
c.setFillColor(dark_rgb)
|
||||
c.drawString(MARGIN_LEFT + 8, y - 12, "Transferencia Internacional")
|
||||
c.setFont("Helvetica", 6.5)
|
||||
c.drawString(MARGIN_LEFT + 8, y - 24, f"Beneficiario: {razon_social}")
|
||||
c.drawString(MARGIN_LEFT + 8, y - 35, "Banco: BBVA Mexico")
|
||||
c.drawString(MARGIN_LEFT + CONTENT_W * 0.45, y - 35, "SWIFT: BCMRMXMMPYM")
|
||||
|
||||
# ── FOOTER ON ALL PAGES ──────────────────────
|
||||
page_count = c.getPageNumber()
|
||||
for i in range(1, page_count + 1):
|
||||
c.setPageSize(LETTER)
|
||||
c.setFont("Helvetica", 6)
|
||||
c.setFillColor(muted_rgb)
|
||||
c.setStrokeColor(border_rgb)
|
||||
c.setLineWidth(0.5)
|
||||
c.line(MARGIN_LEFT, MARGIN_BOTTOM - 10, MARGIN_LEFT + CONTENT_W, MARGIN_BOTTOM - 10)
|
||||
c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 22, "Uriel Jareth Consulting")
|
||||
c.drawCentredString(PAGE_W / 2, MARGIN_BOTTOM - 32, "urieljareth.com | [email protected] | (445) 182 9943")
|
||||
|
||||
c.save()
|
||||
return buf.getvalue()
|
||||
Reference in New Issue
Block a user