Files
cotizador/src/lib/propuesta/economia.ts
T
urieljarethandClaude Opus 5 4e103beb27 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]>
2026-07-28 18:10:51 -06:00

97 lines
3.2 KiB
TypeScript

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 };
}