From 69c74faabb065e34b905d0b73f63f4fdd177ea67 Mon Sep 17 00:00:00 2001 From: urieljareth Date: Wed, 10 Jun 2026 02:21:18 -0600 Subject: [PATCH] Restaurar funcionalidades desde stash + setup de despliegue Coolify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Recupera el trabajo revertido (doble propuesta, retainer, niveles, API Python) desde el stash de GitHub Desktop - Dockerfile multi-stage para Next.js (migraciones automáticas al arrancar, seed opcional via RUN_SEED) - docker-compose.coolify.yml: postgres + web + api con healthchecks y SERVICE_FQDN_* - Endpoint público /api/health (verifica BD) para healthchecks - seed.ts parametrizado: conexión DB_* y credenciales SEED_* por entorno (sin passwords hardcodeadas) - .env.example, .dockerignore, uvicorn con --proxy-headers - Limpieza: .xlsx y __pycache__ fuera del repo Co-Authored-By: Claude Fable 5 --- AGENTS.md | 36 +- README.md | 66 +- api/app/database.py | 4 +- api/app/mcp/server.py | 33 +- api/app/mcp/tools.py | 18 +- api/app/models/catalogo.py | 2 + api/app/models/cotizacion.py | 23 +- api/app/models/export_.py | 16 + api/app/routers/catalogo.py | 25 +- api/app/routers/cotizaciones.py | 217 ++- api/app/routers/export_.py | 39 +- api/app/routers/paquetes.py | 156 +- api/app/services/calculators.py | 60 + api/app/services/excel_generator.py | 488 +++++-- api/app/services/pdf_generator.py | 153 +- next.config.ts | 1 + prisma/schema.prisma | 33 +- prisma/seed.ts | 1294 ++++++++++++++++- src/app/(app)/catalogo/CatalogoClient.tsx | 66 +- src/app/(app)/catalogo/page.tsx | 1 + src/app/(app)/clientes/ClientesList.tsx | 21 +- src/app/(app)/clientes/page.tsx | 11 +- .../configuracion/ConfiguracionClient.tsx | 5 +- .../(app)/cotizaciones/CotizacionesList.tsx | 21 +- .../(app)/cotizaciones/[id]/editar/page.tsx | 13 +- src/app/(app)/cotizaciones/[id]/page.tsx | 67 +- src/app/(app)/cotizaciones/nueva/page.tsx | 3 +- src/app/(app)/cotizaciones/page.tsx | 12 +- src/app/(app)/page.tsx | 11 +- src/app/api/catalogo/[id]/route.ts | 1 + src/app/api/catalogo/route.ts | 1 + src/app/api/cotizaciones/[id]/route.ts | 65 +- src/app/api/cotizaciones/route.ts | 49 +- src/app/api/export/excel/[id]/route.ts | 381 +---- src/app/api/export/excel/route.ts | 355 +---- src/app/api/export/pdf/[id]/route.ts | 37 +- src/app/api/export/pdf/route.ts | 11 + src/components/CotizacionForm.tsx | 508 ++++++- src/components/ExportButtons.tsx | 131 +- src/lib/calculators.ts | 60 + src/lib/pdf-generator.ts | 128 +- src/lib/schemas.ts | 23 + src/lib/store.ts | 30 + 43 files changed, 3536 insertions(+), 1139 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fae087d..a01f808 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,12 @@ - **Bucéfalo CRM plan prices** (in `calculators.ts`, NOT the DB): basico=$1,000, estandar=$3,500, premium=$4,500, empresarial=$7,500 (monthly). - **Financing** lives in the `FinanciamientoPlan` table (3/6/9/12 months). Formula: `comisionTotal = monto × comision%`, `pagoMensual = (monto + comisionTotal) × (1 + tasa) / meses`, then add 16% IVA. Both backends must keep this formula identical. +## Auth + +- **JWT sessions** (`src/lib/auth.ts`) signed with `jose` (HS256, 7-day expiry), stored in the `cotizador-session` httpOnly cookie. `JWT_SECRET` env var is **required** (throws at startup if missing). +- **`src/middleware.ts`** gates everything except `PUBLIC_PATHS` (`/login`, `/api/auth/login`, `/api/auth/logout`, `/api/health`). Unauthenticated API calls → 401 JSON; pages → redirect to `/login`. On success it injects `x-user-id` / `x-user-role` request headers for downstream handlers. +- Passwords hashed with `bcryptjs`. Roles: `asesor` (default), and others stored as free-form strings. + ## Deployment (Coolify) - Production stack: `docker-compose.coolify.yml` (postgres + web + api). In Coolify set **Docker Compose Location** to `/docker-compose.coolify.yml`. Local dev keeps using `docker-compose.yml` + `start.bat`. @@ -59,21 +65,31 @@ ``` src/ - app/ # Next.js App Router pages + API routes - api/ # Route handlers (REST endpoints) - cotizaciones/[id]/editar/ # Edit mode (reuses CotizacionEditor via EditorLoader) - components/ # Shared client components (ExportButtons, Sidebar) - lib/ # Utilities: db.ts, store.ts, calculators.ts, pdf-generator.ts + app/ + (app)/ # Authed route group: dashboard, cotizaciones, clientes, catalogo, configuracion (has its own layout.tsx + Sidebar) + api/ # Next.js route handlers (REST): auth, catalogo, categorias, cotizaciones, configuracion, paquetes, export, import + login/ # Public login page + components/ # CotizacionForm, ExportButtons, EstadoBadge, layout/Sidebar + lib/ # auth, db, store, calculators, pdf-generator, schemas, config-helpers generated/prisma/ # Prisma client output (gitignored) prisma/ - schema.prisma # 8 models - seed.ts # All catalog data (23 services, bonos, planes, config) - migrations/ # Prisma migration files + schema.prisma # 13 models (User, Cliente, Cotizacion, Categoria, Paquete, FasePaquete, + # ServicioCatalogo, ServicioPaquete, ServicioCotizado, PlanBucefaloCotizacion, + # Configuracion, Bono, FinanciamientoPlan) + seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts + migrations/ # 3 migrations +api/ # Standalone Python FastAPI + MCP server (see below) ``` -- **Zustand store** (`src/lib/store.ts`) manages cotización editor state (draft form). Used by both new and edit pages. +- **Zustand store** (`src/lib/store.ts`) holds the cotización draft. Used by both the new (`cotizaciones/nueva`) and edit (`cotizaciones/[id]/editar`) pages, both of which render `CotizacionForm.tsx`. - **ExportButtons.tsx** has 4 variants: `ExportExcelButtonSaved` / `ExportPDFButtonSaved` (GET by ID) and `ExportExcelButtonDraft` / `ExportPDFButtonDraft` (POST with body). -- The old standalone `ExportExcelButton.tsx` was deleted — all exports now go through `ExportButtons.tsx`. + +## Python API (`api/`) — Optional Second Backend + +- **FastAPI app** (`api/main.py`) exposing the same domain as REST, **plus an MCP server at `/mcp`** for AI agents (n8n, Claude, ChatGPT). Tools/resources defined in `api/app/mcp/`. +- Auth: **API key** (`X-API-Key` or `Authorization: Bearer`) for agents; JWT for human login. Routers in `api/app/routers/`, business logic in `api/app/services/` (its own `calculators.py`, `pdf_generator.py`, `excel_generator.py` — mirror the TS versions). +- Run: `cd api && pip install -r requirements.txt && uvicorn main:app --reload --port 8000`. Swagger at `/docs`. Full endpoint reference in `api/COTIZADOR_API_SKILL.md`. +- It reads `DB_*`, `API_KEY`, `JWT_SECRET` env vars (same DB as Prisma). ## Domain diff --git a/README.md b/README.md index 999935f..b26ee88 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,60 @@ # Cotizador E3 -Sistema de cotizaciones de Consultoría E3. Dos backends sobre la misma base de datos PostgreSQL: +Sistema de generación de cotizaciones para Consultoría E3 (marketing digital, Querétaro MX). +Servicios organizados en 4 fases, dos tipos de pago (único / mensual), planes CRM Bucéfalo +y financiamiento opcional. Exporta a PDF y Excel. -- **Web (Next.js 16)** — `src/`, puerto 3000. UI completa: cotizaciones, clientes, catálogo, configuración, export PDF/Excel. Prisma es dueño del esquema y las migraciones. -- **API (FastAPI + MCP)** — `api/`, puerto 8000. REST para integraciones (n8n, agentes de IA) con servidor MCP en `/mcp`. Referencia completa en `api/COTIZADOR_API_SKILL.md`. +## Stack -## Desarrollo local (Windows) +- **Frontend / app web:** Next.js 16 (App Router) + React 19 + Tailwind CSS v4 + Zustand +- **ORM:** Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`) +- **Base de datos:** PostgreSQL 16 (vía Docker) +- **Auth:** JWT (`jose`) en cookie httpOnly, contraseñas con `bcryptjs` +- **API alterna:** servicio Python FastAPI + servidor MCP en [`api/`](api/) (para n8n / agentes de IA) -Requisitos: Node 20+, Docker Desktop. +## Requisitos -```bash -# 1. Copia las variables de entorno -copy .env.example .env # y define JWT_SECRET +- Node.js 20+ +- Docker (para PostgreSQL) +- Un archivo `.env` en la raíz — copia [.env.example](.env.example) y define al menos `JWT_SECRET` -# 2. Levanta PostgreSQL + API Python y el dev server de Next.js -start.bat +## Arranque rápido (Windows) + +```bat +start.bat :: levanta PostgreSQL en Docker, aplica migraciones y arranca Next.js +stop.bat :: detiene todo ``` -`start.bat` levanta el compose local (`docker-compose.yml`), aplica migraciones y arranca `npm run dev`. Para detener todo: `stop.bat`. +## Arranque manual -Comandos útiles: +```bash +docker compose up -d postgres # base de datos +npx prisma migrate deploy # aplica migraciones +npx tsx prisma/seed.ts # carga catálogo (idempotente) +npm run dev # http://localhost:3000 +``` + +## Comandos | Comando | Propósito | |---------|-----------| -| `npm run dev` | Dev server Next.js (puerto 3000) | -| `npm run build` | Build de producción | -| `npx prisma migrate dev` | Crear/aplicar migración | -| `npx tsx prisma/seed.ts` | Seed (idempotente; usuarios vía `SEED_*`) | -| `npx prisma studio` | GUI de la base de datos | +| `npm run dev` | Servidor de desarrollo (puerto 3000) | +| `npm run build` | Build de producción (incluye chequeo de tipos) | +| `npm run lint` | ESLint | +| `npm run db:migrate` | `prisma migrate dev` | +| `npm run db:seed` | Carga de datos semilla | +| `npm run db:studio` | Prisma Studio | +| `npm run db:generate` | Regenera el cliente Prisma | + +## API Python (opcional) + +```bash +cd api +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 # Swagger en /docs, MCP en /mcp +``` + +Referencia completa de endpoints en [`api/COTIZADOR_API_SKILL.md`](api/COTIZADOR_API_SKILL.md). ## Despliegue en Coolify @@ -65,6 +92,7 @@ El stack de producción está en [docker-compose.coolify.yml](docker-compose.coo - El servidor MCP queda en `https:///mcp` (auth por `X-API-Key`). - La fórmula de financiamiento y los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` — mantenlos en paridad. -## Arquitectura +## Documentación para agentes -Ver [AGENTS.md](AGENTS.md) para convenciones, gotchas de Prisma 7 / PDFKit / Next 16 y el modelo de datos. +Las convenciones del proyecto, gotchas de Prisma 7 / PDFKit / Next.js 16 y el modelo de datos +están en [`AGENTS.md`](AGENTS.md) (importado por `CLAUDE.md`). diff --git a/api/app/database.py b/api/app/database.py index 80a2740..44221fe 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -15,8 +15,8 @@ async def init_db() -> None: user=settings.DB_USER, password=settings.DB_PASSWORD, database=settings.DB_NAME, - min_size=2, - max_size=10, + min_size=5, + max_size=20, ) diff --git a/api/app/mcp/server.py b/api/app/mcp/server.py index d393f1a..2345c58 100644 --- a/api/app/mcp/server.py +++ b/api/app/mcp/server.py @@ -168,6 +168,8 @@ async def _crear_cotizacion(conn, args: dict) -> dict: moneda = args.get("moneda", "MXN") proyecto = args.get("proyecto", "MKT Digital") esquema = args.get("esquema_pago", "Pago Unico/Mensual") + es_doble = bool(args.get("es_doble", False)) + opciones_metadata = args.get("opciones_metadata") if es_doble else None cliente = await conn.fetchrow( 'SELECT id FROM "Cliente" WHERE nombre = $1 AND empresa = $2', @@ -194,10 +196,13 @@ async def _crear_cotizacion(conn, args: dict) -> dict: 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()) + estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, 'NA', $5, $6, 'borrador', false, false, $7, $8, '', $9, $10, NOW(), NOW()) RETURNING id, numero""", - numero, now, vigencia, moneda, proyecto, esquema, cliente_id, "agent", + numero, now, vigencia, moneda, proyecto, esquema, + es_doble, + json.dumps(opciones_metadata) if opciones_metadata else None, + cliente_id, "agent", ) cot_id = cot["id"] @@ -218,12 +223,14 @@ async def _crear_cotizacion(conn, args: dict) -> dict: except (json.JSONDecodeError, TypeError): entregables = [] + opcion = (srv.get("opcion") or "ambas") if es_doble else None + 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())""", + precio, "tiempoEntrega", entregables, opcion, seleccionado, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, true, NOW(), NOW())""", cot_id, catalogo_id, cat_row["fase"], cat_row["tipoPago"], - precio, cat_row["tiempoEntrega"], json.dumps(entregables or []), + precio, cat_row["tiempoEntrega"], json.dumps(entregables or []), opcion, ) if plan_bucefalo: @@ -363,13 +370,13 @@ async def _duplicar_cotizacion(conn, args: dict) -> dict: 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()) + estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", observaciones, "clienteId", "asesorId", "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'borrador', $8, $9, $10, $11, $12, $13, $14, 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"], + original["incluirFinanciamiento"], original["esDoble"], original["opcionesMetadata"], + original["observaciones"], original["clienteId"], original["asesorId"], ) new_id = new_cot["id"] @@ -379,10 +386,10 @@ async def _duplicar_cotizacion(conn, args: dict) -> dict: 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())""", + precio, "tiempoEntrega", entregables, notas, opcion, seleccionado, "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())""", new_id, s["servicioCatalogoId"], s["fase"], s["tipoPago"], - s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["seleccionado"], + s["precio"], s["tiempoEntrega"], s["entregables"], s["notas"], s["opcion"], s["seleccionado"], ) plan = await conn.fetchrow( diff --git a/api/app/mcp/tools.py b/api/app/mcp/tools.py index 00d38a0..469061b 100644 --- a/api/app/mcp/tools.py +++ b/api/app/mcp/tools.py @@ -45,7 +45,10 @@ TOOLS = [ "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." + "Precios CRM Bucefalo: basico=$1,000/mes, estandar=$3,500/mes, premium=$4,500/mes, empresarial=$7,500/mes. " + "Soporta DOBLE PROPUESTA: con es_doble=true se presentan dos opciones comparables; cada servicio " + "se asigna a la opción '1', '2' o 'ambas' (compartido), y opciones_metadata define el título, " + "descripción y exclusiones de cada opción." ), "inputSchema": { "type": "object", @@ -67,11 +70,24 @@ TOOLS = [ "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)"}, + "opcion": {"type": "string", "enum": ["1", "2", "ambas"], "description": "Solo en doble propuesta: opción a la que pertenece el servicio ('ambas' = compartido). Default 'ambas'."}, }, "required": ["servicio_id"], }, "description": "Lista de servicios a incluir en la cotización", }, + "es_doble": { + "type": "boolean", + "description": "Si es true, la cotización presenta dos opciones comparables (doble propuesta).", + }, + "opciones_metadata": { + "type": "object", + "description": "Solo en doble propuesta. Metadatos por opción, p.ej. {\"1\": {\"titulo\": \"...\", \"descripcion\": \"...\", \"noIncluye\": \"...\"}, \"2\": {...}}.", + "properties": { + "1": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}}, + "2": {"type": "object", "properties": {"titulo": {"type": "string"}, "descripcion": {"type": "string"}, "noIncluye": {"type": "string"}}}, + }, + }, "plan_bucefalo": { "type": "string", "enum": ["basico", "estandar", "premium", "empresarial"], diff --git a/api/app/models/catalogo.py b/api/app/models/catalogo.py index 7f5a6bb..6481c73 100644 --- a/api/app/models/catalogo.py +++ b/api/app/models/catalogo.py @@ -43,6 +43,7 @@ class ServicioCatalogoBase(BaseModel): 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 + nivel: str | None = Field(None, description="Nivel/tier: Emprendedor, PYME, Empresario, Corporativo") orden: int = Field(0, ge=0) @@ -60,6 +61,7 @@ class ServicioCatalogoUpdate(BaseModel): entregablesDefault: list[str] | None = None categoriaId: str | None = None variante: str | None = None + nivel: str | None = None activo: bool | None = None orden: int | None = Field(None, ge=0) diff --git a/api/app/models/cotizacion.py b/api/app/models/cotizacion.py index b31189f..c0e45e5 100644 --- a/api/app/models/cotizacion.py +++ b/api/app/models/cotizacion.py @@ -7,13 +7,28 @@ from pydantic import BaseModel, Field class ServicioCotizadoInput(BaseModel): - catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo") + catalogoId: str = Field(..., min_length=1, description="ID del servicio del catálogo o 'custom-...' para partidas personalizadas") 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) + # Partidas por tiempo (sin catálogo). modeloCobro: "fijo" | "horas" | "retainer". + esPersonalizado: bool = False + horas: float | None = Field(None, ge=0) + tarifaHora: float | None = Field(None, ge=0) + modeloCobro: str | None = Field(None, pattern="^(fijo|horas|retainer)$") + montoMinimo: float | None = Field(None, ge=0) + horasIncluidas: float | None = Field(None, ge=0) + # Doble propuesta: "1" | "2" | "ambas". None en cotizaciones normales. + opcion: str | None = Field(None, pattern="^(1|2|ambas)$") + + +class MetaOpcionInput(BaseModel): + titulo: str | None = None + descripcion: str | None = None + noIncluye: str | None = None class PlanBucefaloInput(BaseModel): @@ -38,6 +53,8 @@ class CotizacionCreate(BaseModel): esquemaPago: str = Field("Pago Unico/Mensual", pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$") incluirBonos: bool = False incluirFinanciamiento: bool = False + esDoble: bool = False + opciones: dict[str, MetaOpcionInput] | None = None observaciones: str = "" asesorId: str = Field(..., min_length=1) cliente: ClienteInput @@ -92,6 +109,8 @@ class CotizacionUpdate(BaseModel): esquemaPago: str | None = Field(None, pattern="^(Pago Unico|Mensual|Pago Unico/Mensual)$") incluirBonos: bool | None = None incluirFinanciamiento: bool | None = None + esDoble: bool | None = None + opciones: dict[str, MetaOpcionInput] | None = None observaciones: str | None = None estado: str | None = Field(None, pattern="^(borrador|enviada|aprobada|rechazada)$") cliente: ClienteInput | None = None @@ -120,6 +139,8 @@ class CotizacionResponse(BaseModel): estado: str incluirBonos: bool incluirFinanciamiento: bool + esDoble: bool = False + opcionesMetadata: dict[str, Any] | None = None observaciones: str | None clienteId: str asesorId: str diff --git a/api/app/models/export_.py b/api/app/models/export_.py index f2cb689..4ceb365 100644 --- a/api/app/models/export_.py +++ b/api/app/models/export_.py @@ -10,6 +10,20 @@ class ServicioDraft(BaseModel): precio: float tiempoEntrega: str entregables: list[str] = Field(default_factory=list) + esPersonalizado: bool = False + horas: float | None = None + tarifaHora: float | None = None + modeloCobro: str | None = None + montoMinimo: float | None = None + horasIncluidas: float | None = None + # Doble propuesta: "1" | "2" | "ambas". + opcion: str | None = None + + +class MetaOpcionDraft(BaseModel): + titulo: str | None = None + descripcion: str | None = None + noIncluye: str | None = None class ExportDraft(BaseModel): @@ -25,6 +39,8 @@ class ExportDraft(BaseModel): servicios: list[ServicioDraft] = Field(default_factory=list) planBucefaloNivel: str | None = None observaciones: str = "" + esDoble: bool = False + opciones: dict[str, MetaOpcionDraft] | None = None class FinanciamientoRequest(BaseModel): diff --git a/api/app/routers/catalogo.py b/api/app/routers/catalogo.py index f905e4e..e73932c 100644 --- a/api/app/routers/catalogo.py +++ b/api/app/routers/catalogo.py @@ -47,6 +47,7 @@ def _row_to_response(row, categoria_row=None) -> ServicioCatalogoResponse: entregablesDefault=entregables, categoriaId=row["categoriaId"], variante=row["variante"], + nivel=row["nivel"], activo=row["activo"], orden=row["orden"], createdAt=row["createdAt"], @@ -99,16 +100,16 @@ async def list_catalogo( *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)) + # Una sola query para todas las categorías referenciadas (evita N+1). + cat_ids = list({r["categoriaId"] for r in rows if r["categoriaId"]}) + cat_map: dict = {} + if cat_ids: + cat_rows = await db.fetch( + 'SELECT * FROM "Categoria" WHERE id = ANY($1::text[])', cat_ids + ) + cat_map = {c["id"]: c for c in cat_rows} - return result + return [_row_to_response(r, cat_map.get(r["categoriaId"])) for r in rows] @router.post( @@ -134,8 +135,8 @@ async def create_catalogo( 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 *", + '"entregablesDefault", "categoriaId", variante, nivel, activo, orden) ' + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true, $11) RETURNING *", body.nombre, body.descripcion, body.fase, @@ -145,6 +146,7 @@ async def create_catalogo( entregables_json, body.categoriaId, body.variante, + body.nivel, body.orden, ) @@ -218,6 +220,7 @@ async def update_catalogo( "entregablesDefault": '"entregablesDefault"', "categoriaId": '"categoriaId"', "variante": "variante", + "nivel": "nivel", "activo": "activo", "orden": "orden", } diff --git a/api/app/routers/cotizaciones.py b/api/app/routers/cotizaciones.py index d8c0a20..7f2794f 100644 --- a/api/app/routers/cotizaciones.py +++ b/api/app/routers/cotizaciones.py @@ -26,8 +26,66 @@ def _cuid() -> str: return uuid.uuid4().hex[:25] +async def _insert_servicio_cotizado(conn, cotizacion_id: str, svc, bucefalo_servicio_id, now, es_doble: bool = False) -> None: + """Inserta un ServicioCotizado desde un ServicioCotizadoInput, manejando + partidas personalizadas (por horas / retainer) en paridad con el backend Next.js.""" + es_personalizado = bool(getattr(svc, "esPersonalizado", False)) or svc.catalogoId.startswith("custom-") + + if es_personalizado: + catalogo_id = None + elif svc.catalogoId.startswith("bucefalo-") and bucefalo_servicio_id: + catalogo_id = bucefalo_servicio_id + else: + catalogo_id = svc.catalogoId + + modelo_cobro = svc.modeloCobro or ("horas" if es_personalizado else "fijo") + # Doble propuesta: por defecto "ambas"; None en cotizaciones normales. + opcion = (svc.opcion or "ambas") if es_doble else None + + await conn.execute( + """ + INSERT INTO "ServicioCotizado" + (id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado", + horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas", + opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas, + seleccionado, "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,true,$18,$18) + """, + _cuid(), + cotizacion_id, + catalogo_id, + svc.nombre if es_personalizado else None, + es_personalizado, + svc.horas if es_personalizado else None, + svc.tarifaHora if es_personalizado else None, + modelo_cobro, + svc.montoMinimo if es_personalizado else None, + svc.horasIncluidas if es_personalizado else None, + opcion, + svc.fase, + svc.tipoPago, + svc.precio, + svc.tiempoEntrega, + json.dumps(svc.entregables), + None, + now, + ) + + +def _parse_json(val): + """asyncpg devuelve columnas jsonb como str; las parsea a dict/list.""" + if isinstance(val, str): + try: + return json.loads(val) + except (ValueError, TypeError): + return None + return val + + def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan=None) -> dict: d = dict(cot) + if "opcionesMetadata" in d: + d["opcionesMetadata"] = _parse_json(d["opcionesMetadata"]) d["cliente"] = dict(cliente) if cliente else None d["asesor"] = dict(asesor) if asesor else None d["servicios"] = [dict(s) for s in servicios] if servicios else [] @@ -35,6 +93,18 @@ def _build_cotizacion_dict(cot, cliente=None, asesor=None, servicios=None, plan= return d +# LEFT JOIN: los servicios personalizados no tienen servicioCatalogoId y un JOIN +# interno los dejaría fuera de la respuesta. COALESCE conserva el nombre custom. +_SERVICIOS_SQL = """ + SELECT sc.*, sc."servicioCatalogoId" as "catalogoId", + COALESCE(s.nombre, sc.nombre) AS nombre, s.descripcion, s."categoriaId" + FROM "ServicioCotizado" sc + LEFT JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId" + WHERE sc."cotizacionId" = {} + ORDER BY sc.fase, sc.opcion, sc.id +""" + + async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | None: cot = await conn.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id) if not cot: @@ -42,22 +112,46 @@ async def _fetch_cotizacion_full(conn, cot_id: str) -> dict | 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, - ) + servicios = await conn.fetch(_SERVICIOS_SQL.format("$1"), cot_id) plan = await conn.fetchrow('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1', cot_id) return _build_cotizacion_dict(cot, cliente, asesor, servicios, plan) +async def _fetch_cotizaciones_full(conn, cot_rows) -> list[dict]: + """Versión batch de _fetch_cotizacion_full: 4 queries en total para toda la + página en lugar de 4 por cotización (evita N+1 en el listado).""" + if not cot_rows: + return [] + + cot_ids = [r["id"] for r in cot_rows] + cliente_ids = list({r["clienteId"] for r in cot_rows}) + asesor_ids = list({r["asesorId"] for r in cot_rows}) + + clientes = await conn.fetch('SELECT * FROM "Cliente" WHERE id = ANY($1::text[])', cliente_ids) + asesores = await conn.fetch('SELECT id, email, name, role FROM "User" WHERE id = ANY($1::text[])', asesor_ids) + servicios = await conn.fetch(_SERVICIOS_SQL.format("ANY($1::text[])"), cot_ids) + planes = await conn.fetch('SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = ANY($1::text[])', cot_ids) + + clientes_map = {c["id"]: c for c in clientes} + asesores_map = {a["id"]: a for a in asesores} + planes_map = {p["cotizacionId"]: p for p in planes} + servicios_map: dict[str, list] = {} + for s in servicios: + servicios_map.setdefault(s["cotizacionId"], []).append(s) + + return [ + _build_cotizacion_dict( + cot, + clientes_map.get(cot["clienteId"]), + asesores_map.get(cot["asesorId"]), + servicios_map.get(cot["id"], []), + planes_map.get(cot["id"]), + ) + for cot in cot_rows + ] + + async def _resolve_bucefalo_servicio(conn) -> str | None: row = await conn.fetchrow( """ @@ -168,11 +262,7 @@ async def list_cotizaciones( pagination.offset, ) - cotizaciones = [] - for r in rows: - full = await _fetch_cotizacion_full(pool, r["id"]) - if full: - cotizaciones.append(full) + cotizaciones = await _fetch_cotizaciones_full(pool, rows) pages = (total + pagination.limit - 1) // pagination.limit if total > 0 else 0 @@ -200,13 +290,16 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir cot_id = _cuid() now = datetime.now(timezone.utc) + opciones_json = None + if body.esDoble and body.opciones: + opciones_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()}) await conn.execute( """ INSERT INTO "Cotizacion" (id, numero, fecha, vigencia, moneda, "tipoCambio", proyecto, "esquemaPago", - estado, "incluirBonos", "incluirFinanciamiento", observaciones, - "clienteId", "asesorId", "createdAt", "updatedAt") - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$14) + estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", + observaciones, "clienteId", "asesorId", "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16) """, cot_id, body.numero, @@ -218,6 +311,8 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir body.esquemaPago, body.incluirBonos, body.incluirFinanciamiento, + body.esDoble, + opciones_json, body.observaciones or None, cliente_id, body.asesorId, @@ -227,28 +322,7 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir 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, - ) + await _insert_servicio_cotizado(conn, cot_id, svc, bucefalo_servicio_id, now, body.esDoble) if body.planBucefalo: await conn.execute( @@ -265,7 +339,9 @@ async def create_cotizacion(body: CotizacionCreate, _auth: dict = Depends(requir ) row = await pool.fetchrow('SELECT * FROM "Cotizacion" WHERE id = $1', cot_id) - return dict(row) + result = dict(row) + result["opcionesMetadata"] = _parse_json(result.get("opcionesMetadata")) + return result # --------------------------------------------------------------------------- @@ -325,9 +401,20 @@ async def update_cotizacion( _add("esquemaPago", '"esquemaPago"', body.esquemaPago) _add("incluirBonos", '"incluirBonos"', body.incluirBonos) _add("incluirFinanciamiento", '"incluirFinanciamiento"', body.incluirFinanciamiento) + _add("esDoble", '"esDoble"', body.esDoble) _add("observaciones", "observaciones", body.observaciones) _add("estado", "estado", body.estado) + es_doble_final = body.esDoble if body.esDoble is not None else existing["esDoble"] + if body.esDoble is not None: + # opcionesMetadata se sincroniza con esDoble: dict (json) si doble, NULL si no. + meta_json = None + if es_doble_final and body.opciones: + meta_json = json.dumps({k: v.model_dump() for k, v in body.opciones.items()}) + updates.append(f'"opcionesMetadata" = ${idx}') + params.append(meta_json) + idx += 1 + if cliente_id != existing["clienteId"]: updates.append(f'"clienteId" = ${idx}') params.append(cliente_id) @@ -348,27 +435,7 @@ async def update_cotizacion( 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, - ) + await _insert_servicio_cotizado(conn, cotizacion_id, svc, bucefalo_servicio_id, now, es_doble_final) if body.planBucefalo is not None: existing_plan = await conn.fetchrow( @@ -505,9 +572,9 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require """ 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) + estado, "incluirBonos", "incluirFinanciamiento", "esDoble", "opcionesMetadata", + observaciones, "clienteId", "asesorId", "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'borrador',$9,$10,$11,$12,$13,$14,$15,$16,$16) """, new_id, new_numero, @@ -519,6 +586,8 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require original["esquemaPago"], original["incluirBonos"], original["incluirFinanciamiento"], + original["esDoble"], + original["opcionesMetadata"], original["observaciones"], original["clienteId"], original["asesorId"], @@ -529,13 +598,23 @@ async def duplicate_cotizacion(cotizacion_id: str, _auth: dict = Depends(require 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) + (id, "cotizacionId", "servicioCatalogoId", nombre, "esPersonalizado", + horas, "tarifaHora", "modeloCobro", "montoMinimo", "horasIncluidas", + opcion, fase, "tipoPago", precio, "tiempoEntrega", entregables, notas, + seleccionado, "createdAt", "updatedAt") + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$19) """, _cuid(), new_id, svc["servicioCatalogoId"], + svc["nombre"], + svc["esPersonalizado"], + svc["horas"], + svc["tarifaHora"], + svc["modeloCobro"], + svc["montoMinimo"], + svc["horasIncluidas"], + svc["opcion"], svc["fase"], svc["tipoPago"], svc["precio"], diff --git a/api/app/routers/export_.py b/api/app/routers/export_.py index 8849764..58a28f9 100644 --- a/api/app/routers/export_.py +++ b/api/app/routers/export_.py @@ -69,9 +69,20 @@ def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) -> "precio": s.precio, "tiempoEntrega": s.tiempoEntrega, "entregables": s.entregables, + "esPersonalizado": s.esPersonalizado, + "horas": s.horas, + "tarifaHora": s.tarifaHora, + "modeloCobro": s.modeloCobro, + "montoMinimo": s.montoMinimo, + "horasIncluidas": s.horasIncluidas, + "opcion": s.opcion, } for s in draft.servicios ], + "esDoble": draft.esDoble, + "opcionesMetadata": ( + {k: v.model_dump() for k, v in draft.opciones.items()} if draft.esDoble and draft.opciones else None + ), "planBucefaloNivel": draft.planBucefaloNivel, "planBucefaloPrecio": bucefalo_precio(draft.planBucefaloNivel) if draft.planBucefaloNivel else 0, "incluirBonos": draft.incluirBonos, @@ -81,6 +92,7 @@ def _build_pdf_data_from_draft(draft: ExportDraft, branding: dict[str, str]) -> "logoMime": logo_mime, "configBancaria": branding, "razonSocial": branding.get("razon_social", "Cotizador E3"), + "domicilioFiscal": branding.get("domicilio_fiscal"), } @@ -99,11 +111,13 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st servicios = await db.fetch( """SELECT sc.fase, sc."tipoPago", sc.precio, sc."tiempoEntrega", sc.entregables, - s.nombre + sc."esPersonalizado", sc.horas, sc."tarifaHora", sc."modeloCobro", + sc."montoMinimo", sc."horasIncluidas", sc.opcion, + COALESCE(s.nombre, sc.nombre) AS nombre FROM "ServicioCotizado" sc - JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId" + LEFT JOIN "ServicioCatalogo" s ON s.id = sc."servicioCatalogoId" WHERE sc."cotizacionId" = $1 AND sc.seleccionado = true - ORDER BY sc.fase, sc.id""", + ORDER BY sc.fase, sc.opcion, sc.id""", cotizacion_id, ) @@ -133,14 +147,28 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st except (json.JSONDecodeError, TypeError): ent = [] servicios_list.append({ - "nombre": s["nombre"], + "nombre": s["nombre"] or "Servicio", "fase": s["fase"], "tipoPago": s["tipoPago"], "precio": float(s["precio"]), "tiempoEntrega": s["tiempoEntrega"], "entregables": ent or [], + "esPersonalizado": s["esPersonalizado"], + "horas": float(s["horas"]) if s["horas"] is not None else None, + "tarifaHora": float(s["tarifaHora"]) if s["tarifaHora"] is not None else None, + "modeloCobro": s["modeloCobro"], + "montoMinimo": float(s["montoMinimo"]) if s["montoMinimo"] is not None else None, + "horasIncluidas": float(s["horasIncluidas"]) if s["horasIncluidas"] is not None else None, + "opcion": s["opcion"], }) + opciones_meta = cot["opcionesMetadata"] + if isinstance(opciones_meta, str): + try: + opciones_meta = json.loads(opciones_meta) + except (json.JSONDecodeError, TypeError): + opciones_meta = None + plan_nivel = plan["nivel"] if plan else None return { @@ -155,6 +183,8 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st "proyecto": cot["proyecto"], "esquemaPago": cot["esquemaPago"], "servicios": servicios_list, + "esDoble": cot["esDoble"], + "opcionesMetadata": opciones_meta, "planBucefaloNivel": plan_nivel, "planBucefaloPrecio": float(plan["precio"]) if plan else 0, "incluirBonos": cot["incluirBonos"], @@ -164,6 +194,7 @@ async def _build_pdf_data_from_db(db: Connection, cotizacion_id: str) -> dict[st "logoMime": logo_mime, "configBancaria": branding, "razonSocial": branding.get("razon_social", "Cotizador E3"), + "domicilioFiscal": branding.get("domicilio_fiscal"), } diff --git a/api/app/routers/paquetes.py b/api/app/routers/paquetes.py index 22b0bf2..1948eea 100644 --- a/api/app/routers/paquetes.py +++ b/api/app/routers/paquetes.py @@ -19,96 +19,111 @@ 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"] +async def _build_paquetes_responses(conn, paquete_rows: list[dict]) -> list[dict]: + """Build PaqueteResponse dicts for several paquetes with 2 queries in total + (instead of 1 + N fases per paquete).""" + if not paquete_rows: + return [] + paquete_ids = [p["id"] for p in paquete_rows] fases_rows = await conn.fetch( 'SELECT id, "paqueteId", nombre, orden, "createdAt", "updatedAt"' - ' FROM "FasePaquete" WHERE "paqueteId" = $1 ORDER BY orden ASC', - paquete_id, + ' FROM "FasePaquete" WHERE "paqueteId" = ANY($1::text[]) ORDER BY orden ASC', + paquete_ids, ) - fases: list[dict] = [] - for fase in fases_rows: + fase_ids = [f["id"] for f in fases_rows] + servicios_rows = [] + if fase_ids: 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' + ' 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"], + ' WHERE sp."fasePaqueteId" = ANY($1::text[])', + fase_ids, ) - 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, - }, - }) + servicios_por_fase: dict[str, 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_por_fase.setdefault(s["fasePaqueteId"], []).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({ + fases_por_paquete: dict[str, list[dict]] = {} + for fase in fases_rows: + fases_por_paquete.setdefault(fase["paqueteId"], []).append({ "id": fase["id"], "paqueteId": fase["paqueteId"], "nombre": fase["nombre"], "orden": fase["orden"], "createdAt": fase["createdAt"], "updatedAt": fase["updatedAt"], - "servicios": servicios, + "servicios": servicios_por_fase.get(fase["id"], []), }) - 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, - } + return [ + { + "id": p["id"], + "nombre": p["nombre"], + "descripcion": p["descripcion"], + "activo": p["activo"], + "createdAt": p["createdAt"], + "updatedAt": p["updatedAt"], + "fases": fases_por_paquete.get(p["id"], []), + } + for p in paquete_rows + ] + + +async def _build_paquete_response(conn, paquete_row: dict) -> dict: + """Build a PaqueteResponse dict with nested fases and servicios.""" + results = await _build_paquetes_responses(conn, [paquete_row]) + return results[0] @router.get("", response_model=list[PaqueteResponse]) @@ -120,10 +135,7 @@ async def list_paquetes( '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 + return await _build_paquetes_responses(conn, [dict(row) for row in rows]) @router.post( diff --git a/api/app/services/calculators.py b/api/app/services/calculators.py index 39e682a..05a3880 100644 --- a/api/app/services/calculators.py +++ b/api/app/services/calculators.py @@ -5,6 +5,19 @@ from datetime import date, datetime IVA_RATE = 0.16 +# Tarifa por hora sugerida para partidas personalizadas (Hora Centinela). +TARIFA_HORA_DEFAULT = 700 + +# Modelos de cobro de una partida (espejo del frontend TS). +MODELOS_COBRO: dict[str, str] = { + "fijo": "Precio fijo", + "horas": "Por horas", + "retainer": "Retainer (minimo + adicionales)", +} + +# Doble propuesta: cada partida va en la Opcion 1, la 2 o en ambas (espejo del TS). +OPCIONES = ("1", "2", "ambas") + FASES: dict[int, str] = { 0: "FASE 0 - Auditoria / Acompanamiento", 1: "FASE 1 - Setup e Infraestructura", @@ -45,6 +58,53 @@ BONOS = [ ] +def calcular_precio_horas(horas: float, tarifa_hora: float) -> float: + return round((horas or 0) * (tarifa_hora or 0), 2) + + +def describir_retainer(monto_minimo: float, horas_incluidas: float, tarifa_hora: float) -> str: + """Texto descriptivo de un retainer (espejo de describirRetainer en TS).""" + partes = [f"{format_currency(monto_minimo or 0)}/mes"] + if horas_incluidas: + partes.append(f"incluye {horas_incluidas:g} hr") + if tarifa_hora: + partes.append(f"adicional {format_currency(tarifa_hora)}/hr (se factura aparte)") + return " · ".join(partes) + + +def detalle_modelo(serv: dict) -> str: + """Sub-linea de desglose por modelo de cobro para PDF/Excel.""" + modelo = serv.get("modeloCobro") + if modelo == "retainer": + return describir_retainer( + serv.get("montoMinimo") or 0, + serv.get("horasIncluidas") or 0, + serv.get("tarifaHora") or 0, + ) + horas = serv.get("horas") + tarifa = serv.get("tarifaHora") + if (modelo == "horas" or serv.get("esPersonalizado")) and horas and tarifa: + return f"{horas:g} h x {format_currency(tarifa)}/hr" + return "" + + +def calcular_totales_opcion(servicios: list[dict], opcion: str) -> dict: + """Totales de una opcion: suma sus partidas + las marcadas 'ambas'. + + Espejo de calcularTotalesOpcion en TS. Respeta la regla de retainer: el total + usa `precio`, no `horas x tarifa`. Las horas son informativas. + """ + rel = [s for s in servicios if s.get("opcion") == "ambas" or s.get("opcion") == opcion] + total_unico = sum((s.get("precio") or 0) for s in rel if s.get("tipoPago") == "unico") + total_mensual = sum((s.get("precio") or 0) for s in rel if s.get("tipoPago") == "mensual") + horas = sum((s.get("horas") or 0) for s in rel) + return { + "totalUnico": round(total_unico, 2), + "totalMensual": round(total_mensual, 2), + "horas": round(horas, 2), + } + + def bucefalo_precio(nivel: str) -> float: for p in PLANES_BUCEFALO: if p["nivel"] == nivel: diff --git a/api/app/services/excel_generator.py b/api/app/services/excel_generator.py index db9a4ae..2ff5c93 100644 --- a/api/app/services/excel_generator.py +++ b/api/app/services/excel_generator.py @@ -1,26 +1,27 @@ -"""Excel Generator for Cotizador E3 — port of export/excel/route.ts using openpyxl.""" +"""Excel Generator for Cotizador E3 — port of src/lib/excel-builder.ts using openpyxl. + +Mantiene paridad de layout con el builder de TypeScript: mismas columnas, merges, +celdas dinámicas (wrapText + altura calculada) para que el texto nunca se salga. +""" from __future__ import annotations import io +import math 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 openpyxl.utils import column_index_from_string, get_column_letter +from openpyxl.worksheet.worksheet import Worksheet -from app.services.calculators import FASES_SHORT, bucefalo_precio +from app.services.calculators import bucefalo_precio, detalle_modelo, calcular_totales_opcion, format_currency 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: @@ -34,15 +35,58 @@ def _thin_border(color: str = "FFD1D5DB") -> Border: def _sanitize_sheet_name(name: str) -> str: - name = name[:31] - return re.sub(r"[/\\*?\[\]:]", "", name) + name = (name or "Servicio")[:31] + return re.sub(r"[/\\*?\[\]:]", "", name) or "Servicio" -def _set_col_widths(ws, widths: list[tuple[str, float]]): +def _set_col_widths(ws: Worksheet, widths: list[tuple[str, float]]): for col_letter, width in widths: ws.column_dimensions[col_letter].width = width +def _merged_width(widths: dict[str, float], start_col: str, end_col: str) -> float: + """Suma de anchos (unidades de carácter de Excel) entre dos columnas, inclusive.""" + start = column_index_from_string(start_col) + end = column_index_from_string(end_col) + return sum(widths.get(get_column_letter(c), 8) for c in range(start, end + 1)) + + +def _estimate_row_height(text: str, width_chars: float, font_size: int = 10) -> float: + """Altura (pt) que necesita una fila para mostrar `text` envuelto en `width_chars`.""" + cpl = max(8, int(width_chars)) + if font_size <= 10: + line_h = 14.0 + elif font_size <= 11: + line_h = 15.5 + elif font_size <= 12: + line_h = 17.0 + else: + line_h = 20.0 + lines = 0 + for seg in str(text or "").split("\n"): + lines += max(1, math.ceil(len(seg) / cpl)) + return max(16.0, round(lines * line_h + 4)) + + +def _set_wrapped( + ws: Worksheet, + row: int, + col: str, + text: str, + width_chars: float, + font_size: int = 10, + horizontal: str = "left", + vertical: str = "top", +): + """Marca la celda como envolvente y agranda la fila si el texto lo requiere.""" + cell = ws[f"{col}{row}"] + cell.alignment = Alignment(wrap_text=True, vertical=vertical, horizontal=horizontal) + needed = _estimate_row_height(text, width_chars, font_size) + current = ws.row_dimensions[row].height or 0 + if needed > current: + ws.row_dimensions[row].height = needed + + def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> bytes: """Generate a multi-sheet Excel workbook for a quotation. @@ -66,6 +110,7 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte 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) + iva_font = Font(italic=False, size=9, name="Calibri", color=MUTED) 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") @@ -74,6 +119,7 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte white_fill = PatternFill(start_color=WHITE, end_color=WHITE, fill_type="solid") razon_social = data.get("razonSocial", "Cotizador E3") + domicilio_fiscal = data.get("domicilioFiscal") cliente_nombre = data.get("clienteNombre", "") cliente_empresa = data.get("clienteEmpresa", "") asesor_nombre = data.get("asesorNombre", "") @@ -85,24 +131,26 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte esquema = data.get("esquemaPago", "Pago Unico/Mensual") servicios = data.get("servicios", []) plan_nivel = data.get("planBucefaloNivel") + plan_precio = data.get("planBucefaloPrecio") 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 ────────────────────────────── + # ═══════════════════════════════════════════════════════════════════════════ + # 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), - ]) + resumen_widths = { + "A": 3, "B": 18, "C": 26, "D": 13, "E": 16, + "F": 22, "G": 6, "H": 6, "I": 6, "J": 6, "K": 16, + } + _set_col_widths(ws, list(resumen_widths.items())) - # Header banner + # Banner ws.merge_cells("B2:K2") cell = ws["B2"] cell.value = razon_social @@ -110,6 +158,7 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte cell.alignment = Alignment(vertical="center") for col in range(1, 12): ws.cell(row=2, column=col).fill = primary_fill + ws.row_dimensions[2].height = 28 ws.merge_cells("B3:K3") cell = ws["B3"] @@ -122,217 +171,366 @@ def generate_cotizacion_excel(data: dict[str, Any], saved: bool = False) -> byte # Info section info_start = 5 info_rows = [ - [("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "\u2014")], + [("B", "En atencion a:"), ("C", cliente_nombre), ("E", "Empresa:"), ("F", cliente_empresa or "—")], [("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)], + [("B", "Fecha:"), ("C", ""), ("E", "Esquema de Pago:"), ("F", esquema)], ] + c_val_w = _merged_width(resumen_widths, "C", "C") + f_val_w = _merged_width(resumen_widths, "F", "F") 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 + (lc1, lv1), (vc1, vv1), (lc2, lv2), (vc2, vv2) = row_data + ws[f"{lc1}{r}"].value = lv1 + ws[f"{lc1}{r}"].font = label_font + ws[f"{vc1}{r}"].value = vv1 + ws[f"{vc1}{r}"].font = value_font + if vv1: + _set_wrapped(ws, r, vc1, vv1, c_val_w) + ws[f"{lc2}{r}"].value = lv2 + ws[f"{lc2}{r}"].font = label_font + ws[f"{vc2}{r}"].value = vv2 + ws[f"{vc2}{r}"].font = value_font + if vv2: + _set_wrapped(ws, r, vc2, vv2, f_val_w) - # 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 + # Fecha (en la fila "Fecha:") + fecha_cell = ws[f"C{info_start + 3}"] + fecha_cell.value = fecha + fecha_cell.number_format = "DD/MM/YYYY" + fecha_cell.font = value_font + + vig_row = info_start + 4 + ws[f"B{vig_row}"].value = "Vigencia:" + ws[f"B{vig_row}"].font = label_font + ws[f"C{vig_row}"].value = vigencia + ws[f"C{vig_row}"].number_format = "DD/MM/YYYY" + ws[f"C{vig_row}"].font = value_font # Services table table_start = info_start + 6 - cols = ["B", "C", "D", "K"] - headers = ["Fase", "Tipo de Pago", "Servicio", "Precio"] + col_b = column_index_from_string("B") + col_k = column_index_from_string("K") + + def style_table_row(row_num: int, fill: PatternFill, border_color: str): + """Estiliza (relleno + borde) todo el ancho de la fila, de B a K.""" + border = _thin_border(border_color) + for c in range(col_b, col_k + 1): + cell = ws.cell(row=row_num, column=c) + cell.fill = fill + cell.border = border ws.merge_cells(f"D{table_start}:J{table_start}") - for i, col_letter in enumerate(cols): + style_table_row(table_start, primary_fill, PRIMARY) + for col_letter, txt, align in [ + ("B", "Fase", "left"), + ("C", "Tipo de Pago", "left"), + ("D", "Servicio", "left"), + ("K", "Precio", "right"), + ]: cell = ws[f"{col_letter}{table_start}"] - cell.value = headers[i] + cell.value = txt 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) + cell.alignment = Alignment(horizontal=align, vertical="center") 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"] + serv_width = _merged_width(resumen_widths, "D", "J") + es_doble = bool(data.get("esDoble")) + opciones_meta = data.get("opcionesMetadata") or {} 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) + # Totales (helpers reutilizados por modo normal y doble propuesta) + def write_total_row(label: str, amount: float, font: Font): + nonlocal row + ws.merge_cells(f"B{row}:J{row}") + ws[f"B{row}"].value = label + ws[f"B{row}"].font = font + ws[f"B{row}"].alignment = Alignment(vertical="center") + ws[f"K{row}"].value = amount 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) + ws[f"K{row}"].font = font + ws[f"K{row}"].alignment = Alignment(horizontal="right", vertical="center") + style_table_row(row, primary_light_fill, PRIMARY) 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 + def write_iva_note(): + nonlocal row + ws.merge_cells(f"B{row}:J{row}") + ws[f"B{row}"].value = "(+ IVA)" + ws[f"B{row}"].font = iva_font + 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 + def draw_service_rows(servs): + nonlocal row + current_fase = -1 + for serv in servs: + 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 + ws[f"B{row}"].alignment = Alignment(vertical="center") + for col in range(2, 12): + ws.cell(row=row, column=col).fill = primary_light_fill + ws.row_dimensions[row].height = 20 + row += 1 + + row_bg = light_fill if row % 2 == 0 else white_fill + ws.merge_cells(f"D{row}:J{row}") + style_table_row(row, row_bg, BORDER_COLOR) + ws[f"C{row}"].value = "Unico" if serv.get("tipoPago") == "unico" else "Mensual" + ws[f"C{row}"].font = value_font + ws[f"C{row}"].alignment = Alignment(vertical="top") + _detalle = detalle_modelo(serv) + serv_text = f"{serv.get('nombre', '')} — {_detalle}" if _detalle else serv.get("nombre", "") + ws[f"D{row}"].value = serv_text + ws[f"D{row}"].font = value_font + _set_wrapped(ws, row, "D", serv_text, serv_width) + 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", vertical="top") + row += 1 + + def write_opcion_banner(op: str): + nonlocal row + meta = opciones_meta.get(op) or {} + ws.merge_cells(f"B{row}:K{row}") + titulo = meta.get("titulo") + ws[f"B{row}"].value = f"OPCION {op}" + (f": {titulo}" if titulo else "") + ws[f"B{row}"].font = Font(bold=True, size=12, name="Calibri", color=WHITE) + ws[f"B{row}"].alignment = Alignment(vertical="center") + for col in range(2, 12): + ws.cell(row=row, column=col).fill = secondary_fill + ws.row_dimensions[row].height = 22 + row += 1 + desc = meta.get("descripcion") + if desc: + ws.merge_cells(f"B{row}:K{row}") + ws[f"B{row}"].value = desc + ws[f"B{row}"].font = value_font + _set_wrapped(ws, row, "B", desc, _merged_width(resumen_widths, "B", "K")) + row += 1 + no_incluye = meta.get("noIncluye") + if no_incluye: + ws.merge_cells(f"B{row}:K{row}") + ws[f"B{row}"].value = f"No incluye: {no_incluye}" + ws[f"B{row}"].font = small_font + _set_wrapped(ws, row, "B", f"No incluye: {no_incluye}", _merged_width(resumen_widths, "B", "K"), font_size=9) + row += 1 + + if es_doble: + for op in ("1", "2"): + u = [s for s in servicios_unicos if s.get("opcion") in (op, "ambas")] + me = [s for s in servicios_mensuales if s.get("opcion") in (op, "ambas")] + write_opcion_banner(op) + draw_service_rows(u + me) + row += 1 + write_total_row(f"Total Pago Unico - Opcion {op}", sum(s.get("precio", 0) for s in u), total_font) + write_iva_note() + write_total_row(f"Total Pago Mensual - Opcion {op}", sum(s.get("precio", 0) for s in me), total_font) + write_iva_note() + row += 1 + + # Comparativa de opciones (totales con IVA + horas) + t1 = calcular_totales_opcion(servicios, "1") + t2 = calcular_totales_opcion(servicios, "2") + ws.merge_cells(f"B{row}:K{row}") + ws[f"B{row}"].value = "COMPARATIVA DE OPCIONES" + ws[f"B{row}"].font = Font(bold=True, size=12, name="Calibri", color=WHITE) + ws[f"B{row}"].alignment = Alignment(vertical="center") + for col in range(2, 12): + ws.cell(row=row, column=col).fill = primary_fill + ws.row_dimensions[row].height = 22 + row += 1 + t1_tit = (opciones_meta.get("1") or {}).get("titulo") + t2_tit = (opciones_meta.get("2") or {}).get("titulo") + + def comp_row(lab: str, v1: str, v2: str, font: Font): + nonlocal row + ws.merge_cells(f"B{row}:F{row}") + ws[f"B{row}"].value = lab + ws[f"B{row}"].font = font + ws.merge_cells(f"G{row}:H{row}") + ws[f"G{row}"].value = v1 + ws[f"G{row}"].font = font + ws[f"G{row}"].alignment = Alignment(horizontal="right") + ws.merge_cells(f"I{row}:K{row}") + ws[f"I{row}"].value = v2 + ws[f"I{row}"].font = font + ws[f"I{row}"].alignment = Alignment(horizontal="right") + border = _thin_border(BORDER_COLOR) + for col in range(col_b, col_k + 1): + ws.cell(row=row, column=col).border = border + row += 1 + + comp_row("Concepto", "Opcion 1" + (f" - {t1_tit}" if t1_tit else ""), "Opcion 2" + (f" - {t2_tit}" if t2_tit else ""), bold_font) + comp_row("Total unico (c/IVA)", format_currency(t1["totalUnico"] * 1.16), format_currency(t2["totalUnico"] * 1.16), value_font) + comp_row("Total mensual (c/IVA)", format_currency(t1["totalMensual"] * 1.16), format_currency(t2["totalMensual"] * 1.16), value_font) + comp_row("Horas estimadas", f"{t1['horas']:g} h", f"{t2['horas']:g} h", value_font) + row += 1 + else: + draw_service_rows(servicios_unicos + servicios_mensuales) + row += 1 + write_total_row("Total Pago Unico", sum(s.get("precio", 0) for s in servicios_unicos), total_font) + write_iva_note() + write_total_row("Total Pago Mensual", sum(s.get("precio", 0) for s in servicios_mensuales), total_font) + write_iva_note() + row += 1 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 + precio = plan_precio if plan_precio is not None else bucefalo_precio(plan_nivel) + write_total_row(f"CRM Bucefalo - {plan_nivel.capitalize()}", precio, bold_font) + row += 1 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." + nota_resumen = "Todos los precios son en Moneda Nacional (MX), los precios no incluyen IVA. Vigencia de 15 dias habiles." + ws[f"B{row}"].value = nota_resumen ws[f"B{row}"].font = small_font + _set_wrapped(ws, row, "B", nota_resumen, _merged_width(resumen_widths, "B", "K"), font_size=9) - # ── DETAIL SHEETS ────────────────────────────── + # ═══════════════════════════════════════════════════════════════════════════ + # HOJAS DETALLADAS POR SERVICIO + # ═══════════════════════════════════════════════════════════════════════════ + detail_widths = { + "A": 3, "B": 16, "C": 30, "D": 8, "E": 4, + "F": 18, "G": 22, "H": 6, "I": 6, "J": 16, + } + used_names: set[str] = set() for serv in servicios: - safe_name = _sanitize_sheet_name(serv.get("nombre", "Servicio")) + base = _sanitize_sheet_name(serv.get("nombre", "Servicio")) + safe_name = base + n = 2 + while safe_name.lower() in used_names: + suffix = f" ({n})" + safe_name = base[: 31 - len(suffix)] + suffix + n += 1 + used_names.add(safe_name.lower()) + 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), - ]) + _set_col_widths(dw, list(detail_widths.items())) 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', '')}" + banner_text = f"{FASES_MAP.get(fase, f'FASE {fase}')} — {serv.get('nombre', '')}" + dw["B1"].value = banner_text dw["B1"].font = Font(bold=True, size=14, name="Calibri", color=WHITE) - dw["B1"].alignment = Alignment(vertical="center") + dw["B1"].alignment = Alignment(vertical="center", wrap_text=True) for col in range(1, 11): dw.cell(row=1, column=col).fill = primary_fill - dw.row_dimensions[1].height = 30 + dw.row_dimensions[1].height = max( + 30, _estimate_row_height(banner_text, _merged_width(detail_widths, "B", "J"), 14) + ) + + c_val = _merged_width(detail_widths, "C", "D") + g_val = _merged_width(detail_widths, "G", "H") r = 3 dw[f"B{r}"].value = "Cliente:" dw[f"B{r}"].font = label_font + dw.merge_cells(f"C{r}:D{r}") dw[f"C{r}"].value = cliente_empresa or cliente_nombre dw[f"C{r}"].font = bold_font + _set_wrapped(dw, r, "C", cliente_empresa or cliente_nombre, c_val) dw[f"F{r}"].value = "No. Cotizacion:" dw[f"F{r}"].font = label_font + dw.merge_cells(f"G{r}:H{r}") 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.merge_cells(f"C{r}:D{r}") dw[f"C{r}"].value = serv.get("nombre", "") dw[f"C{r}"].font = Font(bold=True, size=12, name="Calibri", color=PRIMARY) + _set_wrapped(dw, r, "C", serv.get("nombre", ""), c_val, font_size=12) dw[f"F{r}"].value = "Tiempo de Entrega:" dw[f"F{r}"].font = label_font + dw.merge_cells(f"G{r}:H{r}") dw[f"G{r}"].value = serv.get("tiempoEntrega", "") dw[f"G{r}"].font = value_font + _set_wrapped(dw, r, "G", serv.get("tiempoEntrega", ""), g_val) 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" + _detalle_serv = detalle_modelo(serv) + if _detalle_serv: + dw[f"B{r}"].value = "Detalle:" + dw[f"B{r}"].font = label_font + dw.merge_cells(f"C{r}:J{r}") + dw[f"C{r}"].value = _detalle_serv + dw[f"C{r}"].font = value_font + _set_wrapped(dw, r, "C", _detalle_serv, _merged_width(detail_widths, "C", "J")) + r += 2 + + # Encabezado de entregables — barra completa B:J + dw.merge_cells(f"B{r}:J{r}") + dw[f"B{r}"].value = "Entregables" + dw[f"B{r}"].font = header_font + dw[f"B{r}"].alignment = Alignment(vertical="center") + for col in range(column_index_from_string("B"), column_index_from_string("J") + 1): + dw.cell(row=r, column=col).fill = primary_fill + dw.row_dimensions[r].height = 22 r += 1 + ent_width = _merged_width(detail_widths, "B", "J") entregables = serv.get("entregables", []) + b_idx = column_index_from_string("B") + j_idx = column_index_from_string("J") for i, ent in enumerate(entregables): + er = r + i + dw.merge_cells(f"B{er}:J{er}") + txt = f"{i + 1}. {ent}" + dw[f"B{er}"].value = txt + dw[f"B{er}"].font = value_font + _set_wrapped(dw, er, "B", txt, ent_width) 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) + for col in range(b_idx, j_idx + 1): + dw.cell(row=er, column=col).fill = bg + dw.cell(row=er, column=col).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) + # Total: etiqueta B:H + precio I:J + dw.merge_cells(f"B{r}:H{r}") 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"B{r}"].alignment = Alignment(vertical="center") + dw.merge_cells(f"I{r}:J{r}") 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") + dw[f"I{r}"].alignment = Alignment(horizontal="right", vertical="center") + for col in range(b_idx, j_idx + 1): + dw.cell(row=r, column=col).fill = primary_light_fill + dw.cell(row=r, column=col).border = _thin_border(PRIMARY) r += 1 dw[f"B{r}"].value = "(+ IVA)" - dw[f"B{r}"].font = Font(italic=False, size=9, name="Calibri", color=MUTED) + dw[f"B{r}"].font = iva_font 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." + nota_detalle = "Esta cotizacion tiene una vigencia de 15 dias habiles. Todos los precios en MXN, precios + IVA." + dw[f"B{r}"].value = nota_detalle dw[f"B{r}"].font = small_font + _set_wrapped(dw, r, "B", nota_detalle, ent_width, font_size=9) r += 2 + dw.merge_cells(f"B{r}:J{r}") dw[f"B{r}"].value = razon_social dw[f"B{r}"].font = Font(bold=True, size=10, name="Calibri", color=PRIMARY) + if domicilio_fiscal: + r += 1 + dw.merge_cells(f"B{r}:J{r}") + dw[f"B{r}"].value = domicilio_fiscal + dw[f"B{r}"].font = iva_font + _set_wrapped(dw, r, "B", domicilio_fiscal, ent_width, font_size=9) output = io.BytesIO() wb.save(output) diff --git a/api/app/services/pdf_generator.py b/api/app/services/pdf_generator.py index 0fd7b5f..d0dacfc 100644 --- a/api/app/services/pdf_generator.py +++ b/api/app/services/pdf_generator.py @@ -13,7 +13,7 @@ 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 +from app.services.calculators import FASES_SHORT, PLANES_BUCEFALO, bucefalo_precio, detalle_modelo, calcular_totales_opcion PRIMARY_DEFAULT = "#2563eb" DARK_DEFAULT = "#1e293b" @@ -40,6 +40,23 @@ def _fmt_currency(n: float) -> str: return f"${n:,.2f}" +def _wrap_text(c, text: str, font: str, size: float, max_width: float) -> list[str]: + """Envuelve texto en lineas que caben en max_width (respeta saltos de linea).""" + lines: list[str] = [] + for paragraph in str(text or "").split("\n"): + words = paragraph.split() + line = "" + for word in words: + test = f"{line} {word}".strip() + if c.stringWidth(test, font, size) > max_width and line: + lines.append(line) + line = word + else: + line = test + lines.append(line) + return lines + + def _fmt_date(d: datetime) -> str: months = [ "enero", "febrero", "marzo", "abril", "mayo", "junio", @@ -187,21 +204,23 @@ def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes: 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 + es_doble = bool(data.get("esDoble")) + opciones_meta = data.get("opcionesMetadata") or {} - c.setStrokeColor(border_rgb) - c.setLineWidth(0.3) - c.line(MARGIN_LEFT, y, MARGIN_LEFT + CONTENT_W, y) - y -= 14 + def _draw_table_header(y_pos): + c.setFillColor(light_rgb) + c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 16, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 7) + c.setFillColor(dark_rgb) + c.drawString(col_nombre + 6, y_pos + 3, "Servicio") + c.drawString(col_tipo + 4, y_pos + 3, "Tipo") + c.drawString(col_tiempo + 4, y_pos + 3, "Entrega") + c.drawRightString(col_precio + 60, y_pos + 3, "Precio") + y_pos -= 4 + c.setStrokeColor(border_rgb) + c.setLineWidth(0.3) + c.line(MARGIN_LEFT, y_pos, MARGIN_LEFT + CONTENT_W, y_pos) + return y_pos - 14 unicos = [s for s in servicios if s.get("tipoPago") == "unico"] mensuales = [s for s in servicios if s.get("tipoPago") == "mensual"] @@ -239,6 +258,13 @@ def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes: c.drawRightString(col_precio + 60, y_pos - 10, _fmt_currency(serv.get("precio", 0))) y_pos -= 14 + detalle = detalle_modelo(serv) + if detalle: + c.setFont("Helvetica-Oblique", 6.5) + c.setFillColor(muted_rgb) + c.drawString(col_nombre + 10, y_pos - 7, detalle[:90]) + y_pos -= 9 + entregables = serv.get("entregables", []) if entregables: half = (len(entregables) + 1) // 2 @@ -272,10 +298,99 @@ def generate_cotizacion_pdf(data: dict[str, Any]) -> bytes: 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) + def _draw_opcion_header(op, y_pos): + meta = opciones_meta.get(op) or {} + max_y = PAGE_H - MARGIN_BOTTOM - 5 + if y_pos - 30 < max_y: + c.showPage() + y_pos = PAGE_H - MARGIN_TOP + titulo = meta.get("titulo") + c.setFillColor(primary_rgb) + c.rect(MARGIN_LEFT, y_pos - 18, CONTENT_W, 18, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 10) + c.setFillColor(white_rgb) + c.drawString(MARGIN_LEFT + 8, y_pos - 13, f"OPCION {op}" + (f": {titulo}" if titulo else "")) + y_pos -= 24 + desc = meta.get("descripcion") + if desc: + c.setFont("Helvetica", 7.5) + c.setFillColor(dark_rgb) + for line in _wrap_text(c, desc, "Helvetica", 7.5, CONTENT_W - 12): + c.drawString(MARGIN_LEFT + 6, y_pos - 8, line) + y_pos -= 10 + y_pos -= 2 + no_incluye = meta.get("noIncluye") + if no_incluye: + c.setFont("Helvetica-Oblique", 7) + c.setFillColor(muted_rgb) + for line in _wrap_text(c, f"No incluye: {no_incluye}", "Helvetica-Oblique", 7, CONTENT_W - 12): + c.drawString(MARGIN_LEFT + 6, y_pos - 8, line) + y_pos -= 10 + y_pos -= 4 + return y_pos + + def _draw_comparativa(y_pos): + t1 = calcular_totales_opcion(servicios, "1") + t2 = calcular_totales_opcion(servicios, "2") + max_y = PAGE_H - MARGIN_BOTTOM - 5 + if y_pos - 90 < max_y: + c.showPage() + y_pos = PAGE_H - MARGIN_TOP + y_pos -= 6 + c.setFillColor(primary_rgb) + c.rect(MARGIN_LEFT, y_pos - 10, 3, 10, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 10) + c.setFillColor(dark_rgb) + c.drawString(MARGIN_LEFT + 10, y_pos - 8, "Comparativa de opciones") + y_pos -= 22 + c_label = MARGIN_LEFT + c_op1 = MARGIN_LEFT + CONTENT_W * 0.45 + c_op2 = MARGIN_LEFT + CONTENT_W * 0.72 + t1_tit = (opciones_meta.get("1") or {}).get("titulo") + t2_tit = (opciones_meta.get("2") or {}).get("titulo") + c.setFillColor(light_rgb) + c.rect(MARGIN_LEFT, y_pos - 2, CONTENT_W, 16, fill=1, stroke=0) + c.setFont("Helvetica-Bold", 7.5) + c.setFillColor(dark_rgb) + c.drawString(c_label + 6, y_pos + 3, "Concepto") + c.drawString(c_op1, y_pos + 3, f"Opcion 1" + (f" - {t1_tit}" if t1_tit else "")) + c.drawString(c_op2, y_pos + 3, f"Opcion 2" + (f" - {t2_tit}" if t2_tit else "")) + y_pos -= 18 + filas = [ + ("Total unico (c/IVA)", _fmt_currency(t1["totalUnico"] * 1.16), _fmt_currency(t2["totalUnico"] * 1.16)), + ("Total mensual (c/IVA)", _fmt_currency(t1["totalMensual"] * 1.16), _fmt_currency(t2["totalMensual"] * 1.16)), + ("Horas estimadas", f"{t1['horas']:g} h", f"{t2['horas']:g} h"), + ] + for lab, v1, v2 in filas: + c.setFont("Helvetica", 8) + c.setFillColor(dark_rgb) + c.drawString(c_label + 6, y_pos - 8, lab) + c.setFont("Helvetica-Bold", 8) + c.drawString(c_op1, y_pos - 8, v1) + c.drawString(c_op2, y_pos - 8, v2) + c.setStrokeColor(_hex_to_rgb("#e5e7eb")) + c.setLineWidth(0.2) + c.line(c_label + 6, y_pos - 12, MARGIN_LEFT + CONTENT_W, y_pos - 12) + y_pos -= 14 + return y_pos - 8 + + if es_doble: + for op in ("1", "2"): + y = _draw_opcion_header(op, y) + y = _draw_table_header(y) + u = [s for s in unicos if s.get("opcion") in (op, "ambas")] + me = [s for s in mensuales if s.get("opcion") in (op, "ambas")] + if u: + y = _draw_section(u, "Unico", f"Total Pago Unico - Opcion {op}", y) + if me: + y = _draw_section(me, "Mensual", f"Total Pago Mensual - Opcion {op}", y) + y = _draw_comparativa(y) + else: + y = _draw_table_header(y) + 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() diff --git a/next.config.ts b/next.config.ts index 7793f7d..c366877 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { serverExternalPackages: ["pdfkit"], + poweredByHeader: false, }; export default nextConfig; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a1248c2..aeb38c4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,8 @@ model Cliente { updatedAt DateTime @updatedAt cotizaciones Cotizacion[] + + @@index([nombre, empresa]) } model Cotizacion { @@ -43,6 +45,8 @@ model Cotizacion { estado String @default("borrador") incluirBonos Boolean @default(false) incluirFinanciamiento Boolean @default(false) + esDoble Boolean @default(false) + opcionesMetadata Json? observaciones String? clienteId String asesorId String @@ -53,6 +57,11 @@ model Cotizacion { asesor User @relation("AsesorCotizaciones", fields: [asesorId], references: [id]) servicios ServicioCotizado[] planBucefalo PlanBucefaloCotizacion? + + @@index([clienteId]) + @@index([asesorId]) + @@index([estado]) + @@index([createdAt]) } model Categoria { @@ -89,6 +98,8 @@ model FasePaquete { paquete Paquete @relation(fields: [paqueteId], references: [id], onDelete: Cascade) servicios ServicioPaquete[] + + @@index([paqueteId]) } model ServicioCatalogo { @@ -102,6 +113,7 @@ model ServicioCatalogo { entregablesDefault Json @default("[]") categoriaId String? variante String? + nivel String? activo Boolean @default(true) orden Int @default(0) createdAt DateTime @default(now()) @@ -110,6 +122,9 @@ model ServicioCatalogo { servicios ServicioCotizado[] categoriaRel Categoria? @relation(fields: [categoriaId], references: [id]) paquetes ServicioPaquete[] + + @@index([categoriaId]) + @@index([activo, fase, orden]) } model ServicioPaquete { @@ -123,12 +138,21 @@ model ServicioPaquete { fasePaquete FasePaquete @relation(fields: [fasePaqueteId], references: [id], onDelete: Cascade) @@unique([servicioCatalogoId, fasePaqueteId]) + @@index([fasePaqueteId]) } model ServicioCotizado { id String @id @default(cuid()) cotizacionId String - servicioCatalogoId String + servicioCatalogoId String? + nombre String? + esPersonalizado Boolean @default(false) + horas Float? + tarifaHora Float? + modeloCobro String @default("fijo") + montoMinimo Float? + horasIncluidas Float? + opcion String? fase Int tipoPago String precio Float @@ -139,8 +163,11 @@ model ServicioCotizado { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade) - servicioCatalogo ServicioCatalogo @relation(fields: [servicioCatalogoId], references: [id]) + cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade) + servicioCatalogo ServicioCatalogo? @relation(fields: [servicioCatalogoId], references: [id]) + + @@index([cotizacionId]) + @@index([servicioCatalogoId]) } model PlanBucefaloCotizacion { diff --git a/prisma/seed.ts b/prisma/seed.ts index 682c524..06da428 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -583,14 +583,1031 @@ async function main() { "Reportes mensuales con analisis de resultados Meta Ads", ], }, + + // ────────────────────────────────────────────────────────────── + // SERVICIOS NIVELADOS (Catalogo E3 - Cotizacion Final) + // Precios convertidos de USD a MXN al tipo de cambio 17.30 + // Niveles: Emprendedor / PYME / Empresario / Corporativo + // ────────────────────────────────────────────────────────────── + + // ── LOGO E IDENTIDAD (Fase 1, unico) ── + { + nombre: "Logo e Identidad", + descripcion: "Diseno de identidad de marca y logotipo", + fase: 1, + tipoPago: "unico", + precioBase: 6903, // 399 USD + tiempoEntrega: "7 - 15 dias", + categoria: "diseno", + nivel: "Emprendedor", + orden: 101, + entregablesDefault: [ + "Investigacion de Tendencias, Mercado, Competencia y Demanda", + "Creacion y Propuesta del Naming: 2", + "Propuesta del Logotipo: 2", + "Creacion Logotipo", + "Diseno de Aplicativos Fisicos: 2", + "Diseno de Aplicativos Digitales: 4", + "Manual de Identidad", + "Atencion y asesoria al cliente", + "Revisiones", + ], + }, + { + nombre: "Logo e Identidad", + descripcion: "Diseno de identidad de marca y logotipo", + fase: 1, + tipoPago: "unico", + precioBase: 12093, // 699 USD + tiempoEntrega: "7 - 15 dias", + categoria: "diseno", + nivel: "PYME", + orden: 102, + entregablesDefault: [ + "Investigacion de Tendencias, Mercado, Competencia y Demanda", + "Creacion y Propuesta del Naming: 3", + "Propuesta del Logotipo: 3", + "Creacion Logotipo", + "Aplicativos Fisicos: 4", + "Aplicativos Digitales: 4", + "Manual de Identidad", + "Atencion y asesoria al cliente", + "Revisiones", + "Kit Basico para Redes Sociales (1 portada, 1 foto de perfil, 1 template RSS)", + ], + }, + { + nombre: "Logo e Identidad", + descripcion: "Diseno de identidad de marca y logotipo", + fase: 1, + tipoPago: "unico", + precioBase: 17283, // 999 USD + tiempoEntrega: "7 - 15 dias", + categoria: "diseno", + nivel: "Empresario", + orden: 103, + entregablesDefault: [ + "Investigacion de Tendencias, Mercado, Competencia y Demanda", + "Creacion y Propuesta del Naming: 4", + "Propuesta del Logotipo: 4", + "Creacion Logotipo", + "Aplicativos Fisicos: 6", + "Aplicativos Digitales: 4", + "Manual de Identidad", + "Atencion y asesoria al cliente", + "Revisiones", + "Kit Basico para Redes Sociales (1 portada, 1 foto de perfil, 4 templates RSS)", + ], + }, + { + nombre: "Logo e Identidad", + descripcion: "Diseno de identidad de marca y logotipo", + fase: 1, + tipoPago: "unico", + precioBase: 42368, // 2449 USD + tiempoEntrega: "10 - 20 dias", + categoria: "diseno", + nivel: "Corporativo", + orden: 104, + entregablesDefault: [ + "Investigacion de Tendencias, Mercado, Competencia y Demanda", + "Creacion y Propuesta del Naming: 5", + "Propuesta del Logotipo: 5", + "Creacion Logotipo", + "Aplicativos Fisicos: 10", + "Aplicativos Digitales: 4", + "Manual de Identidad", + "Atencion y asesoria al cliente", + "Revisiones", + "Kit Basico para Redes Sociales (3 portadas, 1 foto de perfil, 6 templates RSS)", + "Iconografia", + "Registro del IMPI (10 anos)", + ], + }, + + // ── PAGINA WEB (Fase 1, unico) ── + { + nombre: "Pagina Web", + descripcion: "Creacion, configuracion y optimizacion de sitio web", + fase: 1, + tipoPago: "unico", + precioBase: 7768, // 449 USD + tiempoEntrega: "15 - 30 dias", + categoria: "sitio_web", + nivel: "Emprendedor", + orden: 111, + entregablesDefault: [ + "Propuesta de Diseno: 2", + "Creacion, Configuracion y Optimizacion de Sitio Web", + "Configuracion de Chat en Sitio Web", + "Optimizacion Basico SEO", + "Optimizacion basica de Velocidad de Carga", + "Estadisticas de Sitio Web (mensual)", + "Cambios de Informacion al Mes (No Acumulable): 1", + "Capacitacion basica para Manejo de Sitio Web", + "Reuniones de Trabajo", + "Alta en Buscadores", + "Cuentas de E-mail con Dominio", + "Paginas base (Gracias/Politicas/Terminos/Contacto/Nosotros/404)", + "Diseno Responsivo", + "Integracion de Formulario", + "Espacio 12 GB", + "Certificado Seguridad SSL (1 ano)", + "Integracion de Hosting (1 ano)", + "Asesoria y Adquisicion de Dominio (1 ano)", + ], + }, + { + nombre: "Pagina Web", + descripcion: "Creacion, configuracion y optimizacion de sitio web", + fase: 1, + tipoPago: "unico", + precioBase: 13996, // 809 USD + tiempoEntrega: "15 - 30 dias", + categoria: "sitio_web", + nivel: "PYME", + orden: 112, + entregablesDefault: [ + "Propuesta de Diseno: 4", + "Creacion, Configuracion y Optimizacion de Sitio Web", + "Configuracion de Chat en Sitio Web", + "Optimizacion Basico SEO", + "Optimizacion basica de Velocidad de Carga", + "Estadisticas de Sitio Web (mensual)", + "Cambios de Informacion al Mes (No Acumulable): 2", + "Capacitacion basica para Manejo de Sitio Web", + "Reuniones de Trabajo", + "Alta en Buscadores", + "Cuentas de E-mail con Dominio", + "Paginas base (Gracias/Politicas/Terminos/Contacto/Nosotros/404)", + "Diseno Responsivo", + "Integracion de Formulario", + "Espacio 12 GB", + "Certificado Seguridad SSL (1 ano)", + "Integracion de Hosting (1 ano)", + "Asesoria y Adquisicion de Dominio (1 ano)", + ], + }, + { + nombre: "Pagina Web", + descripcion: "Creacion, configuracion y optimizacion de sitio web", + fase: 1, + tipoPago: "unico", + precioBase: 21781, // 1259 USD + tiempoEntrega: "20 - 35 dias", + categoria: "sitio_web", + nivel: "Empresario", + orden: 113, + entregablesDefault: [ + "Propuesta de Diseno: 6", + "Creacion, Configuracion y Optimizacion de Sitio Web", + "Configuracion de Chat en Sitio Web", + "Optimizacion Basico SEO", + "Optimizacion basica de Velocidad de Carga", + "Estadisticas de Sitio Web (mensual)", + "Cambios de Informacion al Mes (No Acumulable): 3", + "Investigacion Tendencias, Mercado, Competencia y Demanda", + "Capacitacion basica para Manejo de Sitio Web", + "Reuniones de Trabajo", + "Alta en Buscadores", + "Cuentas de E-mail con Dominio", + "Paginas base (Gracias/Politicas/Terminos/Contacto/Nosotros/404)", + "Diseno Responsivo", + "Integracion de Formulario", + "Espacio 12 GB", + "Integracion Base de Datos para Correo", + "Certificado Seguridad SSL (1 ano)", + "Integracion de Hosting (1 ano)", + "Asesoria y Adquisicion de Dominio (1 ano)", + ], + }, + { + nombre: "Pagina Web", + descripcion: "Creacion, configuracion y optimizacion de sitio web", + fase: 1, + tipoPago: "unico", + precioBase: 39254, // 2269 USD + tiempoEntrega: "25 - 45 dias", + categoria: "sitio_web", + nivel: "Corporativo", + orden: 114, + entregablesDefault: [ + "Propuesta de Diseno: 8", + "Creacion, Configuracion y Optimizacion de Sitio Web", + "Configuracion de Chat en Sitio Web", + "Optimizacion Avanzado SEO", + "Optimizacion Avanzado de Velocidad de Carga", + "Optimizacion SEO Local", + "Estadisticas de Sitio Web (mensual)", + "Cambios de Informacion al Mes (No Acumulable): 5", + "Investigacion Tendencias, Mercado, Competencia y Demanda", + "Escritura Comercial", + "Capacitacion basica para Manejo de Sitio Web", + "Reuniones de Trabajo", + "Alta en Buscadores", + "Cuentas de E-mail con Dominio", + "Paginas base (incluye Bolsa de Trabajo)", + "Diseno Responsivo", + "Integracion de Formulario", + "Espacio 12 GB", + "Integracion Base de Datos para Correo", + "Certificado Seguridad SSL (1 ano)", + "Integracion de Hosting (1 ano)", + "Asesoria y Adquisicion de Dominio (1 ano)", + "Integracion CRM", + ], + }, + + // ── TIENDA EN LINEA (Fase 1, unico) ── (hoja "Tienda en linea") + { + nombre: "Tienda en Linea", + descripcion: "Desarrollo de tienda en linea (Magento, Shopify, WordPress)", + fase: 1, + tipoPago: "unico", + precioBase: 17283, // 999 USD + tiempoEntrega: "20 - 40 dias", + categoria: "ecommerce", + nivel: "Emprendedor", + orden: 121, + entregablesDefault: [ + "Configuracion en Tienda en Linea (Magento, Shopify, WordPress)", + "Eleccion de plantilla y Estructura de diseno (UX, UI)", + "Desarrollo y Estructura de tienda Optimizada para SEO", + "Creacion y Configuracion de Categorias", + "Diseno y Configuracion de Blog", + "Carga de Productos: 100", + "Capacitacion", + "Atencion y asesoria al cliente", + "Certificado Seguridad SSL (1 ano)", + "Integracion de Hosting (1 ano)", + "Asesoria y Adquisicion de Dominio (1 ano)", + "Integracion de metodos de pago", + "Integracion de metodos de envio", + "Integracion de Chat", + "Integracion y Configuracion de Google Analytics", + "Integracion y Configuracion de Search Console", + ], + }, + { + nombre: "Tienda en Linea", + descripcion: "Desarrollo de tienda en linea (Magento, Shopify, WordPress)", + fase: 1, + tipoPago: "unico", + precioBase: 26106, // 1509 USD + tiempoEntrega: "25 - 45 dias", + categoria: "ecommerce", + nivel: "PYME", + orden: 122, + entregablesDefault: [ + "Configuracion en Tienda en Linea (Magento, Shopify, WordPress)", + "Eleccion de plantilla y Estructura de diseno (UX, UI)", + "Desarrollo y Estructura de tienda Optimizada para SEO", + "Creacion y Configuracion de Categorias", + "Diseno y Configuracion de Blog", + "Carga de Productos: 250", + "Configuracion de productos seleccionados y recomendados (upsell, cross selling)", + "Capacitacion", + "Atencion y asesoria al cliente", + "Certificado Seguridad SSL (1 ano)", + "Integracion de Hosting (1 ano)", + "Asesoria y Adquisicion de Dominio (1 ano)", + "Integracion de metodos de pago", + "Integracion de metodos de envio", + "Integracion de Chat", + "Integracion y Configuracion de Google Analytics", + "Integracion y Configuracion de Search Console", + "Estrategias para recuperacion de carritos abandonados", + ], + }, + { + nombre: "Tienda en Linea", + descripcion: "Desarrollo de tienda en linea (Magento, Shopify, WordPress)", + fase: 1, + tipoPago: "unico", + precioBase: 26106, // 1509 USD + tiempoEntrega: "30 - 50 dias", + categoria: "ecommerce", + nivel: "Empresario", + orden: 123, + entregablesDefault: [ + "Configuracion en Tienda en Linea (Magento, Shopify, WordPress)", + "Eleccion de plantilla y Estructura de diseno (UX, UI)", + "Desarrollo y Estructura de tienda Optimizada para SEO", + "Creacion y Configuracion de Categorias", + "Diseno y Configuracion de Blog", + "Carga de Productos: 500", + "Configuracion de productos seleccionados y recomendados (upsell, cross selling)", + "Desarrollo de Contenido SEO", + "Asesoramiento para estrategias de promociones y descuentos", + "Capacitacion", + "Atencion y asesoria al cliente", + "Certificado Seguridad SSL (1 ano)", + "Integracion de Hosting (1 ano)", + "Asesoria y Adquisicion de Dominio (1 ano)", + "Integracion de metodos de pago", + "Integracion de metodos de envio", + "Integracion de Chat", + "Integracion y Configuracion de Google Analytics", + "Integracion y Configuracion de Search Console", + "Estrategias para recuperacion de carritos abandonados", + ], + }, + + // ── E-COMMERCE (Fase 1, unico) ── (hoja "Hemeroteca", version mas completa) + { + nombre: "E-commerce", + descripcion: "Tienda en linea completa: plataforma, hosting, dominio y puesta en marcha", + fase: 1, + tipoPago: "unico", + precioBase: 17283, // 999 USD + tiempoEntrega: "20 - 40 dias", + categoria: "ecommerce", + variante: "Hemeroteca", + nivel: "Emprendedor", + orden: 131, + entregablesDefault: [ + "Asesoria y Eleccion de Plataforma", + "Hosting, Eleccion y Asesoria de Dominio", + "Certificado SSL", + "Asesoria y Eleccion de Plantilla Base", + "Configuracion en Tienda en Linea", + "Integracion Metodos de Pago", + "Integracion Metodos de Envio", + "Integracion de Chat: 1", + "Configuracion Paginas Transaccionales", + "Integracion Google Analytics", + "Integracion Search Console", + "Estructura Tienda en Linea Optimizada", + "Creacion y Configuracion de Categorias", + "Diseno y Configuracion de Blog", + "Carga de Productos: 100", + "Diseno y Personalizacion de Tienda", + "Capacitacion y Puesta en Marcha", + "Soporte por Correo Electronico (24 hrs)", + "Juntas de Retroalimentacion cada Mes", + ], + }, + { + nombre: "E-commerce", + descripcion: "Tienda en linea completa: plataforma, hosting, dominio y puesta en marcha", + fase: 1, + tipoPago: "unico", + precioBase: 26106, // 1509 USD + tiempoEntrega: "25 - 45 dias", + categoria: "ecommerce", + variante: "Hemeroteca", + nivel: "PYME", + orden: 132, + entregablesDefault: [ + "Asesoria y Eleccion de Plataforma", + "Hosting, Eleccion y Asesoria de Dominio", + "Certificado SSL", + "Asesoria y Eleccion de Plantilla Base", + "Configuracion en Tienda en Linea", + "Integracion Metodos de Pago", + "Integracion Metodos de Envio", + "Integracion de Chat: 2", + "Configuracion Paginas Transaccionales", + "Integracion Google Analytics", + "Integracion Search Console", + "Estructura Tienda en Linea Optimizada", + "Creacion y Configuracion de Categorias", + "Diseno y Configuracion de Blog", + "Carga de Productos: 250", + "Diseno y Personalizacion de Tienda", + "Capacitacion y Puesta en Marcha", + "Soporte por Correo Electronico (12 hrs)", + "Juntas de Retroalimentacion cada Mes", + ], + }, + { + nombre: "E-commerce", + descripcion: "Tienda en linea completa: plataforma, hosting, dominio y puesta en marcha", + fase: 1, + tipoPago: "unico", + precioBase: 34756, // 2009 USD + tiempoEntrega: "30 - 50 dias", + categoria: "ecommerce", + variante: "Hemeroteca", + nivel: "Empresario", + orden: 133, + entregablesDefault: [ + "Asesoria y Eleccion de Plataforma", + "Hosting, Eleccion y Asesoria de Dominio", + "Certificado SSL", + "Asesoria y Eleccion de Plantilla Base", + "Configuracion en Tienda en Linea", + "Integracion Metodos de Pago", + "Integracion Metodos de Envio", + "Integracion de Chat: 3", + "Configuracion Paginas Transaccionales", + "Integracion Google Analytics", + "Integracion Search Console", + "Estructura Tienda en Linea Optimizada", + "Creacion y Configuracion de Categorias", + "Diseno y Configuracion de Blog", + "Carga de Productos: 500", + "Diseno y Personalizacion de Tienda", + "Capacitacion y Puesta en Marcha", + "Soporte por Correo Electronico (8 hrs)", + "Juntas de Retroalimentacion cada Mes", + "Diseno Responsivo UX", + "Configuracion de Carritos Abandonados", + "Up-Selling, Cross-Selling", + ], + }, + { + nombre: "E-commerce", + descripcion: "Tienda en linea completa: plataforma, hosting, dominio y puesta en marcha", + fase: 1, + tipoPago: "unico", + precioBase: 43579, // 2519 USD + tiempoEntrega: "35 - 60 dias", + categoria: "ecommerce", + variante: "Hemeroteca", + nivel: "Corporativo", + orden: 134, + entregablesDefault: [ + "Asesoria y Eleccion de Plataforma", + "Hosting, Eleccion y Asesoria de Dominio", + "Certificado SSL", + "Asesoria y Eleccion de Plantilla Base", + "Configuracion en Tienda en Linea", + "Integracion Metodos de Pago", + "Integracion Metodos de Envio", + "Integracion de Chat: 4", + "Configuracion Paginas Transaccionales", + "Integracion Google Analytics", + "Integracion Search Console", + "Estructura Tienda en Linea Optimizada", + "Creacion y Configuracion de Categorias", + "Diseno y Configuracion de Blog", + "Carga de Productos: ilimitados", + "Diseno y Personalizacion de Tienda", + "Capacitacion y Puesta en Marcha", + "Soporte por Correo Electronico (inmediata)", + "Juntas de Retroalimentacion cada Mes", + "Diseno Responsivo UX", + "Configuracion de Carritos Abandonados", + "Up-Selling, Cross-Selling", + "Desarrollo Contenido SEO", + "Asesoramiento Promociones y Descuentos", + "Configuracion Programas de Lealtad", + ], + }, + + // ── MENU DIGITAL (Fase 1, unico) ── + { + nombre: "Menu Digital", + descripcion: "Menu digital con QR personalizado", + fase: 1, + tipoPago: "unico", + precioBase: 1713, // 99 USD + tiempoEntrega: "7 - 15 dias", + categoria: "sitio_web", + nivel: "Emprendedor", + orden: 141, + entregablesDefault: [ + "Diseno de Menu Digital", + "QR Personalizado / Dinamico", + "Diseno de Pegatinas", + "Un Idioma (Espanol)", + "Lecturas Ilimitadas", + "Alojamiento de Menu y QR", + "Soporte por Ticket", + "Juntas de Retroalimentacion Cada Mes", + ], + }, + { + nombre: "Menu Digital", + descripcion: "Menu digital con QR personalizado", + fase: 1, + tipoPago: "unico", + precioBase: 3443, // 199 USD + tiempoEntrega: "7 - 15 dias", + categoria: "sitio_web", + nivel: "PYME", + orden: 142, + entregablesDefault: [ + "Diseno de Menu Digital", + "QR Personalizado / Dinamico", + "Diseno de Pegatinas", + "Un Idioma (Espanol)", + "Lecturas Ilimitadas", + "Alojamiento de Menu y QR", + "Soporte por Ticket", + "Juntas de Retroalimentacion Cada Mes", + "Integracion de Redes Sociales", + "Hasta 100 Productos", + "Actualizaciones por Ano: 10", + "Boton de WhatsApp", + "Promocion Mensual", + "Captacion de Base de Datos", + "E-mail Marketing Automation", + "Templates a Elegir: 5", + ], + }, + { + nombre: "Menu Digital", + descripcion: "Menu digital con QR personalizado", + fase: 1, + tipoPago: "unico", + precioBase: 5173, // 299 USD + tiempoEntrega: "10 - 20 dias", + categoria: "sitio_web", + nivel: "Empresario", + orden: 143, + entregablesDefault: [ + "Diseno de Menu Digital", + "QR Personalizado / Dinamico", + "Diseno de Pegatinas", + "Un Idioma (Espanol)", + "Lecturas Ilimitadas", + "Alojamiento de Menu y QR", + "Soporte por Ticket", + "Juntas de Retroalimentacion Cada Mes", + "Integracion de Redes Sociales", + "Hasta 100 Productos", + "Actualizaciones por Ano: 10", + "Boton de WhatsApp", + "Promocion Mensual", + "Captacion de Base de Datos", + "E-mail Marketing Automation", + "Templates a Elegir: 5", + "Pagina Web (Menu Digital)", + "Pago en Linea", + "Personalizacion de Productos", + "Guia de Nutricion por Producto", + "Multiples Addons", + "Notificacion de Nueva Orden", + "Mensajes de Horarios de Atencion", + "Multiples Menus o Secciones", + "100% Responsivo", + "Etiquetas Personalizables", + ], + }, + + // ── GOOGLE ADS (Fase 2, mensual) ── + { + nombre: "Google Ads", + descripcion: "Creacion, configuracion y optimizacion de campanas Google Ads", + fase: 2, + tipoPago: "mensual", + precioBase: 2578, // 149 USD + tiempoEntrega: "Servicio mensual", + categoria: "ads", + nivel: "Emprendedor", + orden: 201, + entregablesDefault: [ + "Creacion y Configuracion de cuenta Google Ads", + "Investigacion y Optimizacion de palabras clave positivas y negativas", + "Creacion de Campanas Publicitarias: 1 a 3", + "Optimizacion de presupuesto de Campanas diaria", + "Integracion con Google Analytics", + "Integracion de etiqueta de pixeles de conversion", + "Reporte de Resultado mensual y junta de retroalimentacion", + "Atencion y asesoria al cliente", + ], + }, + { + nombre: "Google Ads", + descripcion: "Creacion, configuracion y optimizacion de campanas Google Ads", + fase: 2, + tipoPago: "mensual", + precioBase: 5173, // 299 USD + tiempoEntrega: "Servicio mensual", + categoria: "ads", + nivel: "PYME", + orden: 202, + entregablesDefault: [ + "Creacion y Configuracion de cuenta Google Ads", + "Investigacion y Optimizacion de palabras clave positivas y negativas", + "Creacion de Campanas Publicitarias: 1 a 6", + "Optimizacion de presupuesto de Campanas diaria", + "Configuracion de Anuncios de Remarketing", + "Integracion con Google Analytics", + "Integracion de etiqueta de pixeles de conversion", + "Reporte de Resultado mensual y junta de retroalimentacion", + "Atencion y asesoria al cliente", + ], + }, + { + nombre: "Google Ads", + descripcion: "Creacion, configuracion y optimizacion de campanas Google Ads", + fase: 2, + tipoPago: "mensual", + precioBase: 10363, // 599 USD + tiempoEntrega: "Servicio mensual", + categoria: "ads", + nivel: "Empresario", + orden: 203, + entregablesDefault: [ + "Creacion y Configuracion de cuenta Google Ads", + "Investigacion y Optimizacion de palabras clave positivas y negativas", + "Creacion de Campanas Publicitarias: 10", + "Optimizacion de presupuesto de Campanas diaria", + "Configuracion de Anuncios de Remarketing", + "Configuracion de Anuncios para Red Display", + "Automatizacion de envio de Ficha de Prospectos", + "Integracion con Google Analytics", + "Integracion de etiqueta de pixeles de conversion", + "Integracion de Herramientas de automatizacion", + "Reporte de Resultado mensual y junta de retroalimentacion", + "Atencion y asesoria al cliente", + ], + }, + { + nombre: "Google Ads", + descripcion: "Creacion, configuracion y optimizacion de campanas Google Ads", + fase: 2, + tipoPago: "mensual", + precioBase: 17456, // 1009 USD + tiempoEntrega: "Servicio mensual", + categoria: "ads", + nivel: "Corporativo", + orden: 204, + entregablesDefault: [ + "Creacion y Configuracion de cuenta Google Ads", + "Investigacion y Optimizacion de palabras clave positivas y negativas", + "Creacion de Campanas Publicitarias: 15", + "Optimizacion de presupuesto de Campanas diaria", + "Configuracion de Anuncios de Remarketing", + "Configuracion de Anuncios para Red Display", + "Configuracion de Google Merchant Center", + "Configuracion y Optimizacion del Fit de productos", + "Configuracion de Campanas para Shopping", + "Configuracion de cuenta BING", + "Configuracion de Campanas BING", + "Automatizacion de envio de Ficha de Prospectos", + "Integracion con Google Analytics", + "Integracion de etiqueta de pixeles de conversion", + "Integracion de Herramientas de automatizacion", + "Reporte de Resultado mensual y junta de retroalimentacion", + "Atencion y asesoria al cliente", + ], + }, + + // ── FB & IG ADS (Fase 2, mensual) ── + { + nombre: "Facebook & Instagram Ads", + descripcion: "Creacion y optimizacion de campanas en Meta (Facebook e Instagram)", + fase: 2, + tipoPago: "mensual", + precioBase: 2578, // 149 USD + tiempoEntrega: "Servicio mensual", + categoria: "ads", + nivel: "Emprendedor", + orden: 211, + entregablesDefault: [ + "Investigacion, Segmentacion y Optimizacion del Publico Objetivo", + "Creacion y Configuracion del Administrador de Anuncios", + "Creacion de Artes para Anuncios Publicitarios", + "Creacion y Configuracion de Campanas Publicitarias: 1 a 3", + "Configuracion de lista de Remarketing", + "Optimizacion de presupuesto diario y campanas publicitarias", + "Instalacion de Pixeles en Pag. Web", + "Reporte de Resultado mensual y junta de retroalimentacion", + "Atencion y asesoria al cliente", + ], + }, + { + nombre: "Facebook & Instagram Ads", + descripcion: "Creacion y optimizacion de campanas en Meta (Facebook e Instagram)", + fase: 2, + tipoPago: "mensual", + precioBase: 5173, // 299 USD + tiempoEntrega: "Servicio mensual", + categoria: "ads", + nivel: "PYME", + orden: 212, + entregablesDefault: [ + "Investigacion, Segmentacion y Optimizacion del Publico Objetivo", + "Creacion y Configuracion del Administrador de Anuncios", + "Creacion de Artes para Anuncios Publicitarios", + "Creacion y Configuracion de Campanas Publicitarias: 1 a 6", + "Configuracion de lista de Remarketing", + "Optimizacion de presupuesto diario y campanas publicitarias", + "Instalacion de Pixeles en Pag. Web", + "Reporte de Resultado mensual y junta de retroalimentacion", + "Atencion y asesoria al cliente", + ], + }, + { + nombre: "Facebook & Instagram Ads", + descripcion: "Creacion y optimizacion de campanas en Meta (Facebook e Instagram)", + fase: 2, + tipoPago: "mensual", + precioBase: 6903, // 399 USD + tiempoEntrega: "Servicio mensual", + categoria: "ads", + nivel: "Empresario", + orden: 213, + entregablesDefault: [ + "Investigacion, Segmentacion y Optimizacion del Publico Objetivo", + "Creacion y Configuracion del Administrador de Anuncios", + "Creacion de Artes para Anuncios Publicitarios", + "Creacion y Configuracion de Campanas Publicitarias: 1 a 8", + "Configuracion de lista de Remarketing", + "Optimizacion de presupuesto diario y campanas publicitarias", + "Instalacion de Pixeles en Pag. Web", + "Integracion de herramientas de automatizacion", + "Reporte de Resultado mensual y junta de retroalimentacion", + "Atencion y asesoria al cliente", + ], + }, + { + nombre: "Facebook & Instagram Ads", + descripcion: "Creacion y optimizacion de campanas en Meta (Facebook e Instagram)", + fase: 2, + tipoPago: "mensual", + precioBase: 13131, // 759 USD + tiempoEntrega: "Servicio mensual", + categoria: "ads", + nivel: "Corporativo", + orden: 214, + entregablesDefault: [ + "Investigacion, Segmentacion y Optimizacion del Publico Objetivo", + "Creacion y Configuracion del Administrador de Anuncios", + "Creacion de Artes para Anuncios Publicitarios", + "Creacion y Configuracion de Campanas Publicitarias: 1 a 15", + "Creacion de Anuncios para Shopping", + "Configuracion de lista de Remarketing", + "Configuracion de catalogo de Productos de Facebook", + "Optimizacion de presupuesto diario y campanas publicitarias", + "Instalacion de Pixeles en Pag. Web", + "Integracion de herramientas de automatizacion", + "Reporte de Resultado mensual y junta de retroalimentacion", + "Atencion y asesoria al cliente", + ], + }, + + // ── MANEJO DE REDES SOCIALES (Fase 2, mensual) ── + { + nombre: "Manejo de Redes Sociales", + descripcion: "Gestion y contenido de redes sociales", + fase: 2, + tipoPago: "mensual", + precioBase: 6903, // 399 USD + tiempoEntrega: "Servicio mensual", + categoria: "redes", + nivel: "Emprendedor", + orden: 221, + entregablesDefault: [ + "Creacion, Configuracion y/o Optimizacion de pagina de Facebook", + "Medicion de conversiones", + "Estrategia de contenido Publicitario", + "Creacion de embudo de conversion", + "Desarrollo de Cover", + "Creacion de artes y copys para Post", + "Posteos de Publicacion al mes: 10", + "Asesoria al cliente por correo electronico (24 hrs)", + "Informes periodicos", + "Integracion de Google Analytics", + ], + }, + { + nombre: "Manejo de Redes Sociales", + descripcion: "Gestion y contenido de redes sociales", + fase: 2, + tipoPago: "mensual", + precioBase: 12093, // 699 USD + tiempoEntrega: "Servicio mensual", + categoria: "redes", + nivel: "PYME", + orden: 222, + entregablesDefault: [ + "Creacion, Configuracion y/o Optimizacion de pagina de Facebook", + "Medicion de conversiones", + "Estrategia de contenido Publicitario", + "Creacion de embudo de conversion", + "Desarrollo de Cover", + "Creacion de artes y copys para Post", + "Posteos de Publicacion al mes: 15", + "Asesoria al cliente por correo electronico (24 hrs)", + "Informes periodicos", + "Integracion de Google Analytics", + "Integracion Search Console", + "Desarrollo Tienda en Linea Optimizada", + "Video", + ], + }, + { + nombre: "Manejo de Redes Sociales", + descripcion: "Gestion y contenido de redes sociales", + fase: 2, + tipoPago: "mensual", + precioBase: 17456, // 1009 USD + tiempoEntrega: "Servicio mensual", + categoria: "redes", + nivel: "Empresario", + orden: 223, + entregablesDefault: [ + "Creacion, Configuracion y/o Optimizacion de pagina de Facebook", + "Medicion de conversiones", + "Estrategia de contenido Publicitario", + "Creacion de embudo de conversion", + "Desarrollo de Cover", + "Creacion de artes y copys para Post", + "Posteos de Publicacion al mes: 15", + "Asesoria al cliente por correo electronico (24 hrs)", + "Informes periodicos", + "Integracion de Google Analytics", + "Integracion Search Console", + "Desarrollo Tienda en Linea Optimizada", + "Video", + "Gifs", + "Dinamicas", + ], + }, + { + nombre: "Manejo de Redes Sociales", + descripcion: "Gestion y contenido de redes sociales", + fase: 2, + tipoPago: "mensual", + precioBase: 42195, // 2439 USD + tiempoEntrega: "Servicio mensual", + categoria: "redes", + nivel: "Corporativo", + orden: 224, + entregablesDefault: [ + "Creacion, Configuracion y/o Optimizacion de pagina de Facebook", + "Medicion de conversiones", + "Estrategia de contenido Publicitario", + "Creacion de embudo de conversion", + "Desarrollo de Cover", + "Creacion de artes y copys para Post", + "Posteos de Publicacion al mes: 15", + "Asesoria al cliente por correo electronico (24 hrs)", + "Informes periodicos", + "Integracion de Google Analytics", + "Integracion Search Console", + "Desarrollo Tienda en Linea Optimizada", + "Video", + "Gifs", + "Dinamicas", + "Concursos", + "Manejo Inmediato de Crisis", + ], + }, + + // ── SEO (Fase 3, mensual) ── + // NOTA: el Excel origen traia Empresario y Corporativo a 199 USD (error de copia: + // mismo precio que Emprendedor pese a tener mas features). Corregido al patron + // lineal +$100/nivel (5/10/15/20 keywords -> 199/299/399/499 USD). + { + nombre: "Posicionamiento SEO", + descripcion: "Estrategia y posicionamiento SEO mensual", + fase: 3, + tipoPago: "mensual", + precioBase: 3443, // 199 USD + tiempoEntrega: "Servicio mensual", + categoria: "seo", + nivel: "Emprendedor", + orden: 301, + entregablesDefault: [ + "No. KeyWords: 5", + "Estudio y definicion de Palabras Clave", + "Analisis de la competencia", + "Optimizacion tecnica de la estructura del Sitio a nivel SEO", + "Optimizacion de la velocidad de carga en Sitio", + "Creacion y Optimizacion del Sitemap.xml", + "Reporte mensual de trafico y posicion Web", + "Integracion y Configuracion de Google Search Console", + "Integracion y Configuracion de Google Analytics", + "Plan para desarrollo de contenido Estrategico para SEO", + "Estrategia y Generacion de BackLinks", + "Alta de Buscadores", + "Generacion de trafico social a tu Sitio Web", + "Sindicacion en Redes Sociales Externas", + "Atencion y asesoria al cliente", + ], + }, + { + nombre: "Posicionamiento SEO", + descripcion: "Estrategia y posicionamiento SEO mensual", + fase: 3, + tipoPago: "mensual", + precioBase: 5173, // 299 USD + tiempoEntrega: "Servicio mensual", + categoria: "seo", + nivel: "PYME", + orden: 302, + entregablesDefault: [ + "No. KeyWords: 10", + "Estudio y definicion de Palabras Clave", + "Analisis de la competencia", + "Optimizacion tecnica de la estructura del Sitio a nivel SEO", + "Optimizacion de la velocidad de carga en Sitio", + "Optimizacion de SEO local (Google My Business)", + "Creacion y Optimizacion del Sitemap.xml", + "Reporte mensual de trafico y posicion Web", + "Integracion y Configuracion de Google Search Console", + "Integracion y Configuracion de Google Analytics", + "Plan para desarrollo de contenido Estrategico para SEO", + "Estrategia y Generacion de BackLinks", + "Alta de Buscadores", + "Generacion de trafico social a tu Sitio Web", + "Sindicacion en Redes Sociales Externas", + "Atencion y asesoria al cliente", + ], + }, + { + nombre: "Posicionamiento SEO", + descripcion: "Estrategia y posicionamiento SEO mensual", + fase: 3, + tipoPago: "mensual", + precioBase: 6903, // 399 USD (corregido) + tiempoEntrega: "Servicio mensual", + categoria: "seo", + nivel: "Empresario", + orden: 303, + entregablesDefault: [ + "No. KeyWords: 15", + "Estudio y definicion de Palabras Clave", + "Analisis de la competencia", + "Optimizacion tecnica de la estructura del Sitio a nivel SEO", + "Optimizacion de la velocidad de carga en Sitio", + "Optimizacion de SEO local (Google My Business)", + "Creacion y Optimizacion del Sitemap.xml", + "Reporte mensual de trafico y posicion Web", + "Integracion y Configuracion de Google Search Console: 15", + "Integracion y Configuracion de Google Analytics", + "Plan para desarrollo de contenido Estrategico para SEO", + "Estrategia y Generacion de BackLinks", + "Alta de Buscadores", + "Generacion de trafico social a tu Sitio Web", + "Sindicacion en Redes Sociales Externas", + "Desarrollo de contenido para Sitios Externos", + "Atencion y asesoria al cliente", + ], + }, + { + nombre: "Posicionamiento SEO", + descripcion: "Estrategia y posicionamiento SEO mensual", + fase: 3, + tipoPago: "mensual", + precioBase: 8633, // 499 USD (corregido) + tiempoEntrega: "Servicio mensual", + categoria: "seo", + nivel: "Corporativo", + orden: 304, + entregablesDefault: [ + "No. KeyWords: 20", + "Estudio y definicion de Palabras Clave", + "Analisis de la competencia", + "Optimizacion tecnica de la estructura del Sitio a nivel SEO", + "Optimizacion de la velocidad de carga en Sitio", + "Optimizacion de SEO local (Google My Business)", + "Creacion y Optimizacion del Sitemap.xml", + "Reporte mensual de trafico y posicion Web", + "Integracion y Configuracion de Google Search Console: 15", + "Integracion y Configuracion de Google Analytics", + "Plan para desarrollo de contenido Estrategico para SEO", + "Estrategia y Generacion de BackLinks", + "Alta de Buscadores", + "Generacion de trafico social a tu Sitio Web", + "Sindicacion en Redes Sociales Externas", + "Desarrollo de contenido para Sitios Externos", + "Atencion y asesoria al cliente", + ], + }, + + // ── ESTRATEGIA EMPRESARIAL EN ECOMMERCE (Fase 0, unico, sin niveles ni precio en Excel) ── + { + nombre: "Estrategia Empresarial en Ecommerce", + descripcion: "Auditoria y estrategia integral para ecommerce (SEO, tecnica y seguridad Shopify, estudios de mercado/competencia y embudos de venta)", + fase: 0, + tipoPago: "unico", + precioBase: 12900, + tiempoEntrega: "10 - 20 dias", + categoria: "estrategia", + orden: 100, + entregablesDefault: [ + "Auditoria completa de situacion actual del SEO del Sitio", + "Auditoria Tecnica de Shopify", + "Auditoria en Seguridad de Shopify", + "Estudio de canales de comunicacion y captacion", + "Estudio de Segmentos de Mercado para Landing Pages de prospeccion", + "Estudio de Keyword Research para Categorizacion Jerarquica de Ecommerce", + "Estudio de Keyword Research para creacion de Landing Page para prospeccion", + "Estudio de competencia organica", + "Estudio de competencia pagada", + "Estudio de tendencias", + "Adicional: Embudo de ventas para Ecommerce", + "Adicional: Embudo de ventas para captacion", + ], + }, ]; for (const servicio of servicios) { const catNombre = catAlias[servicio.categoria] || "Desarrollo Personalizado"; + const servicioId = + servicio.nombre.toLowerCase().replace(/\s+/g, "-").substring(0, 30) + "-" + servicio.orden; await prisma.servicioCatalogo.upsert({ - where: { id: servicio.nombre.toLowerCase().replace(/\s+/g, "-").substring(0, 30) + "-" + servicio.orden }, + where: { id: servicioId }, update: {}, create: { + id: servicioId, nombre: servicio.nombre, descripcion: servicio.descripcion, fase: servicio.fase, @@ -599,7 +1616,8 @@ async function main() { tiempoEntrega: servicio.tiempoEntrega, entregablesDefault: servicio.entregablesDefault, categoriaId: catMap[catNombre] || null, - variante: servicio.variante || null, + variante: ("variante" in servicio ? servicio.variante : null) || null, + nivel: ("nivel" in servicio ? servicio.nivel : null) || null, orden: servicio.orden, }, }); @@ -642,17 +1660,267 @@ async function main() { faseMap[fg.orden] = created.id; } - const existingLinks = await prisma.servicioPaquete.findMany(); - if (existingLinks.length === 0) { - for (const s of allServicios) { - const fasePaqueteId = faseMap[s.fase]; - if (fasePaqueteId) { - await prisma.servicioPaquete.create({ - data: { + for (const s of allServicios) { + const fasePaqueteId = faseMap[s.fase]; + if (fasePaqueteId) { + await prisma.servicioPaquete.upsert({ + where: { + servicioCatalogoId_fasePaqueteId: { servicioCatalogoId: s.id, fasePaqueteId, }, - }).catch(() => {}); + }, + update: {}, + create: { + servicioCatalogoId: s.id, + fasePaqueteId, + }, + }); + } + } + + // ── PAQUETES RECOMENDADOS PYME ── + // Resuelve un servicio del catalogo por nombre (+ nivel opcional). El nivel + // desambigua servicios con nombre repetido (ej. "Posicionamiento SEO" existe + // generico con nivel null y tambien por nivel). Lanza error si no resuelve + // exactamente 1 servicio para que cualquier nombre mal escrito reviente aqui. + const findServId = (nombre: string, nivel: string | null = null): string => { + const matches = allServicios.filter( + (s) => s.nombre === nombre && (s.nivel ?? null) === (nivel ?? null) + ); + if (matches.length !== 1) { + throw new Error( + `Paquete PyME: '${nombre}' (nivel=${nivel}) resolvio ${matches.length} servicios` + ); + } + return matches[0].id; + }; + + type ServRef = { nombre: string; nivel?: string | null }; + type PaqueteDef = { + id: string; + nombre: string; + descripcion: string; + fases: { orden: number; nombre: string; servicios: ServRef[] }[]; + }; + + const FASE_NOMBRES = [ + "Auditoria / Acompanamiento", + "Setup e Infraestructura", + "Publicidad y Manejo", + "Contenido y SEO", + ]; + + const paquetesPyme: PaqueteDef[] = [ + { + id: "paquete-arranque", + nombre: "Arranque Esencial", + descripcion: + "Inicio - cualquier giro. Minimo viable para una PyME que arranca: presencia profesional en redes y Google, y primeros clientes.", + fases: [ + { orden: 0, nombre: FASE_NOMBRES[0], servicios: [{ nombre: "Auditoria en Redes Sociales" }] }, + { + orden: 1, + nombre: FASE_NOMBRES[1], + servicios: [ + { nombre: "Creacion y Optimizacion de Redes Sociales" }, + { nombre: "Creacion y Optimizacion de la Ficha de Google My Business" }, + ], + }, + { orden: 2, nombre: FASE_NOMBRES[2], servicios: [{ nombre: "Manejo Basico de Redes Sociales" }] }, + ], + }, + { + id: "paquete-marca", + nombre: "Marca + Presencia", + descripcion: + "Inicio - marca nueva o personal. Imagen profesional (identidad), redes y primeras campanas de alcance antes de invertir en pauta fuerte.", + fases: [ + { orden: 0, nombre: FASE_NOMBRES[0], servicios: [{ nombre: "Auditoria en Redes Sociales" }] }, + { + orden: 1, + nombre: FASE_NOMBRES[1], + servicios: [ + { nombre: "Logo e Identidad", nivel: "Emprendedor" }, + { nombre: "Creacion y Optimizacion de Redes Sociales" }, + { nombre: "Creacion y Optimizacion de la Ficha de Google My Business" }, + ], + }, + { + orden: 2, + nombre: FASE_NOMBRES[2], + servicios: [ + { nombre: "Manejo Basico de Redes Sociales" }, + { nombre: "Manejo y Optimizacion de Meta Ads" }, + ], + }, + { orden: 3, nombre: FASE_NOMBRES[3], servicios: [{ nombre: "Desarrollo de Plan de Contenido" }] }, + ], + }, + { + id: "paquete-local", + nombre: "Negocio Local en Crecimiento", + descripcion: + "Crecimiento - giro local (restaurante, consultorio, tienda fisica, servicios). Visibilidad local y captacion por WhatsApp/llamada: web, ficha de Google, redes, Meta Ads y SEO local.", + fases: [ + { orden: 0, nombre: FASE_NOMBRES[0], servicios: [{ nombre: "Auditoria en Sitio Web" }] }, + { + orden: 1, + nombre: FASE_NOMBRES[1], + servicios: [ + { nombre: "Pagina Web", nivel: "Emprendedor" }, + { nombre: "Creacion y Optimizacion de la Ficha de Google My Business" }, + { nombre: "Creacion y Optimizacion de Redes Sociales" }, + ], + }, + { + orden: 2, + nombre: FASE_NOMBRES[2], + servicios: [ + { nombre: "Manejo Basico de Redes Sociales" }, + { nombre: "Manejo y Optimizacion de Meta Ads" }, + ], + }, + { orden: 3, nombre: FASE_NOMBRES[3], servicios: [{ nombre: "Posicionamiento SEO" }] }, + ], + }, + { + id: "paquete-leads-b2b", + nombre: "Servicios / B2B: Generacion de Leads", + descripcion: + "Crecimiento - servicios profesionales / B2B. Motor de prospeccion: sitio web solido, CRM Bucefalo, campanas de Google Ads, contenido y SEO.", + fases: [ + { + orden: 0, + nombre: FASE_NOMBRES[0], + servicios: [{ nombre: "Auditoria en Sitio Web" }, { nombre: "Estudio Digital de Mercado" }], + }, + { + orden: 1, + nombre: FASE_NOMBRES[1], + servicios: [ + { nombre: "Diseno de Sitio Web (WordPress)" }, + { nombre: "Configuracion y Puesta en Marcha del CRM Bucefalo" }, + { nombre: "Configuracion, Optimizacion y Puesta en Marcha de Google Ads y Meta Ads" }, + ], + }, + { + orden: 2, + nombre: FASE_NOMBRES[2], + servicios: [ + { nombre: "Configuracion y Optimizacion Google Ads" }, + { nombre: "Plan Mensual CRM Bucefalo" }, + ], + }, + { + orden: 3, + nombre: FASE_NOMBRES[3], + servicios: [{ nombre: "Desarrollo de Plan de Contenido" }, { nombre: "Posicionamiento SEO" }], + }, + ], + }, + { + id: "paquete-ecommerce", + nombre: "Ecommerce Lanzamiento", + descripcion: + "Inicio / Crecimiento - ecommerce. Vender en linea desde cero con buen setup de medicion y pauta: tienda WooCommerce, CRM y campanas de Google y Meta.", + fases: [ + { orden: 0, nombre: FASE_NOMBRES[0], servicios: [{ nombre: "Auditoria en Ecommerce" }] }, + { + orden: 1, + nombre: FASE_NOMBRES[1], + servicios: [ + { nombre: "Diseno de Ecommerce (WooCommerce)" }, + { nombre: "Configuracion, Optimizacion y Puesta en Marcha de Google Ads y Meta Ads" }, + { nombre: "Configuracion y Puesta en Marcha del CRM Bucefalo" }, + ], + }, + { + orden: 2, + nombre: FASE_NOMBRES[2], + servicios: [ + { nombre: "Manejo y Optimizacion de Meta Ads" }, + { nombre: "Configuracion y Optimizacion Google Ads" }, + ], + }, + ], + }, + { + id: "paquete-360", + nombre: "Escalamiento 360 Integral", + descripcion: + "Escalamiento - negocio consolidado, cualquier giro. Paquete tope: ads en niveles altos, redes profesionales, contenido, SEO completo y acompanamiento estrategico. El asesor recorta segun presupuesto.", + fases: [ + { + orden: 0, + nombre: FASE_NOMBRES[0], + servicios: [ + { nombre: "Auditoria en Sitio Web" }, + { nombre: "Auditoria de cuentas Google Ads y Meta Ads" }, + { nombre: "Estudio Digital de Mercado" }, + { nombre: "Plan de Acompanamiento Integral" }, + ], + }, + { + orden: 1, + nombre: FASE_NOMBRES[1], + servicios: [ + { nombre: "Diseno de Identidad Corporativa" }, + { nombre: "Diseno de Sitio Web (WordPress)" }, + { nombre: "Configuracion y Puesta en Marcha del CRM Bucefalo" }, + { nombre: "Configuracion, Optimizacion y Puesta en Marcha de Google Ads y Meta Ads" }, + ], + }, + { + orden: 2, + nombre: FASE_NOMBRES[2], + servicios: [ + { nombre: "Google Ads", nivel: "Empresario" }, + { nombre: "Facebook & Instagram Ads", nivel: "Empresario" }, + { nombre: "Manejo de Redes Sociales", nivel: "PYME" }, + { nombre: "Plan Mensual CRM Bucefalo" }, + ], + }, + { + orden: 3, + nombre: FASE_NOMBRES[3], + servicios: [ + { nombre: "Desarrollo de Plan de Contenido" }, + { nombre: "Generacion de Contenido" }, + { nombre: "Estrategia SEO Completa" }, + ], + }, + ], + }, + ]; + + for (const pq of paquetesPyme) { + const paqueteRec = await prisma.paquete.upsert({ + where: { id: pq.id }, + update: { nombre: pq.nombre, descripcion: pq.descripcion, activo: true }, + create: { id: pq.id, nombre: pq.nombre, descripcion: pq.descripcion, activo: true }, + }); + + for (const fase of pq.fases) { + const faseId = `fase-${pq.id}-${fase.orden}`; + const faseCreated = await prisma.fasePaquete.upsert({ + where: { id: faseId }, + update: { nombre: fase.nombre, orden: fase.orden }, + create: { id: faseId, paqueteId: paqueteRec.id, nombre: fase.nombre, orden: fase.orden }, + }); + + for (const ref of fase.servicios) { + const servicioCatalogoId = findServId(ref.nombre, ref.nivel ?? null); + await prisma.servicioPaquete.upsert({ + where: { + servicioCatalogoId_fasePaqueteId: { + servicioCatalogoId, + fasePaqueteId: faseCreated.id, + }, + }, + update: {}, + create: { servicioCatalogoId, fasePaqueteId: faseCreated.id }, + }); } } } @@ -666,8 +1934,10 @@ async function main() { { numero: 6, titulo: "Script de Ventas", descripcion: "Script de Ventas con mas de 100 complementos" }, ]; - for (const bono of bonos) { - await prisma.bono.create({ data: bono }); + if ((await prisma.bono.count()) === 0) { + for (const bono of bonos) { + await prisma.bono.create({ data: bono }); + } } const planes = [ diff --git a/src/app/(app)/catalogo/CatalogoClient.tsx b/src/app/(app)/catalogo/CatalogoClient.tsx index 909f6ba..08ab561 100644 --- a/src/app/(app)/catalogo/CatalogoClient.tsx +++ b/src/app/(app)/catalogo/CatalogoClient.tsx @@ -6,14 +6,12 @@ import { Pencil, Trash2, X, - Check, ChevronDown, ChevronRight, Loader2, Package, LayoutGrid, Settings, - GripVertical, Save, FileDown, Upload, @@ -34,6 +32,7 @@ interface Servicio { categoriaNombre: string; categoriaColor: string; variante: string | null; + nivel: string | null; activo: boolean; orden: number; } @@ -51,7 +50,7 @@ interface FasePaqueteUI { id: string; nombre: string; orden: number; - servicios: { id: string; nombre: string; precioBase: number; tipoPago: string }[]; + servicios: { id: string; nombre: string; precioBase: number; tipoPago: string; nivel: string | null }[]; } interface PaqueteUI { @@ -309,33 +308,36 @@ export function CatalogoClient({ initialServicios, initialCategorias, initialPaq ({catServs.length})
- +
- - - - + + + + {catServs.map((s) => ( - - - - - - + + +
ServicioTipoTiempoPrecioEntregablesTipoTiempoPrecioEntregables Acciones
-
{s.nombre}
- {s.descripcion &&
{s.descripcion}
} +
+
+ {s.nombre} + {s.nivel && {s.nivel}} +
+ {s.descripcion &&
{s.descripcion}
}
+ {s.tipoPago === "unico" ? "Unico" : "Mensual"} {s.tiempoEntrega}{formatCurrency(s.precioBase)}{s.entregablesDefault.length} + {s.tiempoEntrega}{formatCurrency(s.precioBase)}{s.entregablesDefault.length}