/** * 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 /** Etapa del pipeline de venta (columna status "Etapa" del tablero Unidades); null si no aplica */ etapa?: string | 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 ---------- export type BasePrecio = 'lista' | 'final' export type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje' export interface PropuestaConfig { tipo: TipoPropuesta claveDescuento: keyof DescuentosPropuestas titulo: string descuentoDefault: number pctEnganche: number engancheBase: BasePrecio pctPagoFinal: number pagoFinalBase: BasePrecio modoMensualidades: ModoMensualidades engancheCeroSinApartado?: boolean /** * Regla del admin: false => la propuesta no se ofrece ni se puede elegir en * el cotizador. Ausente/true => vigente (comportamiento histórico). */ activa?: boolean } export interface ParametrosDesarrollo { id: string /** Nombre para mostrar (solo lo traen las reglas guardadas por el admin) */ nombre?: string precioUnico: boolean fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad' nFijo?: number /** * N por defecto del desarrollo: se usa cuando el inventario no trae fecha de * entrega ni meses para firma. Garantiza que la mensualidad y el total * siempre se calculen (espejo de apps/cotizador/src/data/desarrollos.ts). */ nDefault?: number /** Cuota mensual de mantenimiento (MXN); null => pendiente */ cuotaMantenimiento?: number | null /** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */ prefijoUnidadPdf?: string /** * Regla del admin: false => el desarrollo no se ofrece en el cotizador. * Ausente/true => disponible (comportamiento histórico). */ activoEnCotizador?: boolean 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', // Último recurso si la unidad no trae fecha de entrega (mediana del // inventario disponible 2026-09: 13 meses hasta la terminación de obra) nDefault: 13, 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', // Último recurso si la unidad no trae fecha de entrega (mediana del // inventario disponible 2026-09: 6 mensualidades) nDefault: 6, 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', // Último recurso si la unidad no trae "meses para firma" (las casas // vendibles del inventario Excel están en 1 mes) nDefault: 1, 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' /** * Reglas comerciales del desarrollo: las configuradas por el admin (BD) ganan * cuando corresponden al id pedido; si no, se usan las constantes del módulo. */ function obtenerDesarrollo( id?: string | null, reglas?: ParametrosDesarrollo | null, ): ParametrosDesarrollo { if (reglas && (id === undefined || id === null || id === '' || reglas.id === id)) return reglas return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]! } /** Propuestas vigentes: el admin puede desactivar una propuesta por desarrollo */ export function propuestasVigentes(params: ParametrosDesarrollo): PropuestaConfig[] { return params.propuestas.filter((p) => p.activa !== false) } 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 /** * Reglas comerciales efectivas del desarrollo (las configuradas por el admin * o su respaldo en constantes). Si no vienen, se usan las constantes. */ reglas?: ParametrosDesarrollo /** 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; cae al plazo por defecto del * desarrollo (`nDefault`) cuando el inventario no trae el dato, para que la * mensualidad y el total SIEMPRE se calculen. */ export function resolverNumMensualidades( unidad: UnidadCotizable, params: ParametrosDesarrollo, ): number | null { switch (params.fuenteDeN) { case 'fijo': return params.nFijo ?? 1 case 'porUnidad': return unidad.mesesParaFirma ?? params.nDefault ?? null case 'fechaEntrega': return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : (params.nDefault ?? 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 } } /** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */ interface BloquesPago { enganche: number mensualidades: number | null pagoFinal: number } /** * Cuadra los bloques para que apartado + enganche + mensualidades + pago final * sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la * propuesta), aunque el apartado o un enganche personalizado descuadren las * fórmulas: * - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando * el apartado supera el enganche, ni mensualidades negativas con enganche * alto); * - el excedente se recorta de las mensualidades (bloque flexible del * remanente) y, si no alcanza, del pago final. Nunca se suma por encima del * precio. */ function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago { const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal const enganche = Math.max(0, bloques.enganche) let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades) let pagoFinal = Math.max(0, bloques.pagoFinal) let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total if (excedente > 0 && mensualidades !== null) { const recorte = Math.min(mensualidades, excedente) mensualidades -= recorte excedente -= recorte } if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente) return { enganche, mensualidades, pagoFinal } } /** Calcula una propuesta comercial con los parámetros dados */ function calcularPropuestaConfig( cfg: PropuestaConfig, precioLista: number, apartado: number, descuento: number, n: number | null, enganchePct: number | null = null, ): PropuestaResultado { const { montoDescuento, precioFinal } = baseComun(precioLista, descuento) // % de enganche efectivo: el override del asesor (mínimo 2.5%) solo aplica a // esquemas con crédito (bancario/Infonavit: 2.5–5%); Contado (90%) y // Semi Contado (50%) son estructuras fijas del Excel const aplicaOverride = enganchePct !== null && cfg.modoMensualidades !== 'ninguna' && cfg.pctEnganche > 0 && cfg.pctEnganche <= 0.05 const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche // Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de // SOLANA lo deja en 0 cuando el % es 0 const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal const engancheBruto = cfg.engancheCeroSinApartado && pctEnganche === 0 ? 0 : pctEnganche * baseEnganche // Fórmula del Excel: enganche neto del apartado (puede salir negativo si el // apartado supera el enganche; cuadrarBloques lo acota a 0) const engancheExcel = engancheBruto - apartado const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal let mensualidadesExcel: 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': mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel break case 'remanenteLista': // Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel break case 'porcentaje': mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal) break } } } const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, { enganche: engancheExcel, mensualidades: mensualidadesExcel, pagoFinal: pagoFinalExcel, }) const mensualidad = mensualidades === null || n === null ? null : mensualidades / n return { tipo: cfg.tipo, titulo: cfg.titulo, descuento, montoDescuento, precioFinal, enganche, numMensualidades: n, mensualidad, mensualidadesTotal: mensualidades, 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 vigentes 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, input.reglas ?? null, ) const precioLista = obtenerPrecioLista(unidad, acabados) const n = input.numMeses ?? resolverNumMensualidades(unidad, params) return propuestasVigentes(params).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. * `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con * crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo. * `numMeses` opcional: número de mensualidades elegido por el vendedor en el * cotizador; si no viene se resuelve con la fuente del desarrollo. * `reglas` opcional: reglas comerciales efectivas del desarrollo de la unidad * (BD del admin con respaldo en constantes); sin ellas se usan las constantes. */ export function calcularPropuesta( unidad: UnidadCotizable, tipo: TipoPropuestaApi, acabados: boolean, apartado: number = APARTADO_DEFAULT, descuento?: number, enganchePct?: number, numMeses?: number, reglas?: ParametrosDesarrollo | null, ): PropuestaResultado { const params = obtenerDesarrollo(unidad.desarrollo, reglas ?? null) const tipoInterno = TIPO_PROPUESTA_API[tipo] const cfg = propuestasVigentes(params).find((p) => p.tipo === tipoInterno) if (!cfg) { const desactivada = params.propuestas.some((p) => p.tipo === tipoInterno) throw new Error( desactivada ? `La propuesta ${tipo} está desactivada para el desarrollo ${params.id}` : `La propuesta ${tipo} no aplica para el desarrollo ${params.id}`, ) } const precioLista = obtenerPrecioLista(unidad, acabados) const n = numMeses ?? resolverNumMensualidades(unidad, params) const d = descuento ?? cfg.descuentoDefault return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null) }