From 4e103beb270dd017860138ad9fb0e8beb4992d18 Mon Sep 17 00:00:00 2001 From: urieljareth Date: Tue, 28 Jul 2026 18:10:51 -0600 Subject: [PATCH] Propuesta IA: modelo PropuestaIA y capa de datos economicos La capa de economia existe para que el dinero viva en un solo sitio y nunca cruce hacia el prompt. El pipeline recibe de ahi solo refPartida y nombre. Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 26 +++++ prisma/schema.prisma | 25 +++++ scripts/verificar-propuesta-economia.ts | 26 +++++ src/lib/propuesta/economia.ts | 96 +++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 prisma/migrations/20260729000000_add_propuesta_ia/migration.sql create mode 100644 scripts/verificar-propuesta-economia.ts create mode 100644 src/lib/propuesta/economia.ts diff --git a/prisma/migrations/20260729000000_add_propuesta_ia/migration.sql b/prisma/migrations/20260729000000_add_propuesta_ia/migration.sql new file mode 100644 index 0000000..b1aec1a --- /dev/null +++ b/prisma/migrations/20260729000000_add_propuesta_ia/migration.sql @@ -0,0 +1,26 @@ +-- Propuesta consultiva con IA: tabla nueva. +-- ADITIVA. No toca ninguna tabla ni fila existente. + +CREATE TABLE IF NOT EXISTS "PropuestaIA" ( + "id" TEXT NOT NULL, + "cotizacionId" TEXT NOT NULL, + "estado" TEXT NOT NULL DEFAULT 'borrador', + "contenidoIA" JSONB NOT NULL, + "contenidoEditado" JSONB, + "traza" JSONB, + "avisos" JSONB NOT NULL DEFAULT '[]', + "modelo" TEXT NOT NULL, + "aprobadaPor" TEXT, + "aprobadaAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PropuestaIA_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX IF NOT EXISTS "PropuestaIA_cotizacionId_idx" ON "PropuestaIA"("cotizacionId"); + +ALTER TABLE "PropuestaIA" + ADD CONSTRAINT "PropuestaIA_cotizacionId_fkey" + FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 17b4e60..6ec3975 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -66,6 +66,7 @@ model Cotizacion { servicios ServicioCotizado[] planBucefalo PlanBucefaloCotizacion? registrosHoras RegistroHoras[] + propuestasIA PropuestaIA[] @@index([clienteId]) @@index([asesorId]) @@ -220,6 +221,30 @@ model RegistroHoras { @@index([estadoPago]) } +// Documento consultivo generado por IA a partir del contexto humano de la cotizacion. +// Se guarda por separado lo que produjo el modelo y lo que edito el asesor, para poder +// mostrar que cambio y para no perder el original al editar. +model PropuestaIA { + id String @id @default(cuid()) + cotizacionId String + estado String @default("borrador") // borrador | aprobada + contenidoIA Json + contenidoEditado Json? + // Traza de la generacion: modelo, intentos por paso, tokens y aciertos de cache. + traza Json? + // Hallazgos de validacion y de contenido que el asesor debe revisar antes de enviar. + avisos Json @default("[]") + modelo String + aprobadaPor String? + aprobadaAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade) + + @@index([cotizacionId]) +} + model Configuracion { id String @id @default(cuid()) clave String @unique diff --git a/scripts/verificar-propuesta-economia.ts b/scripts/verificar-propuesta-economia.ts new file mode 100644 index 0000000..b25eace --- /dev/null +++ b/scripts/verificar-propuesta-economia.ts @@ -0,0 +1,26 @@ +import { calcularRatio } from "@/lib/propuesta/economia"; + +let fallas = 0; +function check(n: string, ok: boolean, d = "") { + console.log(`${ok ? " OK " : " FALLA"} | ${n}${d ? " -> " + d : ""}`); + if (!ok) fallas++; +} + +check("sin valor anual no hay ratio", calcularRatio(100000, null) === null); +check("valor anual cero no divide entre cero", calcularRatio(100000, 0) === null); +check("valor anual negativo no produce ratio", calcularRatio(100000, -5) === null); + +check("ratio 5% = subcotizado", calcularRatio(50000, 1000000)?.lectura === "subcotizado"); +check("ratio 20% = en rango", calcularRatio(200000, 1000000)?.lectura === "en_rango"); +check("ratio 35% = alto", calcularRatio(350000, 1000000)?.lectura === "alto"); +check("ratio 60% = objecion probable", calcularRatio(600000, 1000000)?.lectura === "objecion_probable"); + +// Los cortes exactos: 0.10 entra en rango, 0.25 sigue en rango, 0.40 sigue alto. +check("corte 10% cae en rango", calcularRatio(100000, 1000000)?.lectura === "en_rango"); +check("corte 25% cae en rango", calcularRatio(250000, 1000000)?.lectura === "en_rango"); +check("corte 40% cae en alto", calcularRatio(400000, 1000000)?.lectura === "alto"); + +check("ratio redondeado a 3 decimales", calcularRatio(123456, 1000000)?.ratio === 0.123); + +console.log(fallas === 0 ? "\ntodo paso\n" : `\n${fallas} fallas\n`); +process.exit(fallas === 0 ? 0 : 1); diff --git a/src/lib/propuesta/economia.ts b/src/lib/propuesta/economia.ts new file mode 100644 index 0000000..44afaab --- /dev/null +++ b/src/lib/propuesta/economia.ts @@ -0,0 +1,96 @@ +import { prisma } from "@/lib/db"; +import { calcularTotalesCotizacion, type TotalesCotizacion } from "@/lib/calculators"; + +/** + * Capa de datos economicos de la propuesta consultiva. + * + * Existe para que el dinero viva en UN solo sitio y nunca cruce hacia el prompt. + * El pipeline de IA recibe de aqui unicamente `refPartida` y `nombre`; los importes + * los inyecta el renderizador. Asi el principio "la IA no toca los numeros" lo + * garantiza el compilador y no una regla que alguien pueda olvidar validar. + */ + +export interface PartidaCanonica { + /** P01, P02... Es lo unico que identifica una partida ante el modelo. */ + refPartida: string; + /** cuid real de ServicioCotizado. NUNCA sale hacia el proveedor: no es estable + * entre ediciones, porque el PUT hace deleteMany + createMany. */ + servicioCotizadoId: string; + nombre: string; + fase: number; + tipoPago: string; + precio: number; + tiempoEntrega: string; + modeloCobro: string; + horas: number | null; + tarifaHora: number | null; + entregables: string[]; +} + +export interface DatosEconomicos { + partidas: PartidaCanonica[]; + totales: TotalesCotizacion; + moneda: string; +} + +export async function cargarEconomia(cotizacionId: string): Promise { + const cot = await prisma.cotizacion.findUnique({ + where: { id: cotizacionId }, + include: { + servicios: { + include: { servicioCatalogo: true }, + // Mismo orden que los exportadores: los tres documentos van en el mismo + // correo y deben listar las partidas igual. + orderBy: [{ fase: "asc" }, { createdAt: "asc" }], + }, + }, + }); + if (!cot) throw new Error(`Cotizacion ${cotizacionId} no encontrada`); + + const activos = cot.servicios.filter((s) => s.seleccionado); + const partidas: PartidaCanonica[] = activos.map((s, i) => ({ + refPartida: `P${String(i + 1).padStart(2, "0")}`, + servicioCotizadoId: s.id, + nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio", + fase: s.fase, + tipoPago: s.tipoPago, + precio: s.precio, + tiempoEntrega: s.tiempoEntrega, + modeloCobro: s.modeloCobro, + horas: s.horas, + tarifaHora: s.tarifaHora, + entregables: Array.isArray(s.entregables) ? (s.entregables as string[]) : [], + })); + + return { + partidas, + totales: calcularTotalesCotizacion(activos, { incluirIva: cot.incluirIva }), + moneda: cot.moneda, + }; +} + +export type LecturaRatio = "subcotizado" | "en_rango" | "alto" | "objecion_probable"; + +/** + * Ratio precio/valor para el anexo interno. + * + * Base: el desembolso real del primer ano CON IVA (unico + mensual x 12). Se eligio + * asi para que el numerador y el denominador tengan las mismas unidades: comparar un + * pago inicial contra un valor ANUAL del problema daria una lectura optimista falsa. + */ +export function calcularRatio( + totalPrimerAnio: number, + valorAnual: number | null +): { ratio: number; lectura: LecturaRatio } | null { + if (!valorAnual || valorAnual <= 0) return null; + const ratio = totalPrimerAnio / valorAnual; + const lectura: LecturaRatio = + ratio < 0.1 + ? "subcotizado" + : ratio <= 0.25 + ? "en_rango" + : ratio <= 0.4 + ? "alto" + : "objecion_probable"; + return { ratio: Math.round(ratio * 1000) / 1000, lectura }; +}