Modulo nuevo (solo admin) para dar de alta desarrollos sin tocar codigo: - CRUD de la ficha, modelos e inventario (alta manual y generador por rango) - Reglas del cotizador por desarrollo, editables y persistidas en la BD - El cotizador consume las reglas desde /desarrollos/reglas-cotizador y cae a las constantes con aviso cuando un desarrollo aun no tiene reglas guardadas - Micrositio publico en /d/:clave (sin sesion) con ficha, modelos y disponibilidad; jamas expone datos de clientes ni cotizaciones Integracion: - La landing monta el modulo en el grid (clave 'desarrollos', solo admin) y el micrositio fuera del login; el rol manager NO accede (matriz y API con 403) - La tabla de roles de Configuracion ahora lista todos los modulos - Se alinea la FK de ReglaCotizadorDesarrollo con la BD viva (Desarrollo.id) Verificado: 708 pruebas del API, 115 del cotizador y 89 de la landing en verde; 12/12 cotizaciones identicas antes y despues del cambio de reglas; prueba en vivo del API con alta, modelos, inventario, reglas, micrositio y baja (30/30).
455 lines
20 KiB
TypeScript
455 lines
20 KiB
TypeScript
/**
|
||
* 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<string, ParametrosDesarrollo> = {
|
||
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)
|
||
}
|