Files
FRASA-CAS/api-gateway/src/services/cotizacion.service.ts
Consult AS Technology afb454b54c feat(cotizador): multi-desarrollo — ATELIER, ESTANZA, SENDA, SOLANA + DIAGONAL
- Motor de cálculo parametrizado por desarrollo (enganches, descuentos, splits,
  fuente de N, quirk Infonavit-lista); DIAGONAL byte-idéntico
- 285 unidades (5 desarrollos) en seeds, API (?desarrollo=) y board Monday
- Selector de desarrollo en UI, propuestas por config, PDF multi-desarrollo
- Knowledge IA: catálogo por desarrollo + matriz de diferencias (10 docs)
- Tests: cotizador 53, api 99 — fixtures de los Excel validados al centavo
2026-08-22 04:31:21 +00:00

326 lines
14 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
}
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<string, ParametrosDesarrollo> = {
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)
}