Compare commits

..
2 Commits
Author SHA1 Message Date
urieljarethandClaude Opus 5 9023384de9 Fase 0: cerrar el MCP, unificar totales y separar el contexto interno del cliente
Primera fase del plugin de propuesta consultiva (docs/superpowers/specs/
2026-07-28-propuesta-consultiva-ia-fase0-fase1-design.md). Recupera el contexto
humano que hoy se captura y se descarta, y cierra los bloqueadores que el
analisis previo destapo.

Seguridad (lo mas urgente):
- POST /mcp no tenia NINGUNA autenticacion: cero Depends() en main.py, mientras
  el servicio recibe dominio publico en produccion (SERVICE_FQDN_API_8000).
  Cualquiera en internet podia leer y escribir cotizaciones. Ahora exige
  require_auth, que ya existia en app/auth.py y no se estaba usando ahi.
- _obtener_cotizacion hacia SELECT c.* y devolvia dict(cot) al agente, asi que
  cualquier columna nueva se publicaba sola. Ahora usa lista blanca espejo de
  CotizacionResponse, excluyendo observacionesInternas.

Totales:
- Nueva calcularTotalesCotizacion() en calculators.ts como fuente de verdad
  unica. El calculo estaba duplicado a mano en siete consumidores.
- conIva() sustituye a los `* 1.16` hardcodeados de pdf-generator y
  excel-builder, que ignoraban IVA_RATE y el flag incluirIva.

Contexto humano:
- Campo nuevo Cotizacion.observacionesInternas. La migracion es PURAMENTE
  ADITIVA: no mueve ni una fila. El movimiento de datos no hace falta porque la
  unica fila de produccion con observaciones ya contiene texto dirigido al
  cliente, y separarlo en dos despliegues mantiene el rollback limpio.
- Dos textareas visualmente inconfundibles en el formulario.
- observaciones se imprime por primera vez en el PDF y el Excel; el dato ya
  viajaba hasta las rutas de borrador y se tiraba.
- observacionesInternas solo se ve en la app. La garantia es estructural: el
  campo no existe en CotizacionPDFData ni en ExcelData, asi que el generador no
  puede filtrarlo aunque alguien lo intente.

Bugs vecinos:
- orderBy explicito en las cuatro rutas de export: el PDF asume las partidas
  agrupadas por fase y sin orderBy podia diferir del Excel del mismo envio.
- El PDF de borrador leia solo el branding, no los datos bancarios, e imprimia
  los hardcodeados del generador.
- detalleModelo local en PDF y Excel omitia la rama "demanda": esa partida
  salia en $0 y sin explicacion. Ahora delegan en la version canonica.
- Los bonos salen de la tabla Bono; la lista hardcodeada queda de respaldo y su
  texto ya no coincidia con el seed.
- La palomita de los bonos mide 0pt en las fuentes base de PDFKit (verificado),
  o sea que salia como dos espacios. Sustituida por una vineta.

Ademas: zod pasa a ser dependencia declarada. Se importaba en schemas.ts y
resolvia transitivamente, asi que un npm ci --omit=dev reventaba.

Verificado con build, lint y 22 comprobaciones funcionales sobre PDF y Excel
reales (texto del cliente presente, texto interno ausente incluso inyectandolo
a la fuerza, texto largo multipagina, y totales con y sin IVA).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:50:03 -06:00
urieljarethandClaude Opus 5 0ff783fd65 Spec: diseño de Fase 0 y Fase 1 del plugin de propuesta consultiva con IA
Convierte el análisis de negocio de docs/BI-propuesta-consultiva-IA.md en una
especificación cerrada para las dos primeras fases del roadmap.

El análisis previo destapó cuatro problemas que el documento de negocio no
anticipaba, los cuatro verificados en disco:

- POST /mcp no tiene autenticación (api/main.py:107) y _obtener_cotizacion hace
  SELECT c.* sin lista blanca, con dominio público en producción. Publicaría la
  columna de notas internas sin tocar una línea de código.
- No existe una fuente de verdad para los totales: el cálculo está duplicado en
  siete consumidores y el IVA está hardcodeado como * 1.16 en PDF y Excel.
- El Excel es un documento del cliente, no una herramienta interna. El diseño
  inicial proponía poner ahí las notas internas; se corrigió.
- Las transcripciones se sincronizarían a la nube de MEGA: .megaignore solo
  excluye .next, node_modules y .turbo.

También reconcilia nueve contradicciones de contrato entre los diseños
explorados (clave de partida, forma de la evidencia, nombres del valor anual)
que habrían hecho que la capa de validación validara el vacío.

Actualiza AGENTS.md, que había quedado desfasado: 14 modelos y 10 migraciones
(decía 13 y 3), el registro de horas, la doble propuesta, los modelos de cobro
y la convención de no usar diálogos nativos del navegador.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:20:03 -06:00
22 changed files with 1679 additions and 65 deletions
+44 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+859
View File
@@ -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: **~$1222 USD/mes** (~$240440 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.
+1
View File
@@ -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;
+6
View File
@@ -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) ?? {},
+20 -1
View File
@@ -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>
);
}
+2
View File
@@ -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 }),
},
});
+2
View File
@@ -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",
+9 -1
View File
@@ -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);
+1
View File
@@ -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);
+13 -2
View File
@@ -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,
});
+14 -2
View File
@@ -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,
});
+50 -13
View File
@@ -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>
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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(),
+2
View File
@@ -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: {},
};