/** * Cálculos replicados fielmente de los Excel de producción "COTIZADOR" de * Frasa Desarrollos (port parametrizado de apps/cotizador/src/lib/calculations.ts * + src/data/desarrollos.ts). NO "corregir" las fórmulas: algunas usan precio * de lista donde intuitivamente iría precio final; así está en los Excel y * así debe quedar. */ export interface UnidadCotizable { codigo: string modelo: string fechaEntrega: string | null // ISO 'YYYY-MM-DD'; null si no aplica o se desconoce supEstacionamiento: number supConstruccion: number supExcedente: number precioSinAcabados: number precioConAcabados: number status: string // DISPONIBLE | APARTADA | VENDIDA | APARTADO | VENDIDO | ESCRITURADO | BLOQUEADO desarrollo: string /** Plazo de mensualidades cuando lo fija el inventario (SOLANA: meses para firma) */ mesesParaFirma?: number | null } export type TipoPropuesta = | 'CREDITO_BANCARIO' | 'CREDITO_INFONAVIT_COFINAVIT' | 'CONTADO' | 'SEMI_CONTADO' export interface DescuentosPropuestas { creditoBancario: number creditoInfonavit: number contado: number semiContado: number } export const APARTADO_DEFAULT = 20000 // Descuentos por defecto de DIAGONAL (fracción, no porcentaje) export const DESCUENTOS_DEFAULT: DescuentosPropuestas = { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01, } // ---------- Parametrización por desarrollo ---------- type BasePrecio = 'lista' | 'final' type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje' interface PropuestaConfig { tipo: TipoPropuesta claveDescuento: keyof DescuentosPropuestas titulo: string descuentoDefault: number pctEnganche: number engancheBase: BasePrecio pctPagoFinal: number pagoFinalBase: BasePrecio modoMensualidades: ModoMensualidades engancheCeroSinApartado?: boolean } interface ParametrosDesarrollo { id: string precioUnico: boolean fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad' nFijo?: number propuestas: PropuestaConfig[] } /** * Espejo de apps/cotizador/src/data/desarrollos.ts (mantener sincronizados). * Detalle celda por celda en reference/cotizadores/*.md. */ export const DESARROLLOS: Record = { DIAGONAL: { id: 'DIAGONAL', precioUnico: false, fuenteDeN: 'fechaEntrega', propuestas: [ { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'remanenteLista' }, { tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0.02, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' }, { tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0.01, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, ], }, ATELIER: { id: 'ATELIER', precioUnico: true, fuenteDeN: 'fijo', nFijo: 1, propuestas: [ { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, { tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' }, { tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, ], }, ESTANZA: { id: 'ESTANZA', precioUnico: true, fuenteDeN: 'fijo', nFijo: 1, propuestas: [ { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'lista', modoMensualidades: 'porcentaje' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'lista', modoMensualidades: 'porcentaje' }, { tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' }, { tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, ], }, SENDA: { id: 'SENDA', precioUnico: true, fuenteDeN: 'fechaEntrega', propuestas: [ { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, { tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0.02, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' }, { tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0.01, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.05, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, ], }, SOLANA: { id: 'SOLANA', precioUnico: true, fuenteDeN: 'porUnidad', propuestas: [ { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje', engancheCeroSinApartado: true }, { tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado', descuentoDefault: 0, pctEnganche: 0.9, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'ninguna' }, { tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado', descuentoDefault: 0, pctEnganche: 0.5, engancheBase: 'final', pctPagoFinal: 0.1, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, ], }, } const DESARROLLO_DEFAULT = 'DIAGONAL' function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo { return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]! } export interface PropuestaResultado { tipo: TipoPropuesta titulo: string descuento: number // fracción (0.02 = 2%) montoDescuento: number precioFinal: number // precio lista - descuento enganche: number // NETO del apartado (el apartado ya se pagó) numMensualidades: number | null // null => plazo desconocido (sin fecha de entrega) mensualidad: number | null // null => N/A (Contado) o plazo desconocido mensualidadesTotal: number | null // null => N/A (Contado) o plazo desconocido pagoFinal: number notaMensualidades?: string } export interface CotizacionInput { unidad: UnidadCotizable acabados: boolean // true => incluye cocina y closet (ignorado en precio único) apartado: number descuentos: DescuentosPropuestas desarrollo?: string /** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */ numMeses?: number } const MS_POR_MES = 30.4 * 86400000 /** Meses (redondeados, mínimo 1) desde hoy hasta la terminación de obra */ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new Date()): number { const entrega = new Date(`${fechaEntrega}T00:00:00`) return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES)) } /** Resuelve N según la fuente del desarrollo (null si no se puede conocer) */ export function resolverNumMensualidades( unidad: UnidadCotizable, params: ParametrosDesarrollo, ): number | null { switch (params.fuenteDeN) { case 'fijo': return params.nFijo ?? 1 case 'porUnidad': return unidad.mesesParaFirma ?? null case 'fechaEntrega': return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null } } function baseComun(precioLista: number, descuento: number) { // Excel COTIZADOR: MONTO DE DESCUENTO y PRECIO FINAL C/DESC const montoDescuento = precioLista * descuento const precioFinal = precioLista - montoDescuento return { montoDescuento, precioFinal } } /** Calcula una propuesta comercial con los parámetros dados */ function calcularPropuestaConfig( cfg: PropuestaConfig, precioLista: number, apartado: number, descuento: number, n: number | null, ): PropuestaResultado { const { montoDescuento, precioFinal } = baseComun(precioLista, descuento) const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal const enganche = cfg.engancheCeroSinApartado && cfg.pctEnganche === 0 ? 0 : cfg.pctEnganche * baseEnganche - apartado const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal const pagoFinal = cfg.pctPagoFinal * basePagoFinal let mensualidadesTotal: number | null = null let mensualidad: number | null = null let notaMensualidades: string | undefined if (cfg.modoMensualidades !== 'ninguna') { if (n === null) { notaMensualidades = 'sin fecha de entrega' } else { switch (cfg.modoMensualidades) { case 'remanente': mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal break case 'remanenteLista': // Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal mensualidadesTotal = precioLista - pagoFinal - apartado - enganche break case 'porcentaje': mensualidadesTotal = precioFinal * (1 - cfg.pctEnganche - cfg.pctPagoFinal) break } mensualidad = mensualidadesTotal / n } } return { tipo: cfg.tipo, titulo: cfg.titulo, descuento, montoDescuento, precioFinal, enganche, numMensualidades: n, mensualidad, mensualidadesTotal, pagoFinal, ...(notaMensualidades !== undefined ? { notaMensualidades } : {}), } } /** Precio de lista según si incluye cocina y closet (acabados) */ export function obtenerPrecioLista(unidad: UnidadCotizable, acabados: boolean): number { return acabados ? unidad.precioConAcabados : unidad.precioSinAcabados } /** * Total pagado por el cliente en una propuesta: * apartado + enganche (neto del apartado) + mensualidades + pago final. * null cuando el total no se puede conocer (plazo desconocido). */ export function totalPagado(p: PropuestaResultado, apartado: number): number | null { if (p.notaMensualidades) return null return apartado + p.enganche + (p.mensualidadesTotal ?? 0) + p.pagoFinal } /** Índice del menor total pagado; en empate gana el primero. -1 si la lista está vacía */ export function indiceMenorTotal(totales: number[]): number { if (totales.length === 0) return -1 let indice = 0 for (let i = 1; i < totales.length; i++) { const actual = totales[i] const mejor = totales[indice] if (actual !== undefined && mejor !== undefined && actual < mejor) indice = i } return indice } /** Calcula las propuestas comerciales del desarrollo de la unidad */ export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] { const { unidad, acabados, apartado, descuentos } = input const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT) const precioLista = obtenerPrecioLista(unidad, acabados) const n = input.numMeses ?? resolverNumMensualidades(unidad, params) return params.propuestas.map((cfg) => calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n), ) } /** Mapa entre el nombre del API (body) y el nombre interno de la propuesta */ export const TIPO_PROPUESTA_API = { BANCARIO: 'CREDITO_BANCARIO', INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT', COFINAVIT: 'CREDITO_INFONAVIT_COFINAVIT', CONTADO: 'CONTADO', SEMI_CONTADO: 'SEMI_CONTADO', } as const export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API /** * Calcula UNA propuesta (la elegida por el cliente) para persistir/cotizar, * con los parámetros del desarrollo de la unidad. * `descuento` opcional (fracción); si no viene, usa el default de la propuesta. */ export function calcularPropuesta( unidad: UnidadCotizable, tipo: TipoPropuestaApi, acabados: boolean, apartado: number = APARTADO_DEFAULT, descuento?: number, ): PropuestaResultado { const params = obtenerDesarrollo(unidad.desarrollo) const tipoInterno = TIPO_PROPUESTA_API[tipo] const cfg = params.propuestas.find((p) => p.tipo === tipoInterno) if (!cfg) { throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`) } const precioLista = obtenerPrecioLista(unidad, acabados) const n = resolverNumMensualidades(unidad, params) const d = descuento ?? cfg.descuentoDefault return calcularPropuestaConfig(cfg, precioLista, apartado, d, n) }