Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9023384de9 | ||
|
|
0ff783fd65 |
@@ -7,15 +7,19 @@
|
||||
| `npm run dev` | Start Next.js dev server on port 3000 |
|
||||
| `npm run build` | Production build (runs TypeScript check) |
|
||||
| `npm run lint` | ESLint (flat config, eslint-config-next) |
|
||||
| `npx tsx prisma/seed.ts` | Run seed (upserts all data, idempotent) |
|
||||
| `npx prisma migrate dev` | Create/apply migration |
|
||||
| `npx prisma generate` | Regenerate Prisma client |
|
||||
| `npx prisma studio` | Prisma Studio GUI |
|
||||
| `npm run db:seed` | Run seed (upserts all data, idempotent) |
|
||||
| `npm run db:migrate` | Create/apply migration (`prisma migrate dev`) |
|
||||
| `npm run db:generate` | Regenerate Prisma client |
|
||||
| `npm run db:studio` | Prisma Studio GUI |
|
||||
|
||||
**There is no test suite** — no runner, no test files, in either backend. Verification is `npm run build` (typechecks) + `npm run lint`. Don't claim a change is verified on the strength of a build alone; exercise the affected route or page.
|
||||
|
||||
**Windows environment.** Use `start.bat` / `stop.bat` to manage Docker PostgreSQL + Next.js together. PowerShell is the shell. Paths with brackets (e.g. `[id]`) require `-LiteralPath` in PowerShell, not `-Path`.
|
||||
|
||||
**Two backends, one database.** The Next.js app (`src/`, port 3000) and a standalone Python FastAPI service (`api/`, port 8000) both talk to the same PostgreSQL DB. Prisma owns the schema/migrations; the Python API reads/writes the same tables independently. `docker-compose.yml` runs `postgres` + the `api` service; Next.js is run separately via `npm run dev` / `start.bat`.
|
||||
|
||||
**Three compose files, and two of them are the same file.** `docker-compose.yaml` is a byte-identical copy of `docker-compose.coolify.yml` — it exists only so Coolify's default detection finds the production stack. Local dev is `docker-compose.yml` (postgres with port 5432 published + api). Because `.yml` and `.yaml` both exist, a bare `docker compose` warns about ambiguity before resolving to `docker-compose.yml`; pass `-f docker-compose.yml` explicitly, as `start.bat` does. **When you edit the Coolify stack, edit both `docker-compose.coolify.yml` and `docker-compose.yaml`** or Coolify deploys the stale copy.
|
||||
|
||||
## Prisma 7 — Critical Gotchas
|
||||
|
||||
- **Prisma client is NOT at `@prisma/client`.** It's generated to `src/generated/prisma/` and imported as `@/generated/prisma/client`.
|
||||
@@ -46,6 +50,25 @@
|
||||
- **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.
|
||||
|
||||
### Charge models (`ServicioCotizado.modeloCobro`)
|
||||
|
||||
A quoted line item is `fijo` (default), `horas`, `retainer`, or `demanda` — see `MODELOS_COBRO` in `calculators.ts`. The time-based three are `MODELOS_COBRO_TIEMPO`. Supporting fields: `esPersonalizado`, `horas`, `tarifaHora`, `montoMinimo`, `horasIncluidas`. `TARIFA_HORA_DEFAULT = 700`.
|
||||
|
||||
**`precio` is always the authoritative total.** For retainer and demanda lines, `horas × tarifaHora` is display/comparison metadata only — never re-derive a total from it. `calcularTotalesOpcion` and the PDF/Excel builders all sum `precio`.
|
||||
|
||||
### Doble propuesta (two-option quotes)
|
||||
|
||||
`Cotizacion.esDoble` turns one quote into two comparable proposals. Each `ServicioCotizado.opcion` is `"1"`, `"2"`, or `"ambas"` (shared by both). `opcionesMetadata` (Json) holds per-option `titulo` / `descripcion` / `noIncluye` (`MetaOpcion`). `calcularTotalesOpcion(servicios, "1" | "2")` sums lines matching that option **plus** all `"ambas"` lines. Anything that renders or totals a quote must handle both the single and double shape.
|
||||
|
||||
### Registro de horas (hours log → payment notes)
|
||||
|
||||
`RegistroHoras` tracks worked time against a quote so the client can be billed for it. Only offered when `esCotizacionPorTiempo(servicios)` is true (i.e. some line uses a time-based `modeloCobro`).
|
||||
|
||||
- `estadoPago` is `"por_pagar"` (default) or `"pagada"`, with `fechaPago` stamped on transition. The two buckets must stay separated in totals — pending is what goes on the payment note, paid is history. `resumenPagoHoras` / `esPagada` in `calculators.ts` are the only place that logic should live.
|
||||
- Routes: `GET|POST /api/cotizaciones/[id]/horas` (GET takes `?from=&to=` day filters), `PATCH|DELETE .../horas/[registroId]`, and `POST .../nota-horas` which renders the PDF **server-side** via `src/lib/nota-horas-pdf.ts` (no browser URL).
|
||||
- Hours are entered as `horaInicio`/`horaFin` strings and converted by `calcularHorasRango`. Dates come in as `YYYY-MM-DD` and must go through `fechaRegistroDesdeISO` to avoid UTC off-by-one — don't `new Date(iso)` directly.
|
||||
- Grouping for display/PDF: `ModoAgrupacion` = `detalle | dia | semana | mes` via `periodoAgrupacion`.
|
||||
|
||||
## 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).
|
||||
@@ -66,23 +89,33 @@
|
||||
```
|
||||
src/
|
||||
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
|
||||
(app)/ # Authed route group: dashboard, cotizaciones, clientes, catalogo, configuracion (has its own layout.tsx + Sidebar + DialogProvider)
|
||||
api/ # Next.js route handlers (REST): auth, catalogo, categorias, cotizaciones (+ horas, nota-horas, precio),
|
||||
# configuracion, paquetes, export, import, health
|
||||
login/ # Public login page
|
||||
components/ # CotizacionForm, ExportButtons, EstadoBadge, layout/Sidebar
|
||||
lib/ # auth, db, store, calculators, pdf-generator, schemas, config-helpers
|
||||
components/ # CotizacionForm (~1.4k lines), ExportButtons, EstadoBadge, layout/Sidebar, ui/DialogProvider
|
||||
lib/ # auth, db, store, calculators, schemas, config-helpers,
|
||||
# pdf-generator (quote PDF), nota-horas-pdf (hours-note PDF), excel-builder
|
||||
generated/prisma/ # Prisma client output (gitignored)
|
||||
prisma/
|
||||
schema.prisma # 13 models (User, Cliente, Cotizacion, Categoria, Paquete, FasePaquete,
|
||||
schema.prisma # 14 models (User, Cliente, Cotizacion, Categoria, Paquete, FasePaquete,
|
||||
# ServicioCatalogo, ServicioPaquete, ServicioCotizado, PlanBucefaloCotizacion,
|
||||
# Configuracion, Bono, FinanciamientoPlan)
|
||||
# RegistroHoras, Configuracion, Bono, FinanciamientoPlan)
|
||||
seed.ts # All catalog data (services, categorias, bonos, planes, config) — idempotent upserts
|
||||
migrations/ # 3 migrations
|
||||
migrations/ # 10 migrations
|
||||
api/ # Standalone Python FastAPI + MCP server (see below)
|
||||
docs/ # Business/product notes (Spanish), not code docs
|
||||
```
|
||||
|
||||
- **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).
|
||||
- **Business logic belongs in `calculators.ts`**, not in components or route handlers. It's the shared source of truth for totals, charge models, hours, phases, and formatting — and the file the Python `calculators.py` mirrors.
|
||||
|
||||
## UI Conventions
|
||||
|
||||
- **Never use the browser's native `confirm()` / `alert()` / `prompt()`.** They render as "«domain» dice…" and break the brand. Use the platform's own dialogs: `useConfirm()`, `usePrompt()`, `useToast()` from `@/components/ui/DialogProvider`, mounted once in `src/app/(app)/layout.tsx`. `confirm` and `prompt` return promises (`boolean` / `string | null`); pass `danger: true` for destructive actions.
|
||||
- Everything user-facing is in **Spanish**. Code identifiers are Spanish too (`cotizacion`, `servicio`, `horas`) — match the surrounding naming rather than introducing English terms.
|
||||
- Icons come from `lucide-react`. Colors use the CSS custom properties from `globals.css` (`bg-card-bg`, `border-border`, `text-primary`, `text-muted`), not hardcoded Tailwind palette values.
|
||||
|
||||
## Python API (`api/`) — Optional Second Backend
|
||||
|
||||
|
||||
+13
-2
@@ -246,8 +246,18 @@ async def _crear_cotizacion(conn, args: dict) -> dict:
|
||||
|
||||
|
||||
async def _obtener_cotizacion(conn, args: dict) -> dict:
|
||||
# Lista blanca de columnas: esta herramienta devuelve la fila completa al agente
|
||||
# (result = dict(cot) mas abajo), asi que un SELECT c.* publicaria cualquier
|
||||
# columna nueva sin que nadie lo decida. Espeja CotizacionResponse del REST
|
||||
# (app/models/cotizacion.py) y excluye deliberadamente "observacionesInternas",
|
||||
# que es texto que no debe salir de la app.
|
||||
cot = await conn.fetchrow(
|
||||
"""SELECT c.*, cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
|
||||
"""SELECT c.id, c.numero, c.fecha, c.vigencia, c.moneda, c."tipoCambio",
|
||||
c.proyecto, c."esquemaPago", c.estado, c."incluirBonos",
|
||||
c."incluirFinanciamiento", c."incluirIva", c."esDoble",
|
||||
c."opcionesMetadata", c.observaciones, c."clienteId", c."asesorId",
|
||||
c."createdAt", c."updatedAt",
|
||||
cl.nombre as cliente_nombre, cl.empresa as cliente_empresa,
|
||||
cl.email as cliente_email, cl.telefono as cliente_telefono
|
||||
FROM "Cotizacion" c
|
||||
LEFT JOIN "Cliente" cl ON c."clienteId" = cl.id
|
||||
@@ -266,7 +276,8 @@ async def _obtener_cotizacion(conn, args: dict) -> dict:
|
||||
)
|
||||
|
||||
plan = await conn.fetchrow(
|
||||
'SELECT * FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1',
|
||||
"""SELECT id, "cotizacionId", nivel, precio, seleccionado, "createdAt", "updatedAt"
|
||||
FROM "PlanBucefaloCotizacion" WHERE "cotizacionId" = $1""",
|
||||
args["cotizacion_id"],
|
||||
)
|
||||
|
||||
|
||||
+10
-3
@@ -9,10 +9,11 @@ from __future__ import annotations
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth import require_auth
|
||||
from app.config import settings
|
||||
from app.database import close_db, init_db
|
||||
|
||||
@@ -105,8 +106,14 @@ try:
|
||||
from mcp.server.streamable_http import StreamableHTTPServerTransport
|
||||
|
||||
@app.post("/mcp")
|
||||
async def mcp_endpoint(request: Request):
|
||||
"""MCP (Model Context Protocol) endpoint for AI agents."""
|
||||
async def mcp_endpoint(request: Request, _auth: dict = Depends(require_auth)):
|
||||
"""MCP (Model Context Protocol) endpoint for AI agents.
|
||||
|
||||
Requiere autenticacion (X-API-Key o Bearer JWT), igual que el resto del API.
|
||||
Sin esto el endpoint quedaba abierto a internet: el servicio recibe dominio
|
||||
publico en produccion (docker-compose.coolify.yml, SERVICE_FQDN_API_8000) y
|
||||
las herramientas MCP leen y escriben cotizaciones directamente.
|
||||
"""
|
||||
transport = StreamableHTTPServerTransport(mcp_server)
|
||||
return await transport.handle_request(request)
|
||||
|
||||
|
||||
@@ -0,0 +1,859 @@
|
||||
# Business Intelligence — Plugin de Propuesta Consultiva con IA
|
||||
|
||||
> **Qué es este documento:** el análisis de negocio, la filosofía y los lineamientos para una **futura** implementación de un plugin que, a partir de transcripciones de reuniones y notas de texto, genere un **segundo documento** de cotización — narrativo y consultivo — como complemento al documento económico que hoy produce el Cotizador E3 de forma manual.
|
||||
>
|
||||
> **Qué NO es:** una especificación técnica cerrada ni un plan de implementación. No hay código aquí. Es el mapa que debe leerse antes de escribir la primera línea.
|
||||
>
|
||||
> **Fuentes analizadas:**
|
||||
> - Proyecto real: `H:\MegaSync\Proyectos\Cotizador` (Next.js 16 + Prisma 7 + PostgreSQL, con API Python/MCP paralela)
|
||||
> - Plantilla de referencia: `D:\Documents\plantilla_cotizacion.html`
|
||||
> - Base de conocimiento: `Guia_Onboarding_Cliente_y_Ruta_de_Trabajo.md`, `08-Monetizacion-Onboarding-Pricing-Seguimiento.md`, `Onboarding-Cliente/04-Sesion-Propuesta-y-Continuidad.md`, `sintesis/s2-Pricing-Cobranza.md`, `sintesis/s8-Prompts-Plantillas.md`, `00-Guia-de-Uso-y-Checklist-Calidad.md`
|
||||
>
|
||||
> **Fecha:** 2026-07-28 · **Estado:** análisis previo a implementación
|
||||
|
||||
---
|
||||
|
||||
## 1. Resumen ejecutivo
|
||||
|
||||
El Cotizador E3 hoy resuelve muy bien **el qué y el cuánto**: un catálogo de servicios por fase, precios, IVA, financiamiento, planes Bucéfalo, exportación a PDF y Excel. Es un motor contable y comercial sólido.
|
||||
|
||||
Lo que no resuelve —y lo que la base de conocimiento identifica como el factor que multiplica el ticket entre 3x y 15x— es **el por qué**: el diagnóstico del dolor del cliente en sus propias palabras, la cuantificación del costo de no hacer nada, el anclaje del precio en el valor anual, y la narrativa que permite al cliente defender la inversión internamente.
|
||||
|
||||
Ese "por qué" ya se está capturando de forma parcial pero **se está tirando a la basura**:
|
||||
|
||||
- El campo `observaciones` de `Cotizacion` y el campo `notas` de cada `ServicioCotizado` se guardan en la base de datos y **no se imprimen ni en el PDF ni en el Excel** (verificado por búsqueda en `src/lib/pdf-generator.ts` y `src/lib/excel-builder.ts`: cero coincidencias).
|
||||
- Las reuniones de discovery generan transcripciones que hoy viven fuera del sistema.
|
||||
- La plantilla HTML de `D:\Documents\` ya tiene la estructura narrativa correcta (diagnóstico con semáforo, exclusiones explícitas, argumentos de venta, estado de materiales) pero se llena **a mano**, una por una.
|
||||
|
||||
**La oportunidad:** un plugin que lea el contexto humano disperso (transcripción + notas + observaciones + la cotización ya armada) y genere un segundo archivo —el documento consultivo— siguiendo la filosofía de la plantilla HTML, sin tocar un solo número.
|
||||
|
||||
**La tesis central, en una frase:**
|
||||
|
||||
> El documento manual es la **fuente de verdad económica**. El documento de IA es la **fuente de verdad narrativa**. La IA nunca inventa un precio; sólo explica el que ya existe.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inventario: los tres activos que ya existen
|
||||
|
||||
### 2.1 El Cotizador real (`H:\MegaSync\Proyectos\Cotizador`)
|
||||
|
||||
**Stack.** Next.js 16 (App Router) + React 19 + Tailwind v4 + Zustand · Prisma 7 (cliente generado en `src/generated/prisma`, driver adapter `PrismaPg`) · PostgreSQL 16 en Docker · JWT con `jose` en cookie httpOnly · PDFKit server-side · despliegue en Coolify.
|
||||
|
||||
**Segundo backend.** Un servicio FastAPI en `api/` que expone el mismo dominio como REST **y un servidor MCP en `/mcp`** con 12 herramientas y 3 recursos para agentes de IA. Esto es el hallazgo arquitectónico más importante del inventario: **el proyecto ya tiene una superficie diseñada para que una IA lo opere.**
|
||||
|
||||
Herramientas MCP existentes: `buscar_servicios`, `crear_cotizacion`, `obtener_cotizacion`, `listar_cotizaciones`, `cambiar_estado_cotizacion`, `actualizar_precio_servicio`, `duplicar_cotizacion`, `calcular_financiamiento`, `generar_pdf_cotizacion`, `obtener_configuracion`, `listar_bonos`, `listar_planes_bucefalo`. Recursos: `cotizador://servicios`, `cotizador://categorias`, `cotizador://configuracion`.
|
||||
|
||||
**Modelo de datos relevante.**
|
||||
|
||||
| Modelo | Campos que importan para el plugin |
|
||||
|---|---|
|
||||
| `Cotizacion` | `numero`, `fecha`, `vigencia`, `moneda`, `esquemaPago`, `estado`, `esDoble`, `opcionesMetadata` (JSON), **`observaciones`** |
|
||||
| `ServicioCotizado` | `nombre`, `fase`, `tipoPago`, `precio`, `tiempoEntrega`, `entregables` (JSON), `beneficios` (JSON), **`notas`**, `modeloCobro`, `horas`, `tarifaHora`, `opcion` |
|
||||
| `ServicioCatalogo` | `precioBase`, `descripcion`, `entregablesDefault`, `fase`, `nivel`, `variante` |
|
||||
| `Cliente` | `nombre`, `empresa`, `email`, `telefono`, `rfc` |
|
||||
| `RegistroHoras` | `fecha`, rango horario, `horas`, `tarifaHora`, `descripcion`, `estadoPago` |
|
||||
| `PlanBucefaloCotizacion` | `nivel`, `precio` |
|
||||
| `FinanciamientoPlan` | `meses` (3/6/9/12), `tasa`, `comision`, `montoMinimo`, `iva` |
|
||||
| `Bono` | `numero`, `titulo`, `descripcion` |
|
||||
| `Configuracion` | key-value: `color_primario`, `color_secundario`, `logo_base64` |
|
||||
|
||||
**Reglas de negocio ya codificadas** (`src/lib/calculators.ts`):
|
||||
- `IVA_RATE = 0.16`
|
||||
- `TARIFA_HORA_DEFAULT = 700`
|
||||
- Número de cotización: `UJ{YY}{MM}{InicialesAsesor}{seq}` (ej. `UJ2605AG001`)
|
||||
- Vigencia = 15 días hábiles desde la fecha (excluye sábado y domingo)
|
||||
- 4 fases: F0 Auditoría · F1 Setup/Infra · F2 Publicidad/Manejo · F3 Contenido/SEO
|
||||
- Planes Bucéfalo mensuales: básico $1,000 · estándar $3,500 · premium $4,500 · empresarial $7,500 (hardcodeados en `calculators.ts`, **no en la BD**)
|
||||
- Modelos de cobro: `fijo`, `horas`, `retainer`, `demanda`
|
||||
- 6 paquetes PyME pre-armados + paquete "General" con todo el catálogo
|
||||
|
||||
**El hallazgo clave: `esDoble` ya existe.** El sistema soporta cotización de doble propuesta: `esDoble: boolean` + `opcionesMetadata` con estructura `{ "1": {titulo, descripcion, noIncluye}, "2": {...} }`, y cada `ServicioCotizado` puede asignarse a la opción `"1"`, `"2"` o `"ambas"`. La herramienta MCP `crear_cotizacion` ya acepta `es_doble=true`. **Ya hay una vía nativa para presentar opciones comparables** — y la base de conocimiento dice que 3 opciones cierran 16-28% más alto. Está a mitad de camino.
|
||||
|
||||
### 2.2 La plantilla HTML (`D:\Documents\plantilla_cotizacion.html`)
|
||||
|
||||
Un documento de 690 líneas, autocontenido, con tokens CSS, tipografía editorial (Playfair Display serif + DM Sans), tema oscuro (`--bg: #0a0f1e`), responsive y con reglas `@media print` (`page-break-inside: avoid`).
|
||||
|
||||
Su valor no es el CSS. Es **la arquitectura argumental**:
|
||||
|
||||
| # | Sección | Función retórica |
|
||||
|---|---|---|
|
||||
| Hero | Título + subtítulo + 4 metadatos (elaborado por / cliente / plataforma / vigencia) | Encuadre y contexto |
|
||||
| 01 | **Diagnóstico del proyecto** — un `.quote` con el insight clave + filas con semáforo (`dot-red` problema crítico, `dot-amber` área de mejora, `dot-blue` oportunidad, `dot-green` ventaja existente) | Demuestra que entendiste el negocio antes de vender |
|
||||
| 02 | **Alcance y desglose económico** — filas categoría / entregable+descripción / precio, más caja de totales (subtotal, descuento, IVA, total) | El qué y el cuánto |
|
||||
| 03 | **Qué no incluye esta propuesta** — lista con ✕ | Anti-scope-creep, preventivo |
|
||||
| 04 | **Por qué tiene sentido esta solución** — pares etiqueta/explicación | Argumentos de venta traducidos a beneficio |
|
||||
| 05 | **Plan de mantenimiento opcional** — dos tarjetas (mensual vs anual, una `.featured`) con features `highlight` / `good` / `muted` | Siembra de recurrencia, con límites explícitos |
|
||||
| 06 | **Estado de materiales del cliente** — dos tarjetas: materiales (`done`/`pend`) y decisiones pendientes (`pend`/`neutral`) | Transparencia y transferencia de responsabilidad |
|
||||
| Footer | Datos de contacto + condiciones (50/50, CFDI, avance condicionado a materiales, cambios se cotizan aparte) | Blindaje comercial |
|
||||
|
||||
**El semáforo de la sección 01 es el activo intelectual más valioso de la plantilla.** Obliga a clasificar cada hallazgo por urgencia — exactamente lo que la base de conocimiento pide en las 3 Preguntas de Oro. Y el marcador `dot-green` ("ventaja existente") obliga a reconocer lo que el cliente ya hizo bien, que es el detalle que convierte una propuesta en una conversación entre pares.
|
||||
|
||||
**La sección 06 es la que resuelve la honestidad de la IA.** Ver §10.2.
|
||||
|
||||
### 2.3 La base de conocimiento (vault de Obsidian)
|
||||
|
||||
Sintetizada desde 91 transcripts. Lo que aporta al plugin, en orden de utilidad:
|
||||
|
||||
**Framework de diagnóstico (Fase 2 del playbook):**
|
||||
- Las 3 Preguntas de Oro: (1) ¿cuál es el problema de **negocio**, no técnico? (2) ¿cuánto le está costando hoy? (3) ¿cuánto ingreso/ahorro esperas en 12 meses?
|
||||
- Costo desglosado en 4 dimensiones: dinero directo · tiempo del equipo · costo de oportunidad · costo de error humano
|
||||
- Filtro de viabilidad de 4 preguntas: costo de no hacer nada · ¿los datos existen HOY? · ¿quién es el usuario final real? · ¿qué pasa si funciona?
|
||||
- The Mom Test: preguntas ancladas en el pasado ("¿cómo lo resolvieron la última vez?"), no en el futuro hipotético
|
||||
|
||||
**Framework de pricing:**
|
||||
- `Valor Anual = (horas/mes × costo horario × 12) + costo anual de errores + ingresos recuperados`
|
||||
- `Precio objetivo = 15% a 25% del Valor Anual`
|
||||
- Marco VALOR (techo) / COSTO real (piso, 2-3× horas técnicas) / MERCADO
|
||||
- Anclaje: presentar A (2× objetivo) → B (objetivo, se elige 60%) → C (50% del objetivo)
|
||||
- 3 opciones cierran **16-28% más alto** que una sola
|
||||
|
||||
**Reglas transversales de comunicación** (del checklist de calidad):
|
||||
1. Hablar de resultados, no de herramientas
|
||||
2. **Usar las palabras del cliente** — registrar frases textuales del dolor y reutilizarlas
|
||||
3. Investigar hechos pasados
|
||||
4. 80% habla el cliente / 20% el consultor
|
||||
5. **Distinguir evidencia de hipótesis** — etiquetar cifras como confirmadas, estimadas o pendientes de validar
|
||||
6. No prometer solución antes de validar
|
||||
7. Recapitular y confirmar
|
||||
8. Comunicar riesgos temprano
|
||||
|
||||
**Separación de tres conceptos** (Sesión 4 de onboarding): proyecto inicial ≠ mantenimiento ≠ evolución/retainer. Cada uno con su alcance, precio y forma de contratación explícitos.
|
||||
|
||||
**Criterio ético de monetización:** una mejora posterior se propone sólo si hay evidencia de que resuelve un problema real, el cliente puede decidir libremente, y el nuevo alcance/precio/plazo quedan claros.
|
||||
|
||||
**Los 15 errores de monetización más caros** — de los cuales el plugin puede prevenir directamente: mandar propuesta con 1 sola opción (#4), aceptar scope creep sin orden de cambio (#7), entregar sin documentación (#8), no ofrecer retainer post-entrega (#9), vender habilidades técnicas en vez de resultados (#13).
|
||||
|
||||
---
|
||||
|
||||
## 3. Diagnóstico: la brecha entre los tres activos
|
||||
|
||||
Aplico a este proyecto el mismo semáforo de la plantilla.
|
||||
|
||||
### 🔴 Problema crítico — el contexto humano se captura y se descarta
|
||||
|
||||
`Cotizacion.observaciones` y `ServicioCotizado.notas` existen en el esquema, se llenan en el formulario (`CotizacionForm.tsx:1222`, placeholder literal *"Notas adicionales para la cotizacion..."*) y **no aparecen en ningún exportador**. Es información de discovery que el asesor ya se tomó el trabajo de escribir y que muere en la base de datos.
|
||||
|
||||
Esto no es sólo desperdicio: es la evidencia de que el sistema fue diseñado como calculadora y no como instrumento de venta consultiva. El plugin no tiene que crear el canal de entrada — **tiene que empezar a leerlo.**
|
||||
|
||||
### 🔴 Problema crítico — el pricing es de menú, la filosofía es de valor
|
||||
|
||||
El Cotizador calcula desde `ServicioCatalogo.precioBase`: precio fijo por servicio, sumado. Es pricing de catálogo. La base de conocimiento predica pricing por valor: 15-25% del valor anual del problema.
|
||||
|
||||
**No son incompatibles, pero hoy no se hablan.** El precio de catálogo es el piso operativo y la base de facturación (con CFDI, IVA, RFC — cosas que un porcentaje del valor anual no te da). El valor anual es el argumento que justifica ese precio ante el cliente.
|
||||
|
||||
El plugin no debe reemplazar uno por el otro. Debe **superponer la capa de valor sobre la capa contable**: tomar el total que el humano ya fijó y demostrar que representa X% del valor anual del problema. Si el asesor cotizó $50,000 MXN y la IA detecta en la transcripción un dolor de ~$400,000 MXN/año, la propuesta escribe *"la inversión equivale al 12.5% del costo anual del problema"* — sin mover el $50,000.
|
||||
|
||||
### 🟠 Área de mejora — la doble propuesta existe pero está subutilizada
|
||||
|
||||
`esDoble` + `opcionesMetadata` da dos opciones. La filosofía pide tres, con anclaje psicológico (cara primero). El plugin puede generar el **argumento** de las tres opciones en su documento narrativo aun cuando el documento económico sólo tenga dos, o recomendar al asesor cómo estructurar las dos que sí caben.
|
||||
|
||||
Aquí hay que ser honesto: **no** hay que forzar tres opciones si el proyecto no las admite. La Sesión 4 lo dice explícito: *"Cuando haya alternativas reales, usa tres opciones… las otras no son una trampa ni una lista de extras artificiales."*
|
||||
|
||||
### 🟠 Área de mejora — la base de conocimiento es de otro negocio
|
||||
|
||||
Esto es una tensión real que hay que documentar antes de que alguien la descubra a medio proyecto:
|
||||
|
||||
| | Base de conocimiento | Cotizador E3 |
|
||||
|---|---|---|
|
||||
| Negocio | Freelance/consultoría de automatización con Python | Consultoría de digitalización y marketing digital |
|
||||
| Geografía | LATAM, cobro internacional | Querétaro, MX |
|
||||
| Moneda | USD (Wise/Stripe/Payoneer) | MXN con IVA 16% y CFDI |
|
||||
| Rangos | $300 – $25,000 USD | $7,490 – $90,800 MXN (paquetes) |
|
||||
| Servicios | Scripts, RAG, dashboards, APIs | Ads, SEO, WordPress, CRM Bucéfalo, contenido |
|
||||
| Recurrencia | Retainer de mantenimiento de software | Mensualidades de manejo de pauta y CRM |
|
||||
|
||||
**Los frameworks son transferibles; las cifras no.** La IA debe importar el *método* de diagnóstico, el *marco* de pricing y las *reglas* de comunicación — y jamás las tablas de precios en USD. Si el plugin sugiere "$1,800 USD" en una cotización de E3, es un bug, no una feature.
|
||||
|
||||
### 🔵 Oportunidad — el servidor MCP ya está construido
|
||||
|
||||
`api/app/mcp/server.py` (453 líneas) + `tools.py` (305 líneas) exponen el catálogo, las cotizaciones y la configuración a agentes. El plugin no necesita una capa de acceso a datos nueva: puede consumir la que existe. Y como los cálculos viven duplicados en `src/lib/calculators.ts` y `api/app/services/calculators.py` con paridad obligatoria, la IA puede **leer** totales sin riesgo de recalcularlos mal.
|
||||
|
||||
### 🔵 Oportunidad — la plantilla HTML es un contrato de salida listo
|
||||
|
||||
No hay que diseñar el formato del segundo documento. La plantilla ya define las secciones, la jerarquía y el vocabulario visual. El trabajo es mapear cada sección a su fuente de datos (ver §7) y dejar que la IA llene el contenido, no la estructura.
|
||||
|
||||
### 🟢 Ventaja existente — el asesor ya hace el trabajo difícil
|
||||
|
||||
El humano ya: califica al cliente, corre la reunión, elige servicios del catálogo, ajusta precios, decide el esquema de pago. El plugin no reemplaza ese juicio. **Se monta sobre él.** Esto reduce drásticamente el riesgo: si la IA falla, el documento manual sigue siendo entregable.
|
||||
|
||||
### 🟢 Ventaja existente — el vault ya tiene el system prompt esbozado
|
||||
|
||||
`sintesis/s8-Prompts-Plantillas.md` §1 tiene un system prompt maestro, §3 un prompt de diagnóstico de proyecto, §4 un prompt de propuesta comercial y §9 una plantilla de output estándar. No hay que empezar de cero — hay que **adaptar de USD/Python a MXN/marketing digital**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Filosofía: los 10 principios del plugin
|
||||
|
||||
Estos son los principios no negociables. Si una decisión de implementación los contradice, la decisión está mal.
|
||||
|
||||
### P1 · La IA no toca los números
|
||||
|
||||
La IA **lee** precios, totales, IVA, vigencia y financiamiento. **Nunca los calcula, propone ni modifica.** Todo número que aparezca en el documento de IA debe ser byte-por-byte el mismo que produjo `calculators.ts`.
|
||||
|
||||
*Por qué:* un precio inventado por la IA en un documento con el logo de E3 es un pasivo comercial y potencialmente fiscal. Además destruye la confianza en todo el resto del documento.
|
||||
|
||||
*Implicación técnica:* los totales se inyectan como datos ya calculados en el prompt y se instruye a la IA a citarlos literalmente; idealmente el render final los toma del objeto de cotización, no del texto generado.
|
||||
|
||||
### P2 · Dos documentos, dos trabajos, cero solapamiento
|
||||
|
||||
| | Documento 1 (manual, PDF/Excel) | Documento 2 (IA, HTML) |
|
||||
|---|---|---|
|
||||
| Autor | Asesor humano | IA, revisada por el asesor |
|
||||
| Función | Contractual y económica | Consultiva y narrativa |
|
||||
| Contenido | Servicios, precios, totales, condiciones | Diagnóstico, valor, argumentos, exclusiones, pendientes |
|
||||
| Números | **Fuente de verdad** | Espejo, sólo citados |
|
||||
| Si se contradicen | Gana este | Se corrige este |
|
||||
| Vive en | `pdf-generator.ts` / `excel-builder.ts` | Nuevo generador |
|
||||
|
||||
El documento 2 es un **superconjunto narrativo** y un **subconjunto numérico** del documento 1.
|
||||
|
||||
### P3 · Toda afirmación es trazable
|
||||
|
||||
Cada dato que la IA escriba sobre el cliente debe apuntar a su origen: una línea de transcripción, un archivo de notas, un campo del formulario, o el catálogo. Si no hay origen, no se escribe.
|
||||
|
||||
*Implicación:* el contrato de salida (§8) lleva un campo `evidencia` por afirmación. El documento renderizado puede ocultarlo al cliente, pero el asesor debe poder auditarlo antes de enviar.
|
||||
|
||||
### P4 · Evidencia, estimación e hipótesis se etiquetan distinto
|
||||
|
||||
Directo de la regla 5 del checklist de calidad. Tres estados:
|
||||
|
||||
- **Confirmado** — el cliente lo dijo textualmente o hay un dato duro.
|
||||
- **Estimado** — se derivó de algo que el cliente dijo, con un cálculo explícito.
|
||||
- **Por validar** — hipótesis del consultor que aún nadie confirmó.
|
||||
|
||||
Un "estimado" presentado como "confirmado" es la forma más rápida de perder credibilidad en una reunión de presentación. La IA debe marcar cada cifra.
|
||||
|
||||
### P5 · Las palabras del cliente son sagradas
|
||||
|
||||
La sección de diagnóstico debe usar frases textuales del cliente, no paráfrasis corporativas. Si en la transcripción dice *"se nos van los clientes porque nadie contesta el WhatsApp el fin de semana"*, eso va entre comillas — no *"oportunidades de mejora en la gestión omnicanal de la comunicación"*.
|
||||
|
||||
*Regla operativa:* el bloque `.quote` de la sección 01 debe ser **siempre** una cita literal, nunca una síntesis de la IA.
|
||||
|
||||
### P6 · Se vende resultado, no herramienta
|
||||
|
||||
Error #13 de la lista de errores de monetización. Traducir toda capacidad técnica a tiempo recuperado, errores evitados, ingresos protegidos o tranquilidad operativa.
|
||||
|
||||
*Malo:* "Configuración de Google Ads con estructura SKAG y scripts de puja automatizada."
|
||||
*Bueno:* "Cada peso de pauta se dirige a las búsquedas que sí compran, en lugar de repartirse entre términos que sólo generan clics. Reportamos costo por prospecto real, no impresiones."
|
||||
|
||||
### P7 · Lo que NO incluye vale tanto como lo que sí
|
||||
|
||||
La sección 03 de la plantilla no es un trámite: es prevención de scope creep, que según la base de conocimiento promedia **27% de sobrecosto** en proyectos sin control de alcance. La IA debe generar exclusiones **específicas del proyecto**, no genéricas.
|
||||
|
||||
*Malo:* "No incluye servicios no mencionados."
|
||||
*Bueno:* "No incluye la migración del histórico de 4 años de pedidos que mencionaron en la reunión; eso se evalúa como fase 2 cuando el sistema base esté operando."
|
||||
|
||||
### P8 · Mantenimiento y evolución van separados, con límites por escrito
|
||||
|
||||
De la Sesión 4: *"No prometas mantenimiento gratuito indefinido y no vendas un retainer como si incluyera trabajo ilimitado."* La sección 05 debe listar explícitamente qué **no** cubre el plan (la plantilla ya tiene la clase `.plan-feat.muted` con ✕ para eso). El backlog de evolución se registra aparte y **no se compromete**.
|
||||
|
||||
### P9 · La incertidumbre se declara, no se rellena
|
||||
|
||||
Si falta información, la IA **no la inventa ni la omite silenciosamente**: la escribe en la sección 06 como material o decisión pendiente. Ver §10.2 — esto es el mecanismo central de honestidad del sistema.
|
||||
|
||||
### P10 · El asesor aprueba antes de enviar
|
||||
|
||||
El documento de IA nace en estado borrador. Nadie lo manda al cliente sin que un humano lo lea. El plugin es un copiloto de redacción, no un autopiloto de ventas.
|
||||
|
||||
*Corolario:* el flujo de UI debe hacer que revisar sea fácil (diff, semáforo de confianza, evidencia expandible), no que aprobar sea rápido.
|
||||
|
||||
---
|
||||
|
||||
## 5. Insights de monetización que hay que codificar
|
||||
|
||||
Estos son los mecanismos concretos de la base de conocimiento que el plugin debe hacer operativos. Cada uno con su traducción a comportamiento del sistema.
|
||||
|
||||
| # | Insight de la base de conocimiento | Cómo lo codifica el plugin |
|
||||
|---|---|---|
|
||||
| M1 | El cliente describe síntomas; tu trabajo es diagnosticar la enfermedad | La sección 01 nunca repite la petición del cliente ("quiero una página web"); reformula al problema de negocio ("pierden prospectos porque no tienen dónde mandarlos desde los anuncios") |
|
||||
| M2 | El costo del problema se desglosa en 4 dimensiones | La IA busca en la transcripción las 4 dimensiones y las presenta con su etiqueta de confianza; las que faltan van a la sección 06 como pendiente de validar |
|
||||
| M3 | Precio = 15-25% del valor anual | La IA calcula el **ratio inverso**: dado el total que fijó el humano y el valor anual detectado, reporta qué porcentaje representa. Si sale <10%, señala al asesor que probablemente subcotizó. Si sale >40%, señala que va a haber objeción de precio |
|
||||
| M4 | 3 opciones cierran 16-28% más alto | Si `esDoble = false`, el plugin sugiere al asesor un esquema de 2-3 opciones basado en los servicios ya elegidos (quitando o agregando del mismo catálogo, sin inventar servicios) |
|
||||
| M5 | Anclaje: la opción cara primero | En el render de la sección de opciones, el orden es A (mayor) → B (recomendada, `.featured`) → C (esencial) |
|
||||
| M6 | La objeción de precio se responde con alcance, no con descuento | El documento incluye, en notas internas para el asesor (no visibles al cliente), el "plan de recorte": qué servicios quitar primero si el cliente pide bajar el precio, ordenados por menor impacto en el resultado prometido |
|
||||
| M7 | Anticipo 50% obligatorio antes de empezar | El footer lo enuncia siempre; no es opcional ni negociable por la IA |
|
||||
| M8 | Updates 2×/semana aumentan confiabilidad percibida +125% | La propuesta incluye el compromiso de cadencia de comunicación como **parte del alcance**, no como cortesía |
|
||||
| M9 | El retainer se ofrece a las 2 semanas post-entrega, no al inicio | La sección 05 presenta el mantenimiento como **opción con límites explícitos**, nunca como requisito ni como amenaza velada ("para que no se rompa") |
|
||||
| M10 | Las 3 capas de documentación triplican la tarifa | Los entregables documentales (README ejecutivo, registro de decisiones, guía de mantenimiento — adaptados a marketing digital: manual de operación de campañas, bitácora de decisiones de segmentación, guía de qué puede ajustar el cliente solo) se listan como entregables con valor, no como anexos |
|
||||
| M11 | Red flags del cliente | El plugin genera, en notas internas, una sección de riesgo: red flags detectadas en la transcripción (pidió descuento antes de entender el valor, no hay decisor presente, no hay cifra del problema, "hagan todo y luego vemos") |
|
||||
| M12 | Separar proyecto / mantenimiento / evolución | Tres bloques distintos en el documento, con encabezados que lo hagan obvio. El backlog de evolución dice explícitamente "no comprometido en esta propuesta" |
|
||||
| M13 | Criterio ético: sólo se propone lo que hay evidencia de que resuelve un problema real | La IA **no puede** agregar un servicio del catálogo que el asesor no eligió. Puede señalar en notas internas *"el cliente mencionó X, que el catálogo cubre con el servicio Y; considera si aplica"* — pero no lo mete en la propuesta |
|
||||
|
||||
### 5.1 El indicador de salud de la cotización
|
||||
|
||||
De M3 y M11 sale la feature más útil para el negocio y la más fácil de implementar: un semáforo interno, sólo para el asesor, que evalúa la cotización antes de enviarla.
|
||||
|
||||
| Señal | Cómo se calcula | Qué significa |
|
||||
|---|---|---|
|
||||
| Ratio precio/valor | `total / valor_anual_detectado` | <10% subcotizado · 15-25% en rango · >40% objeción probable |
|
||||
| Dolor cuantificado | ¿hay al menos una cifra confirmada del costo del problema? | Si no: no hay ancla de valor; la propuesta va a competir por precio |
|
||||
| Usuario final entrevistado | ¿la transcripción incluye a quien operará, no sólo al que firma? | Si no: riesgo de baja adopción |
|
||||
| Decisor presente | ¿se identificó a quien autoriza el gasto? | Si no: riesgo de "voy a preguntar" |
|
||||
| Exclusiones definidas | ¿hay al menos 3 exclusiones específicas? | Si no: riesgo de scope creep |
|
||||
| Materiales pendientes | conteo de la sección 06 | Alto: la fecha de entrega es irreal |
|
||||
| Red flags | conteo de señales de la lista | ≥2: evaluar si conviene el proyecto |
|
||||
|
||||
Esto no es un adorno. Es **business intelligence real** sobre el pipeline: agregado en el tiempo, dice qué tipo de cotizaciones cierran y cuáles no.
|
||||
|
||||
---
|
||||
|
||||
## 6. Anatomía del documento de IA
|
||||
|
||||
El documento 2 hereda la estructura de la plantilla HTML, con tres cambios. Marcados **[NUEVO]** los que no existen en la plantilla.
|
||||
|
||||
| Sección | Origen del contenido | Notas |
|
||||
|---|---|---|
|
||||
| **Hero** | `Cliente`, `Cotizacion.numero`, `fecha`, `vigencia`, `proyecto`, asesor | El subtítulo lo redacta la IA: una o dos oraciones que enmarcan el problema en lenguaje del cliente |
|
||||
| **01 · Diagnóstico** | Transcripción + notas + observaciones | `.quote` = cita literal obligatoria. Filas con semáforo: al menos 1 rojo, 1 ámbar, 1 azul, 1 verde. Cada fila con etiqueta de confianza |
|
||||
| **01b · Valor del problema** **[NUEVO]** | Transcripción, con las 4 dimensiones de costo | Tabla: dimensión / cifra / estado (confirmado/estimado/por validar) / evidencia. Cierra con el valor anual y el ratio de M3 |
|
||||
| **02 · Resultados a lograr** **[NUEVO]** | Derivado del diagnóstico + entregables de los servicios elegidos | 3-5 resultados de negocio cuantificados. Es el puente entre problema y precio. La plantilla no lo tiene y la base de conocimiento lo pide en toda propuesta |
|
||||
| **03 · Alcance y desglose económico** | `ServicioCotizado[]`, agrupado por fase y `tipoPago` | **Números literales del documento 1.** La IA sólo reescribe las descripciones en lenguaje de resultado (P6). Separar pago único de mensual, como ya hace el PDF |
|
||||
| **04 · Opciones de inversión** | `esDoble` + `opcionesMetadata` | Sólo si hay opciones reales. Orden A → B(`.featured`) → C |
|
||||
| **05 · Qué no incluye** | Transcripción (menciones fuera de alcance) + `opcionesMetadata.noIncluye` | Exclusiones específicas, nunca genéricas (P7) |
|
||||
| **06 · Por qué tiene sentido** | `ServicioCotizado.beneficios` + diagnóstico | Cada beneficio amarrado a un hallazgo del diagnóstico. Si un beneficio no responde a ningún dolor detectado, se elimina |
|
||||
| **07 · Plan de mantenimiento** | `PlanBucefaloCotizacion`, servicios `tipoPago: mensual`, `modeloCobro: retainer` | Con exclusiones explícitas (`.plan-feat.muted`). Mensual vs anual si aplica |
|
||||
| **08 · Estado de materiales y decisiones** | Todo lo que la IA no pudo determinar + menciones de dependencias en la transcripción | **El canal de honestidad.** Ver §10.2 |
|
||||
| **09 · Backlog de evolución** **[NUEVO]** | Fricciones mencionadas por el cliente que no entran en fase 1 | Con encabezado explícito: "registrado, no comprometido en esta propuesta" |
|
||||
| **Footer** | `Configuracion` (logo, colores) + condiciones fijas | 50/50, CFDI, avance condicionado a materiales, cambios se cotizan aparte, MXN |
|
||||
| **Anexo interno** **[NUEVO]** | Todo lo de §5.1 + M6 + M11 | **No se entrega al cliente.** Semáforo de salud, plan de recorte, red flags, evidencia completa. Puede ser una segunda página o un archivo aparte |
|
||||
|
||||
### 6.1 Nota sobre el anexo interno
|
||||
|
||||
Esta es la parte del diseño que más valor operativo tiene y la que más cuidado necesita. Un documento con "plan de recorte de precio" y "red flags del cliente" enviado por error al cliente es un incidente serio.
|
||||
|
||||
Recomendación: **archivo separado**, nombre distinto y visualmente inconfundible (fondo distinto, marca de agua "INTERNO — NO ENVIAR"). No una sección oculta del mismo archivo, no un `display: none`, no un comentario HTML.
|
||||
|
||||
---
|
||||
|
||||
## 7. Arquitectura de ingesta de contexto
|
||||
|
||||
### 7.1 Las cuatro fuentes
|
||||
|
||||
| Fuente | Formato | Volumen típico | Confianza |
|
||||
|---|---|---|---|
|
||||
| **Transcripción de reunión** | `.txt`, `.md`, `.vtt`, `.srt` | 60 min ≈ 9,000 palabras ≈ 13K tokens | Alta para citas; media para cifras (la gente redondea al hablar) |
|
||||
| **Notas del asesor** | `.md`, `.txt` | 200-2,000 palabras | Alta — es interpretación experta |
|
||||
| **Campos del formulario** | `Cotizacion.observaciones`, `ServicioCotizado.notas` | 50-500 palabras | Alta — escrito con intención |
|
||||
| **Cotización estructurada** | JSON desde Prisma/MCP | 3-5K tokens | **Absoluta** — es la fuente de verdad |
|
||||
|
||||
### 7.2 Precedencia en caso de conflicto
|
||||
|
||||
Cuando dos fuentes se contradicen:
|
||||
|
||||
```
|
||||
Cotización estructurada > Notas del asesor > Observaciones > Transcripción
|
||||
```
|
||||
|
||||
*Razón:* la cotización es dato validado; las notas son interpretación experta posterior a la reunión; la transcripción es materia prima cruda con ruido de ASR, muletillas y correcciones en vivo ("son como 20 horas… no, más bien 30").
|
||||
|
||||
Si el conflicto es sobre una **cifra**, la IA no elige: lo reporta en la sección 08 como decisión pendiente. *"En la reunión se mencionaron 20 y 30 horas semanales en momentos distintos; confirmar la cifra antes de calcular el valor anual."*
|
||||
|
||||
### 7.3 Pipeline de preprocesamiento
|
||||
|
||||
Antes de que un token llegue al modelo:
|
||||
|
||||
1. **Normalización de formato** — VTT/SRT a texto plano con marcas de tiempo opcionales; Markdown se conserva.
|
||||
2. **Sanitización de PII** — Esto es obligatorio, no opcional. Las transcripciones contienen nombres de empleados, comentarios sobre desempeño, cifras salariales, quejas sobre terceros. Se redactan nombres de personas que no son el interlocutor comercial, y se marca cualquier mención de temas laborales, legales o de salud como bloque excluido del documento final.
|
||||
3. **Segmentación por hablante** cuando el formato lo permite — permite aplicar la regla 80/20 y detectar si se entrevistó al usuario operativo o sólo al decisor.
|
||||
4. **Conteo de tokens** con `client.messages.countTokens` (nunca con estimadores de otros proveedores) para decidir si cabe entero o requiere segmentación.
|
||||
5. **Detección de idioma** — si la transcripción tiene mezcla, se preserva; el documento final va en español.
|
||||
|
||||
### 7.4 Dónde se suben los archivos
|
||||
|
||||
Tres opciones, en orden de preferencia:
|
||||
|
||||
1. **Nuevo campo en la UI de cotización** — una zona de drop en la pestaña de observaciones. Los archivos se guardan como adjuntos vinculados a la `Cotizacion`. Requiere un modelo nuevo (`ContextoCotizacion`: `cotizacionId`, `tipo`, `nombreArchivo`, `contenido`/`ruta`, `createdAt`).
|
||||
2. **Carpeta convenida en disco** — más simple para un MVP, peor para el despliegue en Coolify (contenedor sin volumen persistente por defecto). Sirve para probar localmente.
|
||||
3. **Files API de Anthropic** (beta `files-api-2025-04-14`) — útil si se quiere pasar un PDF de reunión sin extraer texto; límite 500 MB. Requiere el header beta tanto en el upload como en el `messages.create` que lo referencia.
|
||||
|
||||
Para el MVP: opción 2 para validar la calidad del output; opción 1 para producción.
|
||||
|
||||
---
|
||||
|
||||
## 8. Contrato de salida
|
||||
|
||||
La IA **no genera HTML**. Genera un objeto estructurado que un renderizador convierte a HTML usando la plantilla. Esto es crítico por tres razones: valida la estructura antes de renderizar, permite que el asesor edite campo por campo, y evita que la IA rompa el CSS o inyecte markup arbitrario.
|
||||
|
||||
La API soporta esto nativamente con `output_config: { format: { type: "json_schema", schema: {...} } }` (structured outputs), disponible en Opus 5, Sonnet 5 y Haiku 4.5. En TypeScript hay helper de Zod: `zodOutputFormat()` + `client.messages.parse()`, que devuelve el objeto ya validado en `response.parsed_output`.
|
||||
|
||||
### 8.1 Forma conceptual del schema
|
||||
|
||||
```
|
||||
PropuestaConsultiva
|
||||
├─ meta: { numeroCotizacion, cliente, empresa, proyecto, vigencia, asesor }
|
||||
├─ hero: { titulo, subtitulo }
|
||||
├─ diagnostico:
|
||||
│ ├─ citaLiteral: { texto, origen } ← obligatorio, literal
|
||||
│ └─ hallazgos[]: { urgencia: rojo|ambar|azul|verde,
|
||||
│ titulo, descripcion,
|
||||
│ confianza: confirmado|estimado|por_validar,
|
||||
│ evidencia }
|
||||
├─ valorProblema:
|
||||
│ ├─ dimensiones[]: { tipo: dinero|tiempo|oportunidad|error,
|
||||
│ descripcion, montoAnualMXN|null,
|
||||
│ confianza, evidencia }
|
||||
│ ├─ valorAnualEstimadoMXN: number|null
|
||||
│ └─ notaMetodologia: string ← cómo se llegó a la cifra
|
||||
├─ resultados[]: { enunciado, metrica, lineaBase|null, periodoMedicion }
|
||||
├─ alcance:
|
||||
│ └─ items[]: { fase, categoria, nombre,
|
||||
│ descripcionResultado, ← reescritura en lenguaje de beneficio
|
||||
│ precioMXN, tipoPago, tiempoEntrega }
|
||||
├─ totales: { subtotal, descuento, iva, total, moneda } ← COPIADO, nunca calculado
|
||||
├─ opciones[]|null: { etiqueta, titulo, descripcion, paraQuien,
|
||||
│ incluye[], noIncluye[], recomendada: bool }
|
||||
├─ exclusiones[]: { texto, razon, origen } ← específicas del proyecto
|
||||
├─ beneficios[]: { etiqueta, texto, hallazgoRelacionado } ← amarrado al diagnóstico
|
||||
├─ mantenimiento|null:
|
||||
│ └─ planes[]: { nombre, periodicidad, precioMXN, incluye[], noIncluye[], destacado }
|
||||
├─ pendientes:
|
||||
│ ├─ materiales[]: { texto, estado: recibido|pendiente, bloqueaEntrega: bool }
|
||||
│ └─ decisiones[]: { texto, quienDecide, fechaSugerida }
|
||||
├─ backlogEvolucion[]: { problema, impactoPosible, evidencia, momentoSugerido }
|
||||
└─ interno: ← NUNCA en el documento del cliente
|
||||
├─ salud: { ratioPrecioValor, dolorCuantificado, usuarioFinalEntrevistado,
|
||||
│ decisorPresente, exclusionesDefinidas, materialesPendientes }
|
||||
├─ planRecorte[]: { servicio, impactoEnResultado: bajo|medio|alto, ordenSugerido }
|
||||
├─ redFlags[]: { señal, evidencia, severidad }
|
||||
└─ huecos[]: { queFalta, porQueImporta, comoObtenerlo }
|
||||
```
|
||||
|
||||
### 8.2 Restricciones del JSON Schema en la API
|
||||
|
||||
Hay que diseñar el schema con estos límites en mente:
|
||||
|
||||
- **Soportado:** tipos básicos, `enum`, `const`, `anyOf`, `allOf`, `$ref`/`$def`, formatos de string (`date`, `date-time`, `email`, `uri`, `uuid`).
|
||||
- **No soportado:** esquemas recursivos, restricciones numéricas (`minimum`, `maximum`, `multipleOf`), restricciones de string (`minLength`, `maxLength`), restricciones complejas de array.
|
||||
- **Obligatorio:** `additionalProperties: false` en todos los objetos.
|
||||
- Los SDK de Python y TypeScript quitan automáticamente las restricciones no soportadas del schema enviado y las validan del lado del cliente — así que se pueden usar en Zod, sabiendo que la validación es local.
|
||||
- Incompatible con citations (devuelve 400) y con prefill de mensaje de asistente.
|
||||
- Primera petición con un schema nuevo tiene costo de compilación; después hay caché de 24h.
|
||||
|
||||
### 8.3 Reglas de validación post-generación (código, no IA)
|
||||
|
||||
Después de recibir el objeto y antes de renderizar:
|
||||
|
||||
1. `totales` debe ser **idéntico** al de la cotización en BD. Si no, se descarta el objeto y se registra el fallo. Sin excepciones.
|
||||
2. Cada `alcance.items[].precioMXN` debe existir en `ServicioCotizado`. Ningún item nuevo, ningún precio distinto.
|
||||
3. `diagnostico.citaLiteral.texto` debe encontrarse como substring (normalizado) en alguna fuente de contexto. Si no aparece, se marca como no verificada y se pide revisión.
|
||||
4. Todo `hallazgo` con `confianza: confirmado` debe tener `evidencia` no vacía.
|
||||
5. `interno` se separa del objeto antes de pasar al renderizador del documento del cliente.
|
||||
6. Si `valorProblema.valorAnualEstimadoMXN` es `null`, el documento **no** puede afirmar un ratio precio/valor.
|
||||
|
||||
---
|
||||
|
||||
## 9. Lineamientos del prompt
|
||||
|
||||
### 9.1 Estructura de capas
|
||||
|
||||
El prompt se arma en tres capas, ordenadas por estabilidad (esto importa para el caché — ver §11.3):
|
||||
|
||||
```
|
||||
Capa 1 — ESTABLE (idéntica en toda cotización, cacheable)
|
||||
· Identidad y rol
|
||||
· Filosofía (los 10 principios de §4)
|
||||
· Frameworks de diagnóstico y pricing
|
||||
· Reglas de marca y confidencialidad
|
||||
· Contrato de salida y reglas de validación
|
||||
· Catálogo de servicios activo (snapshot)
|
||||
· Ejemplos de buena y mala redacción
|
||||
|
||||
Capa 2 — SEMI-ESTABLE (por cliente o sector)
|
||||
· Historial del cliente si es recurrente
|
||||
· Notas del sector
|
||||
|
||||
Capa 3 — VOLÁTIL (por cotización)
|
||||
· Cotización estructurada (JSON)
|
||||
· Transcripción sanitizada
|
||||
· Notas y observaciones
|
||||
· Instrucción específica del asesor
|
||||
```
|
||||
|
||||
Nada dinámico —fechas, IDs, timestamps— debe entrar en la capa 1. Una fecha interpolada en el system prompt invalida el caché de todo lo que viene después.
|
||||
|
||||
### 9.2 Reglas del system prompt
|
||||
|
||||
Adaptando el prompt maestro de `s8-Prompts-Plantillas.md` §1 al contexto de E3:
|
||||
|
||||
**Identidad.** Consultor senior de digitalización de negocios para PyMEs mexicanas. Redacta propuestas comerciales consultivas para Consultoría E3 (Querétaro, MX). No es vendedor: es diagnosticador.
|
||||
|
||||
**Reglas de contenido.**
|
||||
1. Cita fuentes internamente para cada dato del cliente. Si no hay fuente, no se escribe.
|
||||
2. Etiqueta toda cifra como confirmada, estimada o por validar.
|
||||
3. Traduce toda capacidad técnica a dinero, tiempo, riesgo evitado o tranquilidad operativa.
|
||||
4. Usa las palabras textuales del cliente en el diagnóstico.
|
||||
5. Nunca proponer un servicio que el asesor no eligió.
|
||||
6. Nunca calcular, sugerir ni modificar un precio.
|
||||
7. Cuando falte información, escribirla como pendiente — jamás rellenarla.
|
||||
8. Español de México, tono cercano y profesional, tuteo con el cliente cuando el registro de la reunión lo permita. Sin lenguaje corporativo vacío (*sinergias*, *holístico*, *stakeholders*, *ecosistema*, *disruptivo*).
|
||||
9. Moneda: MXN. IVA 16%. Facturación CFDI.
|
||||
10. El CRM se llama **Bucéfalo**. No se menciona ninguna otra plataforma de CRM.
|
||||
11. E3 ofrece únicamente servicios digitales. Si en la reunión se pidió algo de marketing tradicional o diseño para imprenta, va a la sección de exclusiones aclarando que no es un servicio de E3.
|
||||
|
||||
**Prohibiciones explícitas.**
|
||||
- No importar rangos de precio en USD de ninguna base de conocimiento.
|
||||
- No incluir datos personales de empleados del cliente, información salarial, ni temas legales o de prestaciones. Si la reunión los tocó, se omiten del documento y se anota en el bloque interno que el tema debe manejarse por el canal correspondiente.
|
||||
- No prometer resultados garantizados en pauta o posicionamiento (número de ventas, posición #1 en Google). Se prometen entregables, procesos y métricas de seguimiento.
|
||||
- No prometer soporte ilimitado.
|
||||
- No usar la palabra "garantizado" sobre resultados de mercado.
|
||||
|
||||
**Formato de razonamiento.** Con `claude-opus-5` el thinking está activo por defecto. Conviene dejarlo así: el diagnóstico requiere razonamiento multi-paso (leer transcripción → detectar dolor → cuantificar → cruzar con servicios elegidos → redactar). No hay que desactivarlo.
|
||||
|
||||
### 9.3 Descomposición en tareas
|
||||
|
||||
Un solo prompt monolítico va a producir un documento mediocre en todas sus partes. Mejor pipeline de 3 pasos, cada uno con su schema:
|
||||
|
||||
| Paso | Entrada | Salida | Modelo sugerido |
|
||||
|---|---|---|---|
|
||||
| **1 · Extracción** | Transcripción + notas | Hechos estructurados: dolores, cifras con confianza, citas literales, materiales mencionados, decisiones pendientes, red flags, menciones fuera de alcance | Opus 5 (`effort: high`) — es el paso que determina la calidad de todo lo demás |
|
||||
| **2 · Diagnóstico y valoración** | Hechos del paso 1 + cotización estructurada + catálogo | Hallazgos con semáforo, valor del problema, resultados a lograr, ratio precio/valor, semáforo de salud | Opus 5 (`effort: high`) |
|
||||
| **3 · Redacción** | Salida de pasos 1 y 2 | `PropuestaConsultiva` completa | Opus 5 (`effort: medium`) — con el análisis hecho, esto es principalmente escritura |
|
||||
|
||||
Ventajas de separar: cada paso se evalúa por separado, el paso 1 se puede cachear si sólo cambia la cotización, y el asesor puede corregir los hechos del paso 1 antes de que se propaguen.
|
||||
|
||||
### 9.4 Ejemplos en el prompt
|
||||
|
||||
La base de conocimiento es enfática en que los **ejemplos positivos** funcionan mejor que las prohibiciones. Hay que incluir en la capa 1 al menos:
|
||||
|
||||
- 2 ejemplos de diagnóstico bien escrito (uno con dolor cuantificado, uno donde falta la cifra y se declara)
|
||||
- 2 ejemplos de descripción de servicio traducida a resultado
|
||||
- 2 ejemplos de exclusión específica vs. genérica
|
||||
- 1 ejemplo de sección 08 bien poblada
|
||||
- 1 ejemplo de qué se ve cuando la transcripción es pobre (documento honesto con muchos pendientes, en lugar de documento inventado)
|
||||
|
||||
Ese último ejemplo es el más importante y el que se suele omitir.
|
||||
|
||||
---
|
||||
|
||||
## 10. Guardarraíles y riesgos
|
||||
|
||||
### 10.1 Matriz de riesgos
|
||||
|
||||
| Riesgo | Severidad | Mitigación |
|
||||
|---|---|---|
|
||||
| **La IA inventa un precio** | Crítica | P1 + validación programática §8.3.1. Los totales se toman del objeto de cotización en el render, no del texto generado |
|
||||
| **La IA inventa una cifra del cliente** | Crítica | Etiqueta de confianza obligatoria + campo `evidencia` + validación de que las citas existan en las fuentes |
|
||||
| **Fuga de datos personales de empleados** | Crítica | Sanitización de PII previa (§7.3.2) + prohibición explícita en el prompt + revisión humana |
|
||||
| **El anexo interno llega al cliente** | Crítica | Archivo separado con marca visual inconfundible, nunca sección oculta (§6.1) |
|
||||
| **Promesa de resultado garantizado en pauta/SEO** | Alta | Prohibición explícita + lista de palabras vetadas en validación post-generación |
|
||||
| **Importación de precios en USD desde el vault** | Alta | Prohibición explícita + validación: ningún monto del documento puede estar en USD ni ser un número que no exista en la cotización |
|
||||
| **Se menciona una plataforma de CRM que no es Bucéfalo** | Alta | Regla de marca en prompt + validación por lista de términos prohibidos |
|
||||
| **La propuesta contradice el PDF manual** | Alta | Los dos documentos se generan del mismo objeto de cotización; el de IA se regenera si la cotización cambia (invalidación por `updatedAt`) |
|
||||
| **Scope creep porque las exclusiones son genéricas** | Media | Requerir mínimo 3 exclusiones con campo `origen` no vacío; si la IA no puede justificarlas, el documento se marca como incompleto |
|
||||
| **La IA propone servicios que el asesor no eligió** | Media | P1 + validación §8.3.2. Las sugerencias van sólo al bloque interno |
|
||||
| **Costo de API descontrolado** | Baja | Ver §11.4: el costo es despreciable frente al ticket. Aun así: caché de prefijo + límite de tamaño de transcripción + conteo previo de tokens |
|
||||
| **El asesor aprueba sin leer** | Media | La UI debe hacer visible el semáforo de salud y el conteo de afirmaciones sin evidencia **antes** del botón de aprobar |
|
||||
| **Rechazo por clasificador de seguridad** | Baja | Manejar `stop_reason: "refusal"` antes de leer `content`; activar `fallbacks: "default"`. Poco probable en este dominio pero el código debe no reventar |
|
||||
|
||||
### 10.2 El mecanismo central de honestidad
|
||||
|
||||
Este es el diseño del que depende que el sistema sea confiable, y merece su propia sección.
|
||||
|
||||
**El problema.** Todo generador de documentos con LLM tiene la misma tentación: cuando falta información, rellenar con plausible. Una propuesta comercial con una cifra inventada del negocio del cliente no es un error cosmético — es una mentira con el logo de E3 encima, que el cliente puede refutar en la reunión de presentación.
|
||||
|
||||
**La solución.** La plantilla HTML ya trae el canal de salida para la incertidumbre: la **sección 06, Estado de materiales del cliente**, con sus dos tarjetas y tres estados (`done` ✓, `pend` ○, `neutral` ·).
|
||||
|
||||
Se convierte esa sección en el destino obligatorio de todo hueco de información:
|
||||
|
||||
```
|
||||
¿La IA no encontró el dato?
|
||||
├─ ¿Es un material que el cliente debe entregar?
|
||||
│ → sección 08, tarjeta "Materiales", estado pendiente
|
||||
├─ ¿Es una decisión que el cliente debe tomar?
|
||||
│ → sección 08, tarjeta "Decisiones", con quién decide y fecha sugerida
|
||||
├─ ¿Es una cifra del negocio que nadie confirmó?
|
||||
│ → sección 01b con confianza "por_validar", y punto a confirmar en la 08
|
||||
└─ ¿Es información que el asesor necesita conseguir?
|
||||
→ bloque interno, campo `huecos`, con "qué falta / por qué importa / cómo obtenerlo"
|
||||
```
|
||||
|
||||
**Lo elegante del diseño:** un hueco de información deja de ser un defecto del documento y se vuelve **contenido de valor**. Un cliente que recibe una propuesta con una sección clara de "esto necesitamos de ustedes, y esto está pendiente de decidir" percibe rigor, no incompetencia. Y de paso transfiere la responsabilidad del retraso a donde corresponde — que es exactamente lo que dice el footer de la plantilla: *"El avance queda condicionado a la entrega de materiales por parte del cliente."*
|
||||
|
||||
**La regla de oro operativa:** una propuesta con 8 pendientes honestos es infinitamente mejor que una con 8 cifras inventadas. El prompt debe decir esto literalmente, y el ejemplo de §9.4 debe demostrarlo.
|
||||
|
||||
---
|
||||
|
||||
## 11. Consideraciones técnicas
|
||||
|
||||
### 11.1 Elección de modelo
|
||||
|
||||
Recomendación: **`claude-opus-5`** para los tres pasos del pipeline.
|
||||
|
||||
| Modelo | Precio (entrada / salida por MTok) | Contexto | Cuándo usarlo aquí |
|
||||
|---|---|---|---|
|
||||
| `claude-opus-5` | $5 / $25 | 1M | **Recomendado.** Los tres pasos. Es una cotización de decenas de miles de pesos; la diferencia de costo con Sonnet es de centavos |
|
||||
| `claude-sonnet-5` | $3 / $15 (intro $2 / $10 hasta 2026-08-31) | 1M | Alternativa si el volumen crece mucho. Probar en el paso 3 (redacción) antes que en el 1 (extracción) |
|
||||
| `claude-haiku-4-5` | $1 / $5 | 200K | Sólo para tareas mecánicas auxiliares: detectar idioma, clasificar tipo de archivo, sanitizar formato |
|
||||
|
||||
**Por qué Opus 5 y no el más barato:** la calidad del diagnóstico es el producto. Un diagnóstico mediocre produce una propuesta que compite por precio, y eso cuesta miles de pesos de margen. Ahorrar $0.15 USD por documento para perder 10% de ticket es una decisión de negocio terrible. El costo del modelo no es la variable a optimizar aquí.
|
||||
|
||||
**Detalles de la API que importan:**
|
||||
- El thinking está **activo por defecto** en Opus 5 — no hay que configurarlo. `output_config: { effort: "high" }` para pasos 1 y 2, `"medium"` para el 3.
|
||||
- `max_tokens` limita thinking + texto juntos. Para el paso 3, que produce un documento completo, hay que dar holgura: **usar streaming** con `max_tokens` alto (>16K obliga a streaming para no chocar con timeouts HTTP del SDK).
|
||||
- No usar `temperature`, `top_p` ni `top_k` — están removidos en Opus 5 y devuelven 400.
|
||||
- No usar prefill de mensaje de asistente — devuelve 400. Para forzar formato: structured outputs.
|
||||
- Manejar `stop_reason: "refusal"` antes de leer `content`, y activar `fallbacks: "default"` con el header beta `server-side-fallback-2026-07-01`.
|
||||
|
||||
### 11.2 SDK y punto de integración
|
||||
|
||||
El proyecto es TypeScript/Next.js → **`@anthropic-ai/sdk`**. Nada de llamadas HTTP crudas ni de shims compatibles con otros proveedores.
|
||||
|
||||
Dos alternativas de arquitectura:
|
||||
|
||||
**Opción A — Route handler en Next.js.** `src/app/api/propuesta-ia/[id]/route.ts`, siguiendo el patrón de `export/pdf/[id]`. Más simple, un solo despliegue, comparte auth JWT y middleware. Riesgo: un paso de 3 llamadas con `effort: high` puede tardar minutos; requiere streaming al cliente o un patrón de job asíncrono (crear job → poll de estado → descargar).
|
||||
|
||||
**Opción B — En el servicio Python de `api/`.** Ya tiene MCP y auth por API key, y está pensado para agentes. Ventaja: la generación no bloquea el servidor web. Desventaja: duplica la lógica del prompt en otro lenguaje, y el proyecto ya sufre de duplicación de `calculators`.
|
||||
|
||||
**Recomendación:** Opción A con patrón de job. Un modelo nuevo (`PropuestaIA`: `cotizacionId`, `estado`, `objetoGenerado` JSON, `htmlRenderizado`, `costoTokens`, `createdAt`, `aprobadaPor`, `aprobadaAt`) permite historial, auditoría y regeneración sin perder versiones anteriores.
|
||||
|
||||
### 11.3 Caché de prompt
|
||||
|
||||
El caché es prefix match: cualquier cambio de un byte invalida todo lo que sigue. Orden de render: `tools` → `system` → `messages`.
|
||||
|
||||
Diseño para este caso:
|
||||
|
||||
| Contenido | Tamaño estimado | Cacheable | Notas |
|
||||
|---|---|---|---|
|
||||
| Filosofía + frameworks + reglas + contrato + ejemplos | 12-20K tokens | **Sí** — breakpoint aquí | Congelado. Cambia sólo cuando se edita la filosofía |
|
||||
| Catálogo de servicios activo | 3-6K tokens | **Sí** — segundo breakpoint | Cambia cuando se edita el catálogo; serializar con orden determinista (por `orden`, `id`) |
|
||||
| Cotización estructurada | 3-5K tokens | No | Varía por cotización |
|
||||
| Transcripción + notas | 5-20K tokens | No | Varía por cotización |
|
||||
|
||||
Notas operativas:
|
||||
- El mínimo cacheable en Opus 5 es **512 tokens** (bajó desde 1024 en Opus 4.8). La capa 1 lo supera con holgura.
|
||||
- Máximo 4 breakpoints por petición. Con dos alcanza.
|
||||
- Lectura de caché ≈ 0.1× del precio de entrada; escritura 1.25× (TTL 5 min) o 2× (TTL 1h). Con 2+ peticiones el TTL de 5 minutos ya sale a favor — y el pipeline de 3 pasos garantiza al menos 3 peticiones seguidas con el mismo prefijo.
|
||||
- **Invalidadores silenciosos a evitar:** `new Date()` en el system prompt, `JSON.stringify` de un objeto con orden de llaves no determinista, el número de cotización en la capa 1, cambio de modelo a media conversación.
|
||||
- Verificar con `response.usage.cache_read_input_tokens`. Si sale 0 en peticiones repetidas con el mismo prefijo, hay un invalidador escondido.
|
||||
|
||||
### 11.4 Costos estimados
|
||||
|
||||
Escenario base por documento (una cotización con transcripción de 60 min):
|
||||
|
||||
```
|
||||
Entrada: capa estable ~18K + catálogo ~5K + cotización ~4K + contexto ~15K ≈ 42K tokens
|
||||
Salida: objeto estructurado + razonamiento ≈ 8K tokens
|
||||
Peticiones: 3 (pipeline de §9.3), las 3 comparten prefijo de 23K
|
||||
```
|
||||
|
||||
| Configuración | Costo estimado por documento |
|
||||
|---|---|
|
||||
| Opus 5 sin caché, 1 petición | ≈ $0.41 USD |
|
||||
| Opus 5 con caché, pipeline de 3 pasos | ≈ **$0.30 – $0.55 USD** |
|
||||
| Sonnet 5 con caché (precio intro) | ≈ $0.12 – $0.22 USD |
|
||||
| Batch API (50% descuento) si se generan en lote nocturno | ≈ mitad de lo anterior |
|
||||
|
||||
A 20 MXN/USD: **entre $6 y $11 MXN por documento** con Opus 5.
|
||||
|
||||
**El insight de negocio:** el paquete PyME más barato del catálogo es de ~$7,490 MXN. El costo de generar su propuesta consultiva es **0.1% del ticket**. En el paquete de escalamiento ($90,800 MXN) es 0.01%.
|
||||
|
||||
Con 40 cotizaciones al mes: **~$12–22 USD/mes** (~$240–440 MXN). Menos que una comida.
|
||||
|
||||
Conclusión: **el costo del modelo no es una restricción de diseño.** Cualquier decisión que sacrifique calidad de output para ahorrar tokens está optimizando la variable equivocada. Optimizar para calidad, medir con `countTokens` para no llevarse sorpresas, y ya.
|
||||
|
||||
### 11.5 Renderizado del documento
|
||||
|
||||
La plantilla HTML es autocontenida (CSS inline, sin dependencias externas más que Google Fonts). Dos rutas:
|
||||
|
||||
1. **HTML directo** — un renderizador toma el objeto validado y produce el HTML usando la plantilla como base. Ventajas: fiel al diseño, imprimible con `@media print`, editable, ligero. Requiere sustituir Google Fonts por fuentes embebidas si se quiere funcionamiento offline o dentro de un PDF.
|
||||
2. **PDF vía PDFKit** — reutiliza `pdf-generator.ts`. Coherente con el resto del sistema, pero la plantilla HTML tiene gradientes, radial-gradients y tipografía serif/sans mezclada que en PDFKit son trabajo considerable.
|
||||
|
||||
**Recomendación:** HTML como entregable primario (se ve mejor, se comparte por link o adjunto, y el cliente lo abre en el celular). Si se necesita PDF, imprimir el HTML desde el navegador o con un headless — no reimplementar el diseño en PDFKit.
|
||||
|
||||
Nota heredada del proyecto: en `pdf-generator.ts` hay un bug documentado de auto-page-break en el footer (`doc.text()` en `y > page.height - margins.bottom` dispara página nueva). Si se va por PDFKit, ese bug ya tiene workaround en el código: poner `margins.bottom = 0` temporalmente.
|
||||
|
||||
Los colores y el logo salen de `Configuracion` (`color_primario`, `color_secundario`, `logo_base64`), así que el documento respeta la marca sin hardcodear.
|
||||
|
||||
---
|
||||
|
||||
## 12. Métricas de éxito del plugin
|
||||
|
||||
Si no se puede medir, no se sabe si sirvió. Estas son las métricas, separadas por qué preguntan.
|
||||
|
||||
### 12.1 ¿La IA escribe bien? (calidad del output)
|
||||
|
||||
| Métrica | Cómo medirla | Meta inicial |
|
||||
|---|---|---|
|
||||
| Tasa de edición del asesor | % de campos del objeto modificados antes de aprobar | <30% |
|
||||
| Afirmaciones sin evidencia | Conteo por documento (validación automática) | 0 |
|
||||
| Citas no verificables | Citas que no aparecen en las fuentes | 0 |
|
||||
| Rechazos completos | Documentos descartados y regenerados desde cero | <10% |
|
||||
| Tiempo de revisión | Minutos entre generación y aprobación | <15 min |
|
||||
|
||||
### 12.2 ¿Sirvió comercialmente? (impacto en el negocio)
|
||||
|
||||
| Métrica | Cómo medirla | Por qué importa |
|
||||
|---|---|---|
|
||||
| Tasa de cierre con vs. sin documento consultivo | Comparar `estado: aprobada` entre cotizaciones con y sin propuesta de IA | La prueba central de la tesis |
|
||||
| Ticket promedio | Total promedio de cotizaciones aprobadas, ambos grupos | La base de conocimiento predice +16-28% con opciones bien presentadas |
|
||||
| Objeciones de precio | Registrar en `observaciones` si el cliente pidió descuento | Un buen anclaje de valor debería reducirlas |
|
||||
| Tiempo de ciclo | Días entre `fecha` y cambio a `aprobada`/`rechazada` | Una propuesta clara decide más rápido, en cualquier dirección |
|
||||
| Scope creep | Cotizaciones que requirieron orden de cambio | El objetivo de las exclusiones específicas |
|
||||
| Conversión a mensualidad | % de cotizaciones aprobadas que incluyen servicio `mensual` o plan Bucéfalo | El objetivo de la sección de mantenimiento |
|
||||
|
||||
**Advertencia metodológica:** con 40 cotizaciones al mes, cualquier comparación de tasa de cierre tarda meses en ser significativa, y hay mil variables confundidas (el asesor, el sector, la temporada, el tamaño del cliente). No conviene tomar decisiones grandes con 3 semanas de datos. Lo honesto es empezar midiendo §12.1, que sí es medible de inmediato, y dejar §12.2 acumular.
|
||||
|
||||
### 12.3 ¿Cuesta lo que debe? (operación)
|
||||
|
||||
- Costo por documento generado (tokens de entrada/salida × precio, guardado en `PropuestaIA.costoTokens`)
|
||||
- Tasa de aciertos de caché (`cache_read_input_tokens / total`)
|
||||
- Latencia p50 y p95 del pipeline completo
|
||||
- Tasa de fallos: refusals, timeouts, validaciones rechazadas
|
||||
|
||||
---
|
||||
|
||||
## 13. Roadmap sugerido
|
||||
|
||||
Cada fase entrega algo usable. Ninguna requiere la siguiente para tener valor.
|
||||
|
||||
### Fase 0 — Ganancia inmediata sin IA (días)
|
||||
|
||||
**Imprimir `observaciones` y `notas` en el PDF y el Excel.** Es un cambio de pocas líneas en `pdf-generator.ts` y `excel-builder.ts`, y recupera información que ya se está capturando y descartando.
|
||||
|
||||
Esto no requiere nada de este documento. Debería hacerse ya, independientemente de si el plugin se construye.
|
||||
|
||||
### Fase 1 — Validación de calidad (1-2 semanas)
|
||||
|
||||
Sin UI, sin base de datos, sin despliegue. Un script que:
|
||||
- Lee una cotización existente vía la API o Prisma
|
||||
- Lee una transcripción de un archivo local
|
||||
- Corre el pipeline de 3 pasos
|
||||
- Escupe el objeto JSON y un HTML
|
||||
|
||||
**El entregable real no es el script: son 5-10 documentos generados sobre cotizaciones reales pasadas, revisados por el asesor.** Si esos documentos no son buenos, nada de lo demás importa y hay que iterar el prompt, no construir infraestructura.
|
||||
|
||||
Criterio de avance: el asesor dice *"esto lo mandaría a un cliente después de editarlo 10 minutos."*
|
||||
|
||||
### Fase 2 — Integración mínima (2-3 semanas)
|
||||
|
||||
- Modelo `PropuestaIA` y `ContextoCotizacion` en Prisma
|
||||
- Zona de carga de archivos en `CotizacionForm.tsx`
|
||||
- Route handler con patrón de job (crear → poll → descargar)
|
||||
- Renderizador HTML desde la plantilla
|
||||
- Vista de revisión con semáforo de salud y evidencia expandible
|
||||
- Botón de aprobación que registra quién y cuándo
|
||||
|
||||
### Fase 3 — Inteligencia comercial (3-4 semanas)
|
||||
|
||||
- Anexo interno como archivo separado
|
||||
- Semáforo de salud de §5.1 en el dashboard, agregado sobre todas las cotizaciones
|
||||
- Sugerencia de esquema de 2-3 opciones cuando `esDoble = false`
|
||||
- Alerta de subcotización (ratio precio/valor <10%)
|
||||
- Métricas de §12.1 instrumentadas
|
||||
|
||||
### Fase 4 — Cierre del ciclo (después, con datos)
|
||||
|
||||
- Aprendizaje de las ediciones del asesor: qué corrige sistemáticamente → ajuste del prompt
|
||||
- Comparación de tasa de cierre (sólo cuando haya volumen suficiente)
|
||||
- Herramienta MCP nueva: `generar_propuesta_consultiva` en `api/app/mcp/tools.py`, para que un agente externo pueda pedirla
|
||||
- Reutilización del documento en la conversación de retainer a las 2 semanas post-entrega (M9)
|
||||
|
||||
---
|
||||
|
||||
## 14. Decisiones abiertas
|
||||
|
||||
Cosas que hay que decidir antes de implementar y que este análisis no puede resolver solo.
|
||||
|
||||
| # | Decisión | Opciones | Recomendación |
|
||||
|---|---|---|---|
|
||||
| D1 | ¿Dónde se genera? | Route handler en Next.js vs. servicio Python | Next.js con patrón de job (§11.2) |
|
||||
| D2 | ¿Dónde viven las transcripciones? | Adjuntos en BD · disco · Files API | Disco para Fase 1, adjuntos en BD para Fase 2 |
|
||||
| D3 | ¿Un documento o dos archivos? | Documento del cliente + anexo interno separados, o uno solo | **Dos archivos.** El riesgo de fuga del anexo es demasiado alto (§6.1) |
|
||||
| D4 | ¿HTML o PDF como entregable? | HTML · PDF · ambos | HTML primario; PDF por impresión del navegador si se necesita |
|
||||
| D5 | ¿El plugin puede sugerir servicios? | Sí en la propuesta · sólo en el anexo interno · no | **Sólo en el anexo interno** (M13, criterio ético) |
|
||||
| D6 | ¿Se regenera al cambiar la cotización? | Automático · manual con aviso · manual silencioso | Manual con aviso de desincronización visible |
|
||||
| D7 | ¿Quién puede aprobar? | Cualquier asesor · sólo el dueño de la cotización · rol admin | El asesor dueño; admin puede aprobar cualquiera |
|
||||
| D8 | ¿Se guarda la transcripción cruda o sólo la sanitizada? | Cruda · sanitizada · ambas | Sólo sanitizada en BD. La cruda no debe persistir con datos de empleados |
|
||||
| D9 | ¿Se versiona el prompt? | Sí, con las propuestas apuntando a su versión · no | Sí. Sin esto no se puede saber si un cambio de prompt mejoró o empeoró |
|
||||
| D10 | ¿Qué pasa si no hay transcripción? | Bloquear · generar con lo que haya · generar sólo el esqueleto | Generar con lo que haya, con la sección 08 muy poblada y aviso claro de confianza baja |
|
||||
|
||||
---
|
||||
|
||||
## Anexo A — Mapeo campo → sección
|
||||
|
||||
Referencia rápida para implementación.
|
||||
|
||||
| Sección del documento | Fuentes de datos |
|
||||
|---|---|
|
||||
| Hero | `Cliente.nombre`, `.empresa`, `Cotizacion.numero`, `.fecha`, `.vigencia`, `.proyecto`, `User.name` (asesor) |
|
||||
| 01 Diagnóstico | Transcripción, `Cotizacion.observaciones`, `ServicioCotizado.notas`, archivos de notas |
|
||||
| 01b Valor del problema | Transcripción (4 dimensiones), `totales.total` para el ratio |
|
||||
| 02 Resultados | Derivado del diagnóstico + `ServicioCotizado.entregables` |
|
||||
| 03 Alcance | `ServicioCotizado[]` (nombre, fase, tipoPago, precio, tiempoEntrega, entregables), `Categoria.nombre` |
|
||||
| Totales | `calculators.ts` — copiados literalmente, `IVA_RATE`, descuentos |
|
||||
| 04 Opciones | `Cotizacion.esDoble`, `.opcionesMetadata`, `ServicioCotizado.opcion` |
|
||||
| 05 No incluye | Transcripción (menciones fuera de alcance), `opcionesMetadata[].noIncluye` |
|
||||
| 06 Por qué tiene sentido | `ServicioCotizado.beneficios`, cruzado con hallazgos del diagnóstico |
|
||||
| 07 Mantenimiento | `PlanBucefaloCotizacion`, servicios con `tipoPago: mensual`, `modeloCobro: retainer`, `Bono[]` |
|
||||
| 08 Materiales y decisiones | Huecos detectados + dependencias mencionadas en la transcripción |
|
||||
| 09 Backlog | Fricciones mencionadas fuera del alcance de fase 1 |
|
||||
| Footer | `Configuracion.logo_base64`, `.color_primario`, `.color_secundario` + condiciones fijas |
|
||||
| Financiamiento (si aplica) | `FinanciamientoPlan[]`, `Cotizacion.incluirFinanciamiento`, `calcularFinanciamiento()` |
|
||||
| Anexo interno | Todo lo anterior + `RegistroHoras` si aplica + análisis de §5.1 |
|
||||
|
||||
---
|
||||
|
||||
## Anexo B — Checklist de calidad antes de enviar
|
||||
|
||||
Adaptado de `Onboarding-Cliente/00-Guia-de-Uso-y-Checklist-Calidad.md`. Este checklist debe estar visible en la UI de revisión, no enterrado en un documento.
|
||||
|
||||
**Problema y valor**
|
||||
- [ ] El problema está redactado en lenguaje del cliente, sin jerga técnica
|
||||
- [ ] Hay al menos un ejemplo real y reciente del problema, citado
|
||||
- [ ] Se estimó el costo anual, con las cifras etiquetadas por confianza
|
||||
- [ ] La métrica de éxito tiene línea base, resultado deseado y periodo
|
||||
- [ ] Consta si se habló con el usuario operativo, no sólo con quien autoriza
|
||||
|
||||
**Viabilidad y alcance**
|
||||
- [ ] Se sabe dónde viven los datos/accesos y quién los autoriza
|
||||
- [ ] La fase 1 resuelve un cuello de botella prioritario, no todos
|
||||
- [ ] Está explícito: incluido ahora / excluido ahora / backlog futuro
|
||||
- [ ] Hay responsables del cliente y decisiones pendientes identificadas
|
||||
- [ ] Está definido qué significa "terminado" y quién acepta
|
||||
|
||||
**Propuesta comercial**
|
||||
- [ ] Si hay opciones, cambian alcance o nivel de servicio — no sólo el precio
|
||||
- [ ] La opción recomendada sí cumple el resultado de negocio definido
|
||||
- [ ] El precio se sustenta en valor, costo real y mercado
|
||||
- [ ] El mantenimiento aparece con servicios concretos, límites y tiempos de respuesta
|
||||
- [ ] Se presentará en llamada, no como cifra suelta por mensajería
|
||||
|
||||
**Integridad del documento generado**
|
||||
- [ ] Todo número coincide con el documento manual
|
||||
- [ ] Cero afirmaciones sin evidencia
|
||||
- [ ] Cero cifras marcadas como confirmadas sin origen
|
||||
- [ ] Ningún dato personal de empleados del cliente
|
||||
- [ ] Ningún monto en USD
|
||||
- [ ] Ninguna promesa de resultado garantizado
|
||||
- [ ] El CRM se menciona como Bucéfalo
|
||||
- [ ] El anexo interno está en archivo separado y no se va a enviar
|
||||
|
||||
---
|
||||
|
||||
## Anexo C — Cruce con la base de conocimiento
|
||||
|
||||
Para profundizar durante la implementación:
|
||||
|
||||
- **Preguntas de diagnóstico completas:** `Base de Conocimiento - IA Negocios y Dev/03-Preguntas-Guia.md`
|
||||
- **Playbooks de pricing y cierre:** `08-Monetizacion-Onboarding-Pricing-Seguimiento.md`
|
||||
- **Errores a evitar:** `01-Errores-a-Evitar.md`, `sintesis/s3-Errores-Fatales.md`
|
||||
- **Modelos de pricing detallados:** `sintesis/s2-Pricing-Cobranza.md`
|
||||
- **Prompts y plantillas base:** `sintesis/s8-Prompts-Plantillas.md` (§1 system prompt, §3 diagnóstico, §4 propuesta, §9 output estándar)
|
||||
- **Las cuatro sesiones de onboarding:** `Onboarding-Cliente/01` a `04`, con `00-Guia-de-Uso-y-Checklist-Calidad.md` como criterio de calidad
|
||||
- **Playbook maestro:** `Guia_Onboarding_Cliente_y_Ruta_de_Trabajo.md`
|
||||
- **Paquetes del catálogo:** `docs/paquetes-recomendados-pyme.md` (este repo)
|
||||
- **Convenciones del proyecto:** `AGENTS.md` (este repo)
|
||||
|
||||
---
|
||||
|
||||
## Cierre
|
||||
|
||||
El Cotizador E3 ya sabe cuánto cobrar. Lo que le falta es explicar por qué vale.
|
||||
|
||||
Ese "por qué" no se inventa: se extrae de lo que el cliente ya dijo en la reunión, de lo que el asesor ya anotó en el campo de observaciones, y del catálogo que ya está armado. Está todo ahí, disperso y sin usar.
|
||||
|
||||
El plugin no es un generador de texto bonito. Es un **traductor**: convierte una lista de precios en un argumento de negocio, usando material que ya existe y sin tocar un solo número. Y cuando no tiene material suficiente, lo dice — porque una propuesta con pendientes honestos cierra mejor que una con cifras inventadas, y porque el día que un cliente refute un dato inventado, el costo no lo paga el modelo: lo paga la marca.
|
||||
|
||||
---
|
||||
|
||||
*Documento de análisis previo a implementación · Consultoría E3 · 2026-07-28*
|
||||
@@ -0,0 +1,454 @@
|
||||
# Propuesta consultiva con IA — Diseño de Fase 0 y Fase 1
|
||||
|
||||
> **Qué es este documento:** la especificación técnica cerrada de las dos primeras fases del
|
||||
> plugin descrito en [`docs/BI-propuesta-consultiva-IA.md`](../../BI-propuesta-consultiva-IA.md).
|
||||
> Aquel documento es el análisis de negocio y dice explícitamente que no es un spec. Este sí lo es.
|
||||
>
|
||||
> **Alcance:** Fase 0 (recuperar el contexto humano que hoy se descarta, más los bloqueadores que
|
||||
> el análisis destapó) y Fase 1 (script local que valida si la IA escribe documentos utilizables).
|
||||
> Fases 2-4 quedan fuera y tendrán su propio ciclo.
|
||||
>
|
||||
> **Fecha:** 2026-07-28 · **Estado:** aprobado para planeación
|
||||
> **Base verificada:** commit `26e6d3a9`
|
||||
|
||||
---
|
||||
|
||||
## 1. Por qué el alcance es este
|
||||
|
||||
El documento de negocio describe ocho subsistemas. Construirlos de una vez significa un mes de
|
||||
trabajo antes de saber si la tesis funciona. Su propio §13 argumenta la salida:
|
||||
|
||||
> *"El entregable real no es el script: son 5-10 documentos generados sobre cotizaciones reales,
|
||||
> revisados por el asesor. Si esos documentos no son buenos, nada de lo demás importa y hay que
|
||||
> iterar el prompt, no construir infraestructura."*
|
||||
|
||||
Se adopta ese criterio. Fase 1 termina cuando el asesor puede decir *"esto lo mandaría a un
|
||||
cliente después de editarlo 10 minutos"* — o cuando queda claro que no.
|
||||
|
||||
El análisis previo a este spec encontró cuatro problemas que el documento de negocio no anticipaba.
|
||||
Tres son bloqueadores y entran al alcance; el cuarto cambia dónde vive un dato.
|
||||
|
||||
---
|
||||
|
||||
## 2. Hallazgos que modifican el plan original
|
||||
|
||||
Los cuatro están verificados en disco, no inferidos.
|
||||
|
||||
### 2.1 🔴 El endpoint MCP publica todas las cotizaciones sin autenticación
|
||||
|
||||
| Eslabón | Evidencia |
|
||||
|---|---|
|
||||
| `POST /mcp` no valida nada | `api/main.py:107` — sin un solo `Depends()`. Cero coincidencias de `require_auth` en el archivo |
|
||||
| La función de auth existe y no se usa ahí | `api/app/auth.py:45` define `require_auth` |
|
||||
| La herramienta devuelve la fila completa | `api/app/mcp/server.py:250` — `SELECT c.*` seguido de `dict(cot)`, sin lista blanca |
|
||||
| Tiene dominio público en producción | `docker-compose.coolify.yml:67` — `SERVICE_FQDN_API_8000` |
|
||||
|
||||
El agujero **ya existe**: precios, catálogo y datos de clientes son legibles hoy por cualquiera.
|
||||
Lo relevante para este spec es que `SELECT c.*` publicaría la columna nueva de notas internas sin
|
||||
que nadie toque `server.py`. Entra al alcance como **Fase 0-A**, previa a todo lo demás.
|
||||
|
||||
### 2.2 🟠 No existe una fuente de verdad para los totales
|
||||
|
||||
La regla de validación §8.3.1 del documento de negocio exige comparar contra *"el total de la
|
||||
cotización en BD"*. No hay tal cosa:
|
||||
|
||||
- Ninguna función devuelve los totales de una cotización. El cálculo está duplicado con
|
||||
`.filter().reduce()` en **siete** consumidores (PDF, Excel, detalle, PreciosEditables, el
|
||||
formulario, la lista y el dashboard), con criterios que no coinciden.
|
||||
- El IVA está hardcodeado como `* 1.16` en `pdf-generator.ts:328-329` y `excel-builder.ts:377-378`,
|
||||
ignorando `IVA_RATE`.
|
||||
- El flag `Cotizacion.incluirIva` no se respeta en esos cálculos.
|
||||
|
||||
Hoy el PDF y el Excel de una misma cotización pueden discrepar y nada lo detecta. Entra como
|
||||
**Fase 0-B**.
|
||||
|
||||
### 2.3 🔴 El Excel es un documento del cliente, no una herramienta interna
|
||||
|
||||
Este hallazgo corrigió un supuesto equivocado del diseño inicial, que proponía poner las notas
|
||||
internas en el Excel.
|
||||
|
||||
| Evidencia | Ubicación |
|
||||
|---|---|
|
||||
| Banner con la razón social de E3 | `excel-builder.ts:165` |
|
||||
| `"En atencion a:"` + nombre del cliente | `excel-builder.ts:181` |
|
||||
| Nota legal de precios, IVA y vigencia | `excel-builder.ts:400` |
|
||||
| Razón social + domicilio fiscal al pie de cada hoja de detalle | `excel-builder.ts:539-547` |
|
||||
| Nombre de archivo `{empresa} - {cliente} - {numero}.xlsx` | `src/app/api/export/excel/[id]/route.ts:76` |
|
||||
| Botón en la misma fila que Preview PDF y PDF | `src/app/(app)/cotizaciones/[id]/page.tsx:93-95` |
|
||||
|
||||
Escenario de fallo: el asesor exporta ambos, caen en Descargas con el mismo prefijo, los arrastra
|
||||
juntos al correo, y el cliente abre la última pestaña y lee el plan de recorte de precio y las red
|
||||
flags sobre él mismo.
|
||||
|
||||
**Consecuencia:** en Fase 0 las notas internas no entran a **ningún** exportador. Viven solo en la
|
||||
app. Es la misma garantía estructural que ya tiene el PDF.
|
||||
|
||||
### 2.4 🟠 Las transcripciones se sincronizarían a la nube de MEGA
|
||||
|
||||
El repo vive en `H:\MegaSync\Proyectos\Cotizador`. El `.megaignore` actual excluye únicamente
|
||||
`.next`, `node_modules` y `.turbo`. Cualquier transcripción guardada dentro del repo se sube a la
|
||||
nube: nombres de empleados, comentarios de desempeño, cifras salariales. `.gitignore` protege el
|
||||
repositorio, no la sincronización.
|
||||
|
||||
**Consecuencia:** `contexto/` y `salidas/` van a `.gitignore` **y** a `.megaignore`, y la exclusión
|
||||
se verifica empíricamente antes de colocar material real.
|
||||
|
||||
---
|
||||
|
||||
## 3. Decisiones cerradas
|
||||
|
||||
Ninguna de estas se re-litiga durante la implementación.
|
||||
|
||||
| # | Decisión | Resolución | Razón |
|
||||
|---|---|---|---|
|
||||
| D1 | Alcance del ciclo | Fase 0 + Fase 1 | Validar calidad antes de construir infraestructura |
|
||||
| D2 | Separar interno de visible | Campo nuevo `observacionesInternas`; `observaciones` pasa a ser texto del cliente | Hace la fuga estructuralmente imposible en vez de depender de un marcador que se olvida |
|
||||
| D3 | Dónde viven las notas internas | Solo en la app. Ningún exportador | §2.3 |
|
||||
| D4 | Forzar el schema sin structured outputs | Tool-calling: `input_schema` de la herramienta = contrato. Zod valida siempre | MiniMax no soporta `output_config` |
|
||||
| D5 | Proveedor | MiniMax-M3 vía endpoint compatible con Anthropic | Decisión del negocio (Token Plan contratado) |
|
||||
| D6 | Sanitización de PII | Filtro de **salida** solamente | El modelo necesita el contexto completo para diagnosticar; el riesgo real es lo que llega al cliente |
|
||||
| D7 | Seguridad del MCP | Cerrar el agujero primero: `require_auth` + lista blanca de columnas | §2.1 |
|
||||
| D8 | Totales | Función canónica `calcularTotalesCotizacion()` + migrar PDF y Excel | §2.2 |
|
||||
| D9 | Base del ratio precio/valor | Primer año **con IVA** (único + mensual × 12) | Es el desembolso real a 12 meses; hace comparable el ratio contra un valor *anual* |
|
||||
| D10 | Nombres de empleados detectados | Advertir, no bloquear | Un bloqueo heurístico produce falsos positivos constantes y enseña a ignorar la alerta |
|
||||
| D11 | Ubicación de transcripciones | Dentro del repo, en `.gitignore` **y** `.megaignore` | §2.4 |
|
||||
| D12 | Migración de datos | Dos despliegues: A aditivo, B mueve datos | §4.3 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Fase 0 — Recuperar el contexto humano
|
||||
|
||||
Cuatro bloques. A y B son prerrequisitos de C.
|
||||
|
||||
### 4.1 Fase 0-A · Cerrar el MCP
|
||||
|
||||
1. Aplicar `require_auth` (`api/app/auth.py:45`) al endpoint `POST /mcp` en `api/main.py:107`.
|
||||
2. Sustituir el `SELECT c.*` de `_obtener_cotizacion` (`api/app/mcp/server.py:250`) por una lista
|
||||
blanca de columnas explícita, con el mismo criterio que el REST ya aplica vía
|
||||
`CotizacionResponse` (`api/app/models/cotizacion.py:130-152`).
|
||||
3. Auditar el resto de `server.py` en busca de otros `SELECT *` y darles el mismo tratamiento.
|
||||
|
||||
**Criterio de aceptación:** una petición `POST /mcp` sin credencial devuelve 401, y
|
||||
`obtener_cotizacion` con credencial válida no incluye `observacionesInternas` en su salida.
|
||||
|
||||
### 4.2 Fase 0-B · Totales canónicos
|
||||
|
||||
Escribir en `src/lib/calculators.ts`:
|
||||
|
||||
```ts
|
||||
calcularTotalesCotizacion(cotizacion): {
|
||||
subtotalUnico, subtotalMensual,
|
||||
ivaUnico, ivaMensual,
|
||||
totalUnico, totalMensual,
|
||||
totalPrimerAnio, // totalUnico + totalMensual * 12, con IVA — base del ratio (D9)
|
||||
moneda
|
||||
}
|
||||
```
|
||||
|
||||
Reglas: usa `IVA_RATE`, respeta `Cotizacion.incluirIva`, y solo suma partidas con
|
||||
`seleccionado: true`. Para cotizaciones dobles se apoya en `calcularTotalesOpcion`.
|
||||
|
||||
Migrar a ella `pdf-generator.ts` y `excel-builder.ts`, eliminando los `* 1.16`. Los otros cinco
|
||||
consumidores se migran en un ciclo posterior; se documenta la deuda.
|
||||
|
||||
**Criterio de aceptación:** para un conjunto de cotizaciones reales, el total del PDF y el del
|
||||
Excel coinciden dígito por dígito, y una cotización con `incluirIva: false` no muestra IVA en
|
||||
ninguno.
|
||||
|
||||
### 4.3 Fase 0-C · El campo de contexto humano
|
||||
|
||||
**Despliegue A — solo aditivo.**
|
||||
|
||||
- `ALTER TABLE "Cotizacion" ADD COLUMN "observacionesInternas" TEXT;` — sin `UPDATE`.
|
||||
- El formulario muestra dos textareas visualmente inconfundibles: *"Observaciones (las ve el
|
||||
cliente)"* y *"Notas internas (no salen de la app)"*, con badge de color distinto.
|
||||
- La vista de detalle muestra ambos campos; el histórico aparece etiquetado como
|
||||
*"Observaciones (histórico, sin clasificar)"*.
|
||||
- El PDF imprime **solo** `observaciones`. `CotizacionPDFData` (`pdf-generator.ts:31-53`) no
|
||||
declara `observacionesInternas` — la garantía es estructural, no disciplinaria.
|
||||
- Ningún exportador recibe el campo interno.
|
||||
|
||||
**Despliegue B — movimiento de datos.** Solo cuando A lleve días estable:
|
||||
|
||||
```sql
|
||||
UPDATE "Cotizacion"
|
||||
SET "observacionesInternas" = "observaciones",
|
||||
"observaciones" = NULL
|
||||
WHERE "observaciones" IS NOT NULL
|
||||
AND btrim("observaciones") <> ''
|
||||
AND "observacionesInternas" IS NULL; -- guarda de idempotencia, obligatoria
|
||||
```
|
||||
|
||||
Antes de correr B, exportar y guardar **fuera de MegaSync**:
|
||||
|
||||
```sql
|
||||
COPY (SELECT id, numero, estado, observaciones FROM "Cotizacion"
|
||||
WHERE observaciones IS NOT NULL AND btrim(observaciones) <> '')
|
||||
TO STDOUT WITH CSV HEADER;
|
||||
```
|
||||
|
||||
Ese CSV es el `down` que Prisma no da: no hay migraciones de reversa en el repo ni servicio de
|
||||
backup en `docker-compose.coolify.yml`.
|
||||
|
||||
**Por qué dos despliegues.** Si A mueve los datos y el despliegue falla, Coolify redespliega la
|
||||
imagen anterior, el código viejo lee `observaciones` (NULL en el 100% de las filas) y el asesor ve
|
||||
todas sus notas desaparecidas — sin error, sin log, en el momento de máximo estrés.
|
||||
|
||||
**Puntos de integración del campo nuevo** (rastreados de punta a punta):
|
||||
`prisma/schema.prisma`, `src/lib/schemas.ts` (los dos schemas), `src/lib/store.ts`,
|
||||
`CotizacionForm.tsx`, `POST /api/cotizaciones`, `PUT /api/cotizaciones/[id]`,
|
||||
`cotizaciones/[id]/page.tsx`, `cotizaciones/[id]/editar/page.tsx`, y en Python los tres modelos
|
||||
Pydantic (`CotizacionCreate`, `CotizacionUpdate`, `CotizacionResponse`) más el `_add` del PUT.
|
||||
|
||||
Los INSERT de Python **no** son urgentes: la columna es nullable y Postgres pone NULL. Los modelos
|
||||
Pydantic sí lo son — hay precedente demostrado de que sin declararlos el campo nunca sale del API
|
||||
(`incluirIva` existe en la tabla desde `20260630000000` y el API Python no lo devuelve).
|
||||
|
||||
**Deuda declarada:** `zod` se importa en `src/lib/schemas.ts:1` y resuelve transitivamente a 4.3.6,
|
||||
pero no está en `package.json`. Se declara explícitamente (`npm i zod@^4.3.6`); un
|
||||
`npm ci --omit=dev` revienta hoy sin eso, y Fase 1 depende fuertemente de zod.
|
||||
|
||||
### 4.4 Fase 0-D · Bugs vecinos aprobados
|
||||
|
||||
| Bug | Arreglo |
|
||||
|---|---|
|
||||
| Orden de partidas | Ninguna consulta tiene `orderBy` en `servicios`, y `drawSection` (`pdf-generator.ts:216-268`) asume que vienen agrupadas por fase. Añadir `orderBy: [{fase}, {createdAt}]` en las cuatro rutas de export |
|
||||
| Datos bancarios en borrador | `export/pdf/route.ts:45` llama solo a `getConfigBranding()`; el Excel llama a ambas. Añadir `getConfigBancaria()` |
|
||||
| Partida por demanda en $0 | Las copias locales de `detalleModelo` en `pdf-generator.ts:21-29` y `excel-builder.ts:58-66` no manejan `modeloCobro === "demanda"`; la canónica de `calculators.ts:23-41` sí. Usar la canónica |
|
||||
| Bonos con dos redacciones | `pdf-generator.ts:377-384` tiene seis bonos hardcodeados y la tabla `Bono` del seed dice otra cosa; la tabla no se consulta en `src/`. Definir la tabla como fuente de verdad |
|
||||
|
||||
---
|
||||
|
||||
## 5. Fase 1 — Script local de validación de calidad
|
||||
|
||||
Sin base de datos nueva, sin UI, sin despliegue. Un script que lee una cotización y una
|
||||
transcripción, corre el pipeline, y escribe un JSON, un HTML y un reporte.
|
||||
|
||||
### 5.1 Contratos compartidos
|
||||
|
||||
Los cuatro diseños explorados compartían vocabulario pero no contratos. Estas resoluciones se
|
||||
escriben **una vez**, en un solo archivo de tipos que todos importan. Sin esto el sistema arranca,
|
||||
no falla, y valida el vacío.
|
||||
|
||||
| # | Conflicto | Resolución |
|
||||
|---|---|---|
|
||||
| B1 | Clave de join partida ↔ IA | `refPartida`, formato `/^P\d{2}$/` → `"P01"`. El cuid de `ServicioCotizado` **nunca** sale hacia el proveedor: no es estable entre ediciones porque el PUT hace `deleteMany` + `createMany` |
|
||||
| B2 | Forma de la evidencia | Arrays de IDs (`citas[]`, `hechos[]`), no prosa. Auditable por máquina, que es el punto de P3 |
|
||||
| B3 | Cita literal | Referenciada por ID. Se **verifica en el paso 1**, donde vive el texto, y su fallo bloquea antes de gastar los pasos 2 y 3 |
|
||||
| B4 | Árbol de módulos | `src/lib/propuesta/{ia,validacion,render}/`, entrada `scripts/propuesta-consultiva.ts`, tipo raíz `PropuestaConsultiva` |
|
||||
| B5 | Códigos de salida | `0` limpio · `1` requiere revisión · `2` bloqueado · `3` error del proveedor · `64` error de uso |
|
||||
| B6 | Variables de entorno | Solo `MINIMAX_API_KEY` / `MINIMAX_BASE_URL` / `MINIMAX_MODEL`, pasadas explícitas al constructor. **Nunca** prefijo `ANTHROPIC_*`: el SDK las lee por su cuenta y un `ANTHROPIC_BASE_URL` exportado en la shell mandaría la clave de MiniMax a Anthropic |
|
||||
| B7 | `tool_choice` | No se envía en Fase 1. Fijarlo solo en el reintento le da al reintento un prefijo distinto, o sea que se paga el contexto completo justo cuando es más grande |
|
||||
| B8 | Snapshot del catálogo | Sin `id` y sin `precioBase`. La resolución es por `nombre` + `fase`, reportando ambigüedad — `ServicioCatalogo.nombre` no es `@unique` |
|
||||
| B9 | Valor anual del problema | `dimensiones[].calculo.montoAnualMXN`. El total se suma **en código**, nunca lo emite el modelo |
|
||||
|
||||
### 5.2 El corte: qué copia el código y qué genera el modelo
|
||||
|
||||
La protección más fuerte del principio P1 no es una regla de validación: es que **ningún schema
|
||||
que llena el modelo contiene un solo campo de dinero de la cotización**.
|
||||
|
||||
- **El modelo devuelve:** `refPartida` + prosa (diagnóstico, resultados, descripciones en lenguaje
|
||||
de beneficio, exclusiones, pendientes, backlog).
|
||||
- **El código inyecta después:** precios, totales, IVA, número de cotización, vigencia, fechas.
|
||||
|
||||
Así P1 deja de ser algo que alguien puede olvidar validar y pasa a ser estructuralmente imposible
|
||||
de violar. El ratio precio/valor también lo calcula el código (D9) y se imprime solo en el anexo
|
||||
interno.
|
||||
|
||||
### 5.3 El pipeline de tres pasos
|
||||
|
||||
| Paso | Entrada | Salida | Por qué separado |
|
||||
|---|---|---|---|
|
||||
| 1 · Extracción | Transcripción + notas + observaciones | Hechos: dolores, cifras con confianza, citas literales con ID, materiales, decisiones, red flags, menciones fuera de alcance | Determina la calidad de todo lo demás. Es corregible por el asesor antes de propagarse |
|
||||
| 2 · Diagnóstico | Hechos del paso 1 + cotización + catálogo | Hallazgos con semáforo, valor del problema, resultados a lograr | Razonamiento, no redacción |
|
||||
| 3 · Redacción | Salidas de 1 y 2 | `PropuestaConsultiva` | Con el análisis hecho, esto es escritura |
|
||||
|
||||
Cada paso define una herramienta cuyo `input_schema` es su contrato, derivado del Zod con
|
||||
`z.toJSONSchema` nativo (verificado: preserva `additionalProperties` y `description`, inlinea
|
||||
subschemas, omite `superRefine`, y emite un `$schema` que hay que borrar). **No hace falta
|
||||
`zod-to-json-schema`.**
|
||||
|
||||
### 5.4 El presupuesto de reintentos
|
||||
|
||||
Hallazgo verificado que ningún diseño anticipó: **`.superRefine` no corre si la forma falla.** Eso
|
||||
crea cuatro compuertas secuenciales:
|
||||
|
||||
1. Forma Zod — siempre
|
||||
2. `superRefine` (IDs únicos, refs internas) — solo si 1 pasa
|
||||
3. Validación cruzada de runtime (`refPartida` reales) — solo si 2 pasa
|
||||
4. Filtro de contenido y PII — solo si 3 pasa
|
||||
|
||||
Con tres intentos y cuatro compuertas, el modo de fallo más probable del piloto es *"tres llamadas
|
||||
pagadas, cero documento"* — justo lo que Fase 1 no puede permitirse, porque su criterio de éxito es
|
||||
que el asesor lea algo.
|
||||
|
||||
**Diseño adoptado:**
|
||||
|
||||
- El filtro de contenido y PII corre sobre el objeto **crudo** aunque Zod haya fallado, recorriendo
|
||||
rutas parciales. Los hallazgos de las cuatro compuertas se acumulan en **un solo** mensaje de
|
||||
corrección.
|
||||
- Presupuesto: **3 intentos totales** por paso (1 inicial + 2 reintentos). El paso 3 recibe **4
|
||||
intentos totales** (1 + 3) por ser el más largo y el que atraviesa más compuertas.
|
||||
- Al agotarse los intentos se escribe igual el HTML del cliente **si y solo si** no hay bloqueantes
|
||||
de PII, moneda, garantía ni marca. Salir con código 1, no con 2.
|
||||
- En la rama `tool_incorrecta` hay que emitir un `tool_result` con `is_error: true` por **cada**
|
||||
`tool_use` del turno antes del texto de corrección; un mensaje de usuario plano después de un
|
||||
`tool_use` produce un 400.
|
||||
|
||||
### 5.5 Validación post-generación
|
||||
|
||||
Las seis reglas de §8.3 del documento de negocio, más una nueva que el análisis de riesgo destapó.
|
||||
|
||||
| Regla | Qué comprueba | Al fallar |
|
||||
|---|---|---|
|
||||
| **R0** (nueva) | Ningún n-grama de 7 tokens de `observacionesInternas` ni de `ServicioCotizado.notas` aparece en el HTML del cliente | **Bloquea** |
|
||||
| R1 | Todo monto en prosa pertenece al conjunto de la economía (BD) o al del valor (declarado y auditado por R4) | Bloquea |
|
||||
| R2 | Todo `refPartida` existe en `ServicioCotizado`. Ninguna partida nueva | Bloquea |
|
||||
| R3 | Cada cita literal existe en las fuentes, con normalización de 9 pasos (NFD sin diacríticos, minúsculas, puntuación, espacios, muletillas de ASR). Corre en el paso 1 | Bloquea si cruza hablantes |
|
||||
| R4 | Todo hallazgo `confirmado` tiene `citas.length + hechos.length > 0`. Corre sobre el objeto del **paso 2** | Degrada a `por_validar` |
|
||||
| R5 | El bloque `interno` se separa antes de renderizar el documento del cliente | Bloquea |
|
||||
| R6 | Sin valor anual, ninguna afirmación de ratio en la prosa | Bloquea la afirmación |
|
||||
|
||||
**R0 existe porque las otras seis no la cubren.** `R5` compara el bloque interno que *produjo el
|
||||
modelo* contra el HTML; nunca compara `observacionesInternas`, que es una **entrada** que el modelo
|
||||
recibe en el prompt y puede copiar literalmente en cualquier campo de prosa.
|
||||
|
||||
**Filtro de PII de salida** (advertencia, no bloqueo — D10): montos en USD, promesas de resultado
|
||||
garantizado, CRMs que no sean Bucéfalo, datos de empleados, temas salariales o legales. Los
|
||||
patrones heurísticos se marcan como tales en el reporte, con su extracto, para que el asesor
|
||||
juzgue. Se debe añadir a la cosecha de nombres las etiquetas de hablante del VTT y los dos campos
|
||||
de notas, que hoy quedarían fuera.
|
||||
|
||||
### 5.6 El renderizador HTML
|
||||
|
||||
**Es el entregable primario y ningún diseño lo tenía.** Primer paso de Fase 1: copiar
|
||||
`D:/Documents/plantilla_cotizacion.html` (27,100 bytes, hoy fuera del repo) a
|
||||
`src/lib/propuesta/render/plantilla.ts` y congelar su CSS. Sin esto, Fase 1 termina con tres JSON
|
||||
impecables y nada que enseñar.
|
||||
|
||||
Cambios obligatorios sobre la plantilla:
|
||||
|
||||
| Problema | Arreglo |
|
||||
|---|---|
|
||||
| 12 `rgba()` con canales escritos a mano duplican `--blue`, `--cream`, `--green`, `--amber` | Parametrizar de verdad, o `color_primario` produce un documento con dos azules |
|
||||
| `.section { page-break-inside: avoid }` en secciones enteras | Con 15-20 partidas empuja la sección a hoja nueva y la parte igual. Aplicar a filas, no a secciones |
|
||||
| No hay `@page` | Todo PDF impreso lleva `localhost:3000/...` estampado al pie de una propuesta comercial |
|
||||
| Tema oscuro + "Gráficos de fondo" desactivado por defecto en Chrome | El PDF sale con texto crema sobre blanco. Necesita un modo de impresión claro |
|
||||
| Cero hueco para el logo | El Anexo A del documento de negocio lo mapea como si existiera |
|
||||
| `.plan-grid` fijo a `1fr 1fr` | La sección de opciones pide tres tarjetas (anclaje A → B → C) |
|
||||
| Números de sección escritos a mano con cinco secciones condicionales | Un documento sin mantenimiento salta del 04 al 06. Numerar en el renderizador |
|
||||
| "Valor del problema" pide tabla de cuatro columnas | No hay patrón CSS; hay que crearlo |
|
||||
|
||||
Estrategia: template literals puros, sin motor de plantillas. El proyecto no tiene ninguno
|
||||
instalado y no lo amerita.
|
||||
|
||||
**Inyección CSS:** `PUT /api/configuracion` filtra las claves permitidas pero no valida los valores,
|
||||
y esos valores terminan dentro de un `<style>`. Validar `/^#[0-9a-fA-F]{3,8}$/` en el punto de
|
||||
inyección y caer al default del seed si no cumple.
|
||||
|
||||
**Anexo interno:** archivo separado, `INTERNO-NO-ENVIAR.html`, visualmente inconfundible. Nunca una
|
||||
sección oculta ni `display: none`.
|
||||
|
||||
### 5.7 Ergonomía del script
|
||||
|
||||
```
|
||||
npx tsx scripts/propuesta-consultiva.ts \
|
||||
--cotizacion UJ2607UJ003 \
|
||||
--transcripcion contexto/UJ2607UJ003/reunion.txt \
|
||||
--salida salidas/UJ2607UJ003/
|
||||
```
|
||||
|
||||
Banderas: `--cotizacion --transcripcion --salida --desde-paso --solo-validar`. Nada más.
|
||||
`--desde-paso` y `--solo-validar` se ganan su lugar: son la diferencia entre iterar el prompt del
|
||||
paso 3 veinte veces o cuatro.
|
||||
|
||||
Artefactos: `propuesta.html`, `INTERNO-NO-ENVIAR.html`, `propuesta.json` (con la traza embebida),
|
||||
`REPORTE.md`, y `traza/pasoN.json` porque `--desde-paso` los necesita.
|
||||
|
||||
Consola: los tres pasos tardan; imprime progreso por paso, tokens consumidos y aciertos de caché.
|
||||
|
||||
`contexto/` y `salidas/` van a `.gitignore` **y** a `.megaignore` (D11), con la exclusión
|
||||
verificada empíricamente antes de colocar material real.
|
||||
|
||||
### 5.8 Prompts y caché
|
||||
|
||||
Tres capas por estabilidad: estable (filosofía, frameworks, reglas, contrato, ejemplos),
|
||||
semi-estable (catálogo), volátil (cotización y transcripción). Dos breakpoints de `cache_control`.
|
||||
|
||||
MiniMax soporta caché con `cache_control` (4 breakpoints, TTL 5 min, verificable con
|
||||
`usage.cache_read_input_tokens`), pero el prefijo arranca en `tools`, no en `system`.
|
||||
|
||||
**Invalidadores a prevenir:** `new Date()` en la capa 1, `JSON.stringify` con orden de llaves no
|
||||
determinista, el número de cotización en la capa estable, cambio de modelo a media ejecución.
|
||||
Serializar el catálogo con orden explícito (`orden`, `id`).
|
||||
|
||||
**Los ejemplos few-shot deben validar contra su propio schema Zod al arrancar el script.** Cuesta
|
||||
diez líneas y es el único test que este proyecto va a tener. Un ejemplar defectuoso no produce un
|
||||
error: produce N documentos parecidos entre sí y se diagnostica tarde. El análisis encontró tres
|
||||
defectos en los ejemplos propuestos —uno que no valida, uno que mete el nombre de pila de una
|
||||
empleada en un campo del cliente, y uno que dispara el filtro de montos con `"400 pesos"`— que hay
|
||||
que corregir antes de la primera corrida.
|
||||
|
||||
También hay que pasar el **texto fijo de la plantilla** por el catálogo de PII una vez antes de la
|
||||
primera corrida, o la primera ejecución devolverá bloqueos que vienen del footer y no del modelo.
|
||||
|
||||
---
|
||||
|
||||
## 6. Supuestos sin verificar
|
||||
|
||||
Se documentan porque el diseño se apoya en ellos y la primera corrida debe medirlos.
|
||||
|
||||
| # | Supuesto | Qué hacer |
|
||||
|---|---|---|
|
||||
| V1 | MiniMax devuelve `cache_read_input_tokens` con `tools` fijo y 2 breakpoints | Primera medición obligatoria. Si no cachea, mandar las tres herramientas en cada petición es costo puro |
|
||||
| V2 | Con `stop_reason: "max_tokens"` el SDK entrega `input` como objeto parcial | Probar a propósito con `max_tokens: 200` |
|
||||
| V3 | `countTokens` existe en el endpoint compatible | Degradar a estimador local y marcar las cifras como estimadas en la traza |
|
||||
|
||||
Verificados durante el análisis, sin acción pendiente: `z.toJSONSchema` preserva lo necesario;
|
||||
`z.prettifyError` emite multi-error con ruta; `tsx` resuelve el alias `@/`; `core.autocrlf=true`
|
||||
exige normalizar saltos de línea.
|
||||
|
||||
---
|
||||
|
||||
## 7. Riesgos aceptados
|
||||
|
||||
| Riesgo | Por qué se acepta | Mitigación |
|
||||
|---|---|---|
|
||||
| La transcripción cruda con datos de empleados viaja a MiniMax | El modelo necesita el contexto para diagnosticar (D6) | Queda escrito aquí, no solo en la conversación. El filtro de salida protege el documento, no el tránsito |
|
||||
| El PDF de una cotización ya enviada deja de ser reproducible | Fase 0 cambia el renderizador; el sistema no archiva los PDF emitidos | Que el asesor archive lo enviado. Documentar en AGENTS.md que los exportadores no son reproducibles en el tiempo |
|
||||
| El hábito del asesor no migra solo | Tras Fase 0, el textarea donde lleva meses escribiendo contexto interno pasa a imprimirse en el PDF | Avisar por el canal del equipo, no solo cambiar la etiqueta |
|
||||
| Fase 1 añade una tercera superficie solo en TypeScript | AGENTS.md ya exige paridad entre dos backends | Documentar la divergencia. Si Fase 4 expone esto por MCP, la validación tendrá que existir del lado Python |
|
||||
|
||||
---
|
||||
|
||||
## 8. Criterios de éxito
|
||||
|
||||
**Fase 0** — verificable de inmediato:
|
||||
|
||||
- `POST /mcp` sin credencial devuelve 401; con credencial, `obtener_cotizacion` no expone el campo interno.
|
||||
- El total del PDF y el del Excel coinciden dígito por dígito en cotizaciones reales.
|
||||
- Una cotización con `incluirIva: false` no muestra IVA en ningún exportador.
|
||||
- Las notas internas no aparecen en ningún archivo exportable.
|
||||
- El texto del cliente aparece en el PDF, con saltos de línea correctos y sin romper el footer.
|
||||
|
||||
**Fase 1** — el criterio es cualitativo y es el que importa:
|
||||
|
||||
> El asesor revisa 5-10 documentos generados sobre cotizaciones reales pasadas y dice
|
||||
> *"esto lo mandaría a un cliente después de editarlo 10 minutos."*
|
||||
|
||||
Métricas de apoyo: cero afirmaciones sin evidencia, cero citas no verificables, y tasa de edición
|
||||
del asesor por debajo del 30%.
|
||||
|
||||
Si el criterio no se cumple, la conclusión correcta es iterar el prompt — no construir Fase 2.
|
||||
|
||||
---
|
||||
|
||||
## 9. Fuera de alcance
|
||||
|
||||
Modelos `PropuestaIA` y `ContextoCotizacion`; carga de archivos en la UI; route handler con patrón
|
||||
de job; vista de revisión con semáforo de salud; dashboard de BI agregado; herramienta MCP
|
||||
`generar_propuesta_consultiva`; sugerencia de esquema de 2-3 opciones; alerta de subcotización.
|
||||
|
||||
También difierido: migrar los cinco consumidores restantes de totales duplicados, y el semáforo de
|
||||
salud de §5.1 del documento de negocio — es una función de producto, no una validación, y no hace
|
||||
falta para saber si el texto generado sirve.
|
||||
@@ -30,6 +30,7 @@
|
||||
"prisma": "^7.8.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Fase 0-C, Despliegue A: separar el texto que ve el cliente del contexto interno.
|
||||
--
|
||||
-- ESTA MIGRACION ES PURAMENTE ADITIVA. No modifica ni una sola fila existente.
|
||||
-- El movimiento de datos (observaciones -> observacionesInternas) seria un
|
||||
-- Despliegue B posterior y NO se incluye aqui, por dos razones:
|
||||
--
|
||||
-- 1. Rollback seguro. No hay migraciones `down` en este repo ni servicio de
|
||||
-- backup en docker-compose.coolify.yml. Si un UPDATE vaciara "observaciones"
|
||||
-- y el despliegue se revirtiera, el codigo anterior leeria NULL en todas las
|
||||
-- filas y el asesor veria sus notas desaparecidas: sin error y sin log.
|
||||
--
|
||||
-- 2. El dato real no lo necesita. Al momento de escribir esto la unica fila de
|
||||
-- produccion con "observaciones" no vacias (UJ2606AG777, aprobada) contiene
|
||||
-- condiciones de pago dirigidas al cliente en segunda persona. Moverlas a
|
||||
-- "internas" ocultaria terminos ya acordados en un documento emitido.
|
||||
-- Esa fila ya esta clasificada correctamente donde esta.
|
||||
--
|
||||
-- IF NOT EXISTS hace la sentencia idempotente si alguien la aplica a mano.
|
||||
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN IF NOT EXISTS "observacionesInternas" TEXT;
|
||||
@@ -49,7 +49,13 @@ model Cotizacion {
|
||||
incluirIva Boolean @default(true)
|
||||
esDoble Boolean @default(false)
|
||||
opcionesMetadata Json?
|
||||
// Texto que SI ve el cliente: se imprime en el PDF y en el Excel.
|
||||
observaciones String?
|
||||
// Contexto de discovery, notas del asesor, riesgos. NO sale de la app: ningun
|
||||
// exportador declara este campo en su interfaz de datos (CotizacionPDFData /
|
||||
// ExcelData) y la herramienta MCP obtener_cotizacion usa lista blanca de
|
||||
// columnas. La garantia es estructural, no depende de recordar filtrarlo.
|
||||
observacionesInternas String?
|
||||
clienteId String
|
||||
asesorId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -45,6 +45,7 @@ export default async function EditarCotizacionPage({
|
||||
incluirBonos: cot.incluirBonos,
|
||||
incluirFinanciamiento: cot.incluirFinanciamiento,
|
||||
observaciones: cot.observaciones || "",
|
||||
observacionesInternas: cot.observacionesInternas || "",
|
||||
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
||||
esDoble: cot.esDoble,
|
||||
opciones: (cot.opcionesMetadata as { "1"?: object; "2"?: object } | null) ?? {},
|
||||
|
||||
@@ -226,10 +226,29 @@ export default async function CotizacionDetailPage({
|
||||
|
||||
{cot.observaciones && (
|
||||
<div className="bg-card-bg rounded-xl border border-border p-5">
|
||||
<h3 className="font-semibold mb-2">Observaciones</h3>
|
||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
||||
Observaciones
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-primary-light text-primary">
|
||||
Las ve el cliente
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unico lugar donde se muestran las notas internas. No van a ningun
|
||||
exportador: ni CotizacionPDFData ni ExcelData declaran el campo. */}
|
||||
{cot.observacionesInternas && (
|
||||
<div className="rounded-xl border border-amber-300 bg-amber-50 p-5">
|
||||
<h3 className="font-semibold mb-2 flex items-center gap-2 text-amber-900">
|
||||
Notas internas
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-200 text-amber-900">
|
||||
No sale de la app
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-sm text-amber-900/80 whitespace-pre-wrap">{cot.observacionesInternas}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function PUT(
|
||||
esDoble,
|
||||
opciones,
|
||||
observaciones,
|
||||
observacionesInternas,
|
||||
cliente,
|
||||
servicios,
|
||||
planBucefalo,
|
||||
@@ -158,6 +159,7 @@ export async function PUT(
|
||||
...(esDoble !== undefined && { esDoble }),
|
||||
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
|
||||
...(observaciones !== undefined && { observaciones }),
|
||||
...(observacionesInternas !== undefined && { observacionesInternas }),
|
||||
...(estado && ESTADOS_COTIZACION.includes(estado as typeof ESTADOS_COTIZACION[number]) && { estado }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function POST(request: NextRequest) {
|
||||
esDoble,
|
||||
opciones,
|
||||
observaciones,
|
||||
observacionesInternas,
|
||||
cliente,
|
||||
asesorId,
|
||||
servicios,
|
||||
@@ -81,6 +82,7 @@ export async function POST(request: NextRequest) {
|
||||
esDoble: esDoble ?? false,
|
||||
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
|
||||
observaciones: observaciones || null,
|
||||
observacionesInternas: observacionesInternas || null,
|
||||
clienteId: clienteIdFinal,
|
||||
asesorId,
|
||||
estado: "borrador",
|
||||
|
||||
@@ -17,7 +17,12 @@ export async function GET(
|
||||
include: {
|
||||
cliente: true,
|
||||
asesor: true,
|
||||
servicios: { include: { servicioCatalogo: true } },
|
||||
// Mismo orderBy que el PDF: los dos documentos van en el mismo correo y
|
||||
// deben listar las partidas en el mismo orden.
|
||||
servicios: {
|
||||
include: { servicioCatalogo: true },
|
||||
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
planBucefalo: true,
|
||||
},
|
||||
}),
|
||||
@@ -67,6 +72,9 @@ export async function GET(
|
||||
planBucefaloPrecio: cot.planBucefalo?.precio ?? null,
|
||||
colorPrimario: branding.colorPrimario || "#2563eb",
|
||||
colorSecundario: branding.colorSecundario || "#1e293b",
|
||||
incluirIva: cot.incluirIva,
|
||||
// El Excel es un documento del cliente: solo el texto del cliente.
|
||||
observaciones: cot.observaciones,
|
||||
};
|
||||
|
||||
const buffer = await buildCotizacionExcel(data);
|
||||
|
||||
@@ -80,6 +80,7 @@ export async function POST(request: NextRequest) {
|
||||
planBucefaloNivel: draft.planBucefaloNivel,
|
||||
colorPrimario: branding.colorPrimario || "#2563eb",
|
||||
colorSecundario: branding.colorSecundario || "#1e293b",
|
||||
observaciones: draft.observaciones,
|
||||
};
|
||||
|
||||
const buffer = await buildCotizacionExcel(data);
|
||||
|
||||
@@ -10,18 +10,25 @@ export async function GET(
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const [cot, config, branding] = await Promise.all([
|
||||
const [cot, config, branding, bonos] = await Promise.all([
|
||||
prisma.cotizacion.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
cliente: true,
|
||||
asesor: true,
|
||||
servicios: { include: { servicioCatalogo: true } },
|
||||
// orderBy explicito: drawSection abre un encabezado de fase nuevo cada vez
|
||||
// que cambia serv.fase, o sea que asume el array agrupado. Sin esto el orden
|
||||
// lo decide Postgres y el PDF puede salir distinto del Excel del mismo envio.
|
||||
servicios: {
|
||||
include: { servicioCatalogo: true },
|
||||
orderBy: [{ fase: "asc" }, { createdAt: "asc" }],
|
||||
},
|
||||
planBucefalo: true,
|
||||
},
|
||||
}),
|
||||
getConfigBancaria(),
|
||||
getConfigBranding(),
|
||||
prisma.bono.findMany({ where: { activo: true }, orderBy: { numero: "asc" } }),
|
||||
]);
|
||||
|
||||
if (!cot) {
|
||||
@@ -61,6 +68,10 @@ export async function GET(
|
||||
planBucefaloNivel: cot.planBucefalo?.nivel || null,
|
||||
planBucefaloPrecio: cot.planBucefalo?.precio || 0,
|
||||
incluirBonos: cot.incluirBonos,
|
||||
bonos,
|
||||
incluirIva: cot.incluirIva,
|
||||
// Solo el texto del cliente. observacionesInternas no se pasa nunca.
|
||||
observaciones: cot.observaciones,
|
||||
configBancaria: config,
|
||||
...branding,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { generateCotizacionPDF } from "@/lib/pdf-generator";
|
||||
import { calcularVigencia, bucefaloPrecio, sanitizeFilename } from "@/lib/calculators";
|
||||
import { getConfigBranding } from "@/lib/config-helpers";
|
||||
import { getConfigBranding, getConfigBancaria } from "@/lib/config-helpers";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -42,7 +43,14 @@ export async function POST(request: NextRequest) {
|
||||
const fechaCot = new Date(draft.fecha);
|
||||
const vigencia = calcularVigencia(fechaCot);
|
||||
|
||||
const branding = await getConfigBranding();
|
||||
// El borrador leia solo el branding, asi que imprimia los datos bancarios
|
||||
// hardcodeados del generador en vez de los configurados. El Excel de borrador
|
||||
// si leia ambos: esto empareja los dos.
|
||||
const [branding, configBancaria, bonos] = await Promise.all([
|
||||
getConfigBranding(),
|
||||
getConfigBancaria(),
|
||||
prisma.bono.findMany({ where: { activo: true }, orderBy: { numero: "asc" } }),
|
||||
]);
|
||||
const empresa = draft.clienteEmpresa || draft.clienteNombre;
|
||||
const nombre = `${sanitizeFilename(empresa)} - ${sanitizeFilename(draft.clienteNombre)} - BORRADOR`;
|
||||
|
||||
@@ -63,6 +71,10 @@ export async function POST(request: NextRequest) {
|
||||
planBucefaloNivel: draft.planBucefaloNivel,
|
||||
planBucefaloPrecio: draft.planBucefaloNivel ? bucefaloPrecio(draft.planBucefaloNivel) : 0,
|
||||
incluirBonos: draft.incluirBonos,
|
||||
bonos,
|
||||
// El cliente ya mandaba observaciones en el body; el generador nunca las recibia.
|
||||
observaciones: draft.observaciones,
|
||||
configBancaria,
|
||||
...branding,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Clock,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
@@ -82,6 +84,7 @@ export interface ExistingData {
|
||||
incluirBonos: boolean;
|
||||
incluirFinanciamiento: boolean;
|
||||
observaciones: string;
|
||||
observacionesInternas?: string;
|
||||
planBucefaloNivel: string | null;
|
||||
estado: string;
|
||||
servicios: ServicioSeleccionado[];
|
||||
@@ -165,6 +168,7 @@ export function CotizacionForm({
|
||||
store.setField("incluirBonos", existingData.incluirBonos);
|
||||
store.setField("incluirFinanciamiento", existingData.incluirFinanciamiento);
|
||||
store.setField("observaciones", existingData.observaciones);
|
||||
store.setField("observacionesInternas", existingData.observacionesInternas ?? "");
|
||||
store.setField("planBucefaloNivel", existingData.planBucefaloNivel);
|
||||
store.setField("esDoble", existingData.esDoble ?? false);
|
||||
store.setField("opciones", existingData.opciones ?? {});
|
||||
@@ -333,6 +337,7 @@ export function CotizacionForm({
|
||||
esDoble: store.draft.esDoble,
|
||||
opciones: store.draft.esDoble ? store.draft.opciones : undefined,
|
||||
observaciones: store.draft.observaciones,
|
||||
observacionesInternas: store.draft.observacionesInternas,
|
||||
cliente: {
|
||||
nombre: store.draft.clienteNombre,
|
||||
empresa: store.draft.clienteEmpresa,
|
||||
@@ -1217,19 +1222,51 @@ export function CotizacionForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card-bg rounded-xl border border-border p-5">
|
||||
<label className="block text-sm font-medium text-muted mb-1">
|
||||
Observaciones
|
||||
</label>
|
||||
<textarea
|
||||
value={store.draft.observaciones}
|
||||
onChange={(e) =>
|
||||
store.setField("observaciones", e.target.value)
|
||||
}
|
||||
rows={3}
|
||||
placeholder="Notas adicionales para la cotizacion..."
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
{/* Dos campos deliberadamente distintos. El de arriba se imprime en el PDF y
|
||||
el Excel que recibe el cliente; el de abajo no sale de la aplicacion.
|
||||
La diferencia visual es la barrera contra escribir en el equivocado. */}
|
||||
<div className="bg-card-bg rounded-xl border border-border p-5 space-y-5">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||
<Eye className="w-4 h-4 text-primary" />
|
||||
Observaciones
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-primary-light text-primary">
|
||||
Las ve el cliente
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted mb-2">
|
||||
Se imprime en el PDF y en el Excel que le envias. Condiciones, supuestos y
|
||||
aclaraciones del acuerdo.
|
||||
</p>
|
||||
<textarea
|
||||
value={store.draft.observaciones}
|
||||
onChange={(e) => store.setField("observaciones", e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Ej: El anticipo es del 50%. El avance queda condicionado a la entrega de accesos."
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium mb-1 text-amber-900">
|
||||
<Lock className="w-4 h-4" />
|
||||
Notas internas
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-amber-200 text-amber-900">
|
||||
No sale de la app
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-amber-800 mb-2">
|
||||
Contexto de la reunion, con quien hablar, riesgos, recordatorios. No se imprime
|
||||
en ningun documento ni se expone por el API.
|
||||
</p>
|
||||
<textarea
|
||||
value={store.draft.observacionesInternas}
|
||||
onChange={(e) => store.setField("observacionesInternas", e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Ej: El que decide es el socio, no el que vino a la reunion. Pidio descuento antes de ver el alcance."
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -112,6 +112,69 @@ export function calcularTotalesOpcion(
|
||||
};
|
||||
}
|
||||
|
||||
// ----- Totales de una cotizacion: FUENTE DE VERDAD UNICA -----
|
||||
// Antes este calculo estaba duplicado a mano con .filter().reduce() en siete
|
||||
// consumidores (PDF, Excel, detalle, PreciosEditables, formulario, lista y dashboard),
|
||||
// con criterios que no coincidian, y el IVA estaba hardcodeado como * 1.16 en los dos
|
||||
// exportadores ignorando IVA_RATE y el flag incluirIva. Toda comparacion de totales
|
||||
// (y cualquier documento que deba coincidir con el PDF) debe pasar por aqui.
|
||||
//
|
||||
// Nota: los subtotales son SIN IVA, que es como los exportadores presentan hoy los
|
||||
// totales de una cotizacion simple ("los precios no incluyen IVA" en la nota al pie).
|
||||
export interface TotalesCotizacion {
|
||||
subtotalUnico: number;
|
||||
subtotalMensual: number;
|
||||
ivaUnico: number;
|
||||
ivaMensual: number;
|
||||
totalUnico: number;
|
||||
totalMensual: number;
|
||||
/** Desembolso real a 12 meses: unico + mensual x 12, con IVA si aplica.
|
||||
* Es la base del ratio precio/valor de la propuesta consultiva. */
|
||||
totalPrimerAnio: number;
|
||||
incluyeIva: boolean;
|
||||
}
|
||||
|
||||
export function calcularTotalesCotizacion(
|
||||
servicios: Array<{ tipoPago: string; precio: number; seleccionado?: boolean }>,
|
||||
opciones?: { incluirIva?: boolean }
|
||||
): TotalesCotizacion {
|
||||
const incluyeIva = opciones?.incluirIva !== false;
|
||||
const activos = servicios.filter((s) => s.seleccionado !== false);
|
||||
|
||||
const suma = (tipo: string) =>
|
||||
r2(activos.filter((s) => s.tipoPago === tipo).reduce((a, s) => a + (s.precio || 0), 0));
|
||||
|
||||
const subtotalUnico = suma("unico");
|
||||
const subtotalMensual = suma("mensual");
|
||||
|
||||
const ivaUnico = incluyeIva ? r2(subtotalUnico * IVA_RATE) : 0;
|
||||
const ivaMensual = incluyeIva ? r2(subtotalMensual * IVA_RATE) : 0;
|
||||
|
||||
const totalUnico = r2(subtotalUnico + ivaUnico);
|
||||
const totalMensual = r2(subtotalMensual + ivaMensual);
|
||||
|
||||
return {
|
||||
subtotalUnico,
|
||||
subtotalMensual,
|
||||
ivaUnico,
|
||||
ivaMensual,
|
||||
totalUnico,
|
||||
totalMensual,
|
||||
totalPrimerAnio: r2(totalUnico + totalMensual * 12),
|
||||
incluyeIva,
|
||||
};
|
||||
}
|
||||
|
||||
function r2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
/** Aplica IVA a un monto respetando el flag de la cotizacion.
|
||||
* Sustituye a los `* 1.16` hardcodeados que habia en pdf-generator y excel-builder. */
|
||||
export function conIva(monto: number, incluirIva: boolean = true): number {
|
||||
return r2((monto || 0) * (incluirIva ? 1 + IVA_RATE : 1));
|
||||
}
|
||||
|
||||
export const FASES: Record<number, string> = {
|
||||
0: "FASE 0 - Auditoria / Acompanamiento",
|
||||
1: "FASE 1 - Setup e Infraestructura",
|
||||
|
||||
+29
-11
@@ -1,5 +1,5 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { bucefaloPrecio, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators";
|
||||
import { bucefaloPrecio, conIva, detalleModelo as detalleModeloCanonico, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "@/lib/calculators";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Forma normalizada de los datos que necesita el Excel. Tanto la ruta de borrador
|
||||
@@ -44,6 +44,11 @@ export interface ExcelData {
|
||||
planBucefaloPrecio?: number | null;
|
||||
colorPrimario: string;
|
||||
colorSecundario: string;
|
||||
incluirIva?: boolean;
|
||||
/** Texto que SI ve el cliente. El Excel es un documento del cliente (lleva razon
|
||||
* social, "En atencion a:", domicilio fiscal y nota legal), no una herramienta
|
||||
* interna: por eso `observacionesInternas` NO se declara aqui. */
|
||||
observaciones?: string | null;
|
||||
}
|
||||
|
||||
// Convierte "#RRGGBB" a ARGB de 8 caracteres. `alpha` es el canal alfa (2 hex):
|
||||
@@ -54,15 +59,10 @@ function argb(hex: string, alpha = "FF"): string {
|
||||
return alpha + h.toUpperCase();
|
||||
}
|
||||
|
||||
// Texto de desglose por modelo de cobro (horas / retainer) para mostrar junto al servicio.
|
||||
// Delega en la version canonica de calculators.ts. La copia local que habia aqui
|
||||
// omitia la rama "demanda", igual que la del PDF.
|
||||
function detalleModelo(serv: ExcelServicio): string {
|
||||
if (serv.modeloCobro === "retainer") {
|
||||
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
|
||||
}
|
||||
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
|
||||
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
|
||||
}
|
||||
return "";
|
||||
return detalleModeloCanonico(serv);
|
||||
}
|
||||
|
||||
function applyThinBorder(cell: ExcelJS.Cell, color?: string) {
|
||||
@@ -374,8 +374,10 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
|
||||
row++;
|
||||
};
|
||||
compRow("Concepto", `Opcion 1${t1Tit ? " - " + t1Tit : ""}`, `Opcion 2${t2Tit ? " - " + t2Tit : ""}`, boldFont);
|
||||
compRow("Total unico (c/IVA)", formatCurrency(t1.totalUnico * 1.16), formatCurrency(t2.totalUnico * 1.16), valueFont);
|
||||
compRow("Total mensual (c/IVA)", formatCurrency(t1.totalMensual * 1.16), formatCurrency(t2.totalMensual * 1.16), valueFont);
|
||||
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
|
||||
const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
|
||||
compRow(`Total unico${ivaLbl}`, formatCurrency(conIva(t1.totalUnico, data.incluirIva)), formatCurrency(conIva(t2.totalUnico, data.incluirIva)), valueFont);
|
||||
compRow(`Total mensual${ivaLbl}`, formatCurrency(conIva(t1.totalMensual, data.incluirIva)), formatCurrency(conIva(t2.totalMensual, data.incluirIva)), valueFont);
|
||||
compRow("Horas estimadas", `${t1.horas} h`, `${t2.horas} h`, valueFont);
|
||||
row++;
|
||||
} else {
|
||||
@@ -402,6 +404,22 @@ export async function buildCotizacionExcel(data: ExcelData): Promise<Buffer> {
|
||||
ws.getCell(`B${row}`).font = smallFont;
|
||||
setWrapped(ws, row, "B", notaResumen, mergedWidth(ws, "B", "K"), { fontSize: 9 });
|
||||
|
||||
// Observaciones del cliente. Solo este campo: el Excel lleva razon social,
|
||||
// "En atencion a:" y domicilio fiscal, o sea que es un documento que el cliente
|
||||
// recibe. observacionesInternas no existe en ExcelData a proposito.
|
||||
if (data.observaciones && data.observaciones.trim()) {
|
||||
row += 2;
|
||||
ws.mergeCells(`B${row}:K${row}`);
|
||||
ws.getCell(`B${row}`).value = "OBSERVACIONES";
|
||||
ws.getCell(`B${row}`).font = { ...boldFont, color: { argb: PRIMARY } };
|
||||
row++;
|
||||
ws.mergeCells(`B${row}:K${row}`);
|
||||
const texto = data.observaciones.trim();
|
||||
ws.getCell(`B${row}`).value = texto;
|
||||
ws.getCell(`B${row}`).font = smallFont;
|
||||
setWrapped(ws, row, "B", texto, mergedWidth(ws, "B", "K"), { fontSize: 9 });
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HOJAS DETALLADAS POR SERVICIO
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
+64
-19
@@ -1,5 +1,5 @@
|
||||
import PDFDocument from "pdfkit";
|
||||
import { FASES_SHORT as FASES, describirRetainer, formatCurrency, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
|
||||
import { FASES_SHORT as FASES, conIva, detalleModelo, calcularTotalesOpcion, type MetaOpcion } from "./calculators";
|
||||
|
||||
interface ServicioPDF {
|
||||
nombre: string;
|
||||
@@ -18,14 +18,11 @@ interface ServicioPDF {
|
||||
}
|
||||
|
||||
// Texto de desglose por modelo de cobro (horas / retainer) para la sub-linea del servicio.
|
||||
// Delega en la version canonica de calculators.ts. La copia local que habia aqui
|
||||
// omitia la rama "demanda", asi que esa partida se imprimia sin desglose y con
|
||||
// precio $0, sin explicar que se factura segun consumo.
|
||||
function detalleModeloPDF(serv: ServicioPDF): string {
|
||||
if (serv.modeloCobro === "retainer") {
|
||||
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
|
||||
}
|
||||
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
|
||||
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
|
||||
}
|
||||
return "";
|
||||
return detalleModelo(serv);
|
||||
}
|
||||
|
||||
interface CotizacionPDFData {
|
||||
@@ -45,6 +42,13 @@ interface CotizacionPDFData {
|
||||
planBucefaloNivel: string | null;
|
||||
planBucefaloPrecio: number;
|
||||
incluirBonos: boolean;
|
||||
/** Bonos desde la tabla Bono. Si no se pasan, se usa la lista de respaldo. */
|
||||
bonos?: { numero: number; descripcion: string }[];
|
||||
incluirIva?: boolean;
|
||||
/** Texto que SI ve el cliente. `observacionesInternas` NO se declara aqui a
|
||||
* proposito: si el generador no puede verlo, no puede filtrarlo. La garantia
|
||||
* es estructural, no depende de la disciplina de quien dibuje. */
|
||||
observaciones?: string | null;
|
||||
configBancaria?: Record<string, string>;
|
||||
colorPrimario?: string;
|
||||
colorSecundario?: string;
|
||||
@@ -324,9 +328,11 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
|
||||
doc.text(`Opcion 1${t1Tit ? " - " + t1Tit : ""}`, cOp1, y + 4, { width: W * 0.27 - 4 });
|
||||
doc.text(`Opcion 2${t2Tit ? " - " + t2Tit : ""}`, cOp2, y + 4, { width: W * 0.28 - 4 });
|
||||
y += 18;
|
||||
// IVA via conIva() y no `* 1.16`: respeta Cotizacion.incluirIva y usa IVA_RATE.
|
||||
const ivaLbl = data.incluirIva === false ? "" : " (c/IVA)";
|
||||
const filas: [string, string, string][] = [
|
||||
["Total unico (c/IVA)", fmt(t1.totalUnico * 1.16), fmt(t2.totalUnico * 1.16)],
|
||||
["Total mensual (c/IVA)", fmt(t1.totalMensual * 1.16), fmt(t2.totalMensual * 1.16)],
|
||||
[`Total unico${ivaLbl}`, fmt(conIva(t1.totalUnico, data.incluirIva)), fmt(conIva(t2.totalUnico, data.incluirIva))],
|
||||
[`Total mensual${ivaLbl}`, fmt(conIva(t1.totalMensual, data.incluirIva)), fmt(conIva(t2.totalMensual, data.incluirIva))],
|
||||
["Horas estimadas", `${t1.horas} h`, `${t2.horas} h`],
|
||||
];
|
||||
for (const [lab, v1, v2] of filas) {
|
||||
@@ -374,21 +380,60 @@ export async function generateCotizacionPDF(data: CotizacionPDFData): Promise<Bu
|
||||
doc.rect(L, y, 3, 10).fill(PRIMARY);
|
||||
doc.font("Helvetica-Bold").fontSize(9).fillColor(DARK).text("Bonos (Pago en una exhibicion)", L + 10, y);
|
||||
y += 16;
|
||||
const bonos = [
|
||||
"Bono 1: 30 min mensuales en servicios Centinela (Sitio Web)",
|
||||
"Bono 2: Workshop Estrategico de Buyer Persona",
|
||||
"Bono 3: Workshop de Propuestas de Valor y Oferta Irresistible",
|
||||
"Bono 4: 1 ano de Membresia Premium",
|
||||
"Bono 5: Un mes gratis de Bucefalo CRM",
|
||||
"Bono 6: Script de Ventas con mas de 100 complementos",
|
||||
];
|
||||
// Fuente de verdad: la tabla Bono. La lista de abajo es solo respaldo por si
|
||||
// la consulta no trajo nada; antes estaba hardcodeada aqui y su texto ya no
|
||||
// coincidia con el del seed (bono 5).
|
||||
const RESPALDO = [
|
||||
"30 min mensuales en servicios Centinela (Sitio Web)",
|
||||
"Workshop Estrategico de Buyer Persona",
|
||||
"Workshop de Propuestas de Valor y Oferta Irresistible",
|
||||
"1 ano de Membresia Premium",
|
||||
"Un mes gratis de Bucefalo CRM, Marketing y Ventas",
|
||||
"Script de Ventas con mas de 100 complementos",
|
||||
].map((d, i) => `Bono ${i + 1}: ${d}`);
|
||||
|
||||
const bonos = data.bonos?.length
|
||||
? data.bonos.map((b) => `Bono ${b.numero}: ${b.descripcion}`)
|
||||
: RESPALDO;
|
||||
|
||||
for (const b of bonos) {
|
||||
y = need(11, y);
|
||||
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2713 ${b}`, L + 8, y, { width: W - 16 });
|
||||
// Vinneta "\u2022" y no la palomita "\u2713": en las fuentes estandar de PDFKit
|
||||
// la palomita mide 0pt de ancho, o sea que hoy salia como dos espacios.
|
||||
doc.font("Helvetica").fontSize(7).fillColor(DARK).text(`\u2022 ${b}`, L + 8, y, { width: W - 16 });
|
||||
y += 11;
|
||||
}
|
||||
}
|
||||
|
||||
// ── OBSERVACIONES (solo el texto del cliente) ──
|
||||
// Va al final de la Hoja Resumen, junto a los totales y antes de T&C, que es
|
||||
// donde el cliente espera leer un mensaje del asesor. Nunca imprime
|
||||
// observacionesInternas: ese campo ni siquiera existe en CotizacionPDFData.
|
||||
if (data.observaciones && data.observaciones.trim()) {
|
||||
y = sectionTitle("Observaciones", y);
|
||||
const usable = maxY - m.top;
|
||||
const parrafos = data.observaciones
|
||||
.split(/\r?\n/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const p of parrafos) {
|
||||
const h = txtHeight(p, W - 4, 7.5);
|
||||
if (h > usable) {
|
||||
// Parrafo mas alto que una pagina entera: need() no sabe partir, asi que
|
||||
// se deja fluir a pdfkit y se resincroniza el contador con doc.y.
|
||||
y = need(20, y);
|
||||
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
|
||||
y = doc.y + 4;
|
||||
} else {
|
||||
y = need(h + 4, y);
|
||||
doc.font("Helvetica").fontSize(7.5).fillColor(DARK).text(p, L + 2, y, { width: W - 4 });
|
||||
y += h + 4;
|
||||
}
|
||||
}
|
||||
y += 4;
|
||||
}
|
||||
|
||||
// ── T&C PAGE ──────────────────────────────────
|
||||
doc.addPage();
|
||||
y = m.top;
|
||||
|
||||
@@ -43,6 +43,7 @@ export const cotizacionPostSchema = z.object({
|
||||
esDoble: z.boolean().optional(),
|
||||
opciones: opcionesSchema,
|
||||
observaciones: z.string(),
|
||||
observacionesInternas: z.string().optional(),
|
||||
asesorId: z.string().min(1),
|
||||
cliente: z.object({
|
||||
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
||||
@@ -73,6 +74,7 @@ export const cotizacionPutSchema = z.object({
|
||||
esDoble: z.boolean().optional(),
|
||||
opciones: opcionesSchema,
|
||||
observaciones: z.string(),
|
||||
observacionesInternas: z.string().optional(),
|
||||
cliente: z.object({
|
||||
nombre: z.string().min(1, "Cliente nombre es requerido"),
|
||||
empresa: z.string(),
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface CotizacionDraft {
|
||||
planBucefaloNivel: string | null;
|
||||
servicios: ServicioSeleccionado[];
|
||||
observaciones: string;
|
||||
observacionesInternas: string;
|
||||
// Doble propuesta: dos opciones comparables dentro de una misma cotizacion.
|
||||
esDoble: boolean;
|
||||
opciones: { "1"?: MetaOpcion; "2"?: MetaOpcion };
|
||||
@@ -79,6 +80,7 @@ const initialDraft: CotizacionDraft = {
|
||||
planBucefaloNivel: null,
|
||||
servicios: [],
|
||||
observaciones: "",
|
||||
observacionesInternas: "",
|
||||
esDoble: false,
|
||||
opciones: {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user