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,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