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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
20f535d485
commit
4e103beb27
@@ -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;
|
||||||
@@ -66,6 +66,7 @@ model Cotizacion {
|
|||||||
servicios ServicioCotizado[]
|
servicios ServicioCotizado[]
|
||||||
planBucefalo PlanBucefaloCotizacion?
|
planBucefalo PlanBucefaloCotizacion?
|
||||||
registrosHoras RegistroHoras[]
|
registrosHoras RegistroHoras[]
|
||||||
|
propuestasIA PropuestaIA[]
|
||||||
|
|
||||||
@@index([clienteId])
|
@@index([clienteId])
|
||||||
@@index([asesorId])
|
@@index([asesorId])
|
||||||
@@ -220,6 +221,30 @@ model RegistroHoras {
|
|||||||
@@index([estadoPago])
|
@@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 {
|
model Configuracion {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
clave String @unique
|
clave String @unique
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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<DatosEconomicos> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user