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]>
97 lines
3.2 KiB
TypeScript
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 };
|
|
}
|