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
This commit is contained in:
291
apps/cotizador/src/lib/calculations-desarrollos.test.ts
Normal file
291
apps/cotizador/src/lib/calculations-desarrollos.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calcularPropuestas,
|
||||
obtenerPrecioLista,
|
||||
resolverNumMensualidades,
|
||||
totalPagado,
|
||||
APARTADO_DEFAULT,
|
||||
} from './calculations'
|
||||
import { DESARROLLOS, descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
|
||||
import { UNIDADES_ATELIER } from '../data/unidades-atelier'
|
||||
import { UNIDADES_ESTANZA } from '../data/unidades-estanza'
|
||||
import { UNIDADES_SENDA } from '../data/unidades-senda'
|
||||
import { UNIDADES_SOLANA } from '../data/unidades-solana'
|
||||
import type { Unidad } from '../types'
|
||||
|
||||
/**
|
||||
* Fixtures validados al centavo contra los Excel de producción
|
||||
* (ver reference/cotizadores/{ATELIER,ESTANZA,SENDA,SOLANA}.md).
|
||||
* Orden de propuestas en la config: [bancario, infonavit/cofinavit, contado, semi].
|
||||
*/
|
||||
|
||||
function porCodigo(unidades: Unidad[], codigo: string): Unidad {
|
||||
const u = unidades.find((x) => x.codigo === codigo)
|
||||
if (!u) throw new Error(`Fixture ${codigo} no existe en el seed`)
|
||||
return u
|
||||
}
|
||||
|
||||
describe('ATELIER — depto 10 (L = 3,997,500, apartado 20,000, N fijo = 1)', () => {
|
||||
const unidad = porCodigo(UNIDADES_ATELIER, 'ATELIER-10')
|
||||
const calcular = () =>
|
||||
calcularPropuestas({
|
||||
unidad,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: descuentosDefault('ATELIER'),
|
||||
})
|
||||
|
||||
it('precio único: no depende de acabados', () => {
|
||||
expect(obtenerPrecioLista(unidad, true)).toBe(3997500)
|
||||
expect(obtenerPrecioLista(unidad, false)).toBe(3997500)
|
||||
})
|
||||
|
||||
it('resuelve N = 1 fijo (entrega inmediata)', () => {
|
||||
expect(resolverNumMensualidades(unidad, DESARROLLOS['ATELIER'])).toBe(1)
|
||||
})
|
||||
|
||||
it('BANCARIO: enganche 5% sobre LISTA; mensualidad = remanente en 1 pago', () => {
|
||||
const [p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(179875, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(199875, 2)
|
||||
expect(p.mensualidad).toBeCloseTo(199875, 2)
|
||||
expect(p.numMensualidades).toBe(1)
|
||||
expect(p.pagoFinal).toBeCloseTo(3597750, 2)
|
||||
})
|
||||
|
||||
it('COFINAVIT: estructura Infonavit sin quirk (todo sobre precio final)', () => {
|
||||
const [, p] = calcular()
|
||||
expect(p.titulo).toBe('Crédito Cofinavit')
|
||||
expect(p.enganche).toBeCloseTo(79937.5, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(99937.5, 2)
|
||||
expect(p.mensualidad).toBeCloseTo(99937.5, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(3797625, 2)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(3997500, 2)
|
||||
})
|
||||
|
||||
it('CONTADO', () => {
|
||||
const [, , p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(3577750, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(399750, 2)
|
||||
expect(p.mensualidad).toBeNull()
|
||||
})
|
||||
|
||||
it('SEMI CONTADO', () => {
|
||||
const [, , , p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(1978750, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(1599000, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(399750, 2)
|
||||
})
|
||||
|
||||
it('bancario cuadra: apartado + enganche + mensualidades + pago final = precio final', () => {
|
||||
const [p] = calcular()
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(3997500, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ESTANZA — casa 52 (L = 2,437,250, apartado 20,000, N fijo = 1, desc 0)', () => {
|
||||
const unidad = porCodigo(UNIDADES_ESTANZA, 'ESTANZA-52')
|
||||
const calcular = () =>
|
||||
calcularPropuestas({
|
||||
unidad,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: descuentosDefault('ESTANZA'),
|
||||
})
|
||||
|
||||
it('BANCARIO: enganche 2.5% sobre final; pago final 90% sobre LISTA (quirk)', () => {
|
||||
const [p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(40931.25, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(182793.75, 2)
|
||||
expect(p.mensualidad).toBeCloseTo(182793.75, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(2193525, 2)
|
||||
})
|
||||
|
||||
it('INFONAVIT: pago final 95% sobre LISTA (quirk)', () => {
|
||||
const [, p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(40931.25, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(60931.25, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(2315387.5, 2)
|
||||
})
|
||||
|
||||
it('CONTADO', () => {
|
||||
const [, , p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(2173525, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(243725, 2)
|
||||
})
|
||||
|
||||
it('SEMI CONTADO', () => {
|
||||
const [, , , p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(1198625, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(974900, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(243725, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0 en el Excel, N = 4)', () => {
|
||||
const unidad = porCodigo(UNIDADES_SENDA, 'SENDA-411-1-7')
|
||||
const calcular = () =>
|
||||
calcularPropuestas({
|
||||
unidad,
|
||||
acabados: false,
|
||||
apartado: 0, // el Excel de SENDA traía MONTO APARTADO (C15) en 0
|
||||
descuentos: descuentosDefault('SENDA'),
|
||||
numMeses: 4,
|
||||
})
|
||||
|
||||
it('BANCARIO: enganche 5%, mensualidades 5% ÷ N, pago final 90%', () => {
|
||||
const [p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(122587.5, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(122587.5, 2)
|
||||
expect(p.mensualidad).toBeCloseTo(30646.875, 3)
|
||||
expect(p.pagoFinal).toBeCloseTo(2206575, 2)
|
||||
})
|
||||
|
||||
it('INFONAVIT: sin quirk de lista — mensualidades sobre precio final', () => {
|
||||
const [, p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(61293.75, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(61293.75, 2)
|
||||
expect(p.mensualidad).toBeCloseTo(15323.4375, 4)
|
||||
expect(p.pagoFinal).toBeCloseTo(2329162.5, 2)
|
||||
})
|
||||
|
||||
it('CONTADO (desc default 2%)', () => {
|
||||
const [, , p] = calcular()
|
||||
expect(p.precioFinal).toBeCloseTo(2402715, 2)
|
||||
expect(p.enganche).toBeCloseTo(2162443.5, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(240271.5, 2)
|
||||
})
|
||||
|
||||
it('SEMI CONTADO (desc default 1%, split 50/45/5)', () => {
|
||||
const [, , , p] = calcular()
|
||||
expect(p.precioFinal).toBeCloseTo(2427232.5, 2)
|
||||
expect(p.enganche).toBeCloseTo(1213616.25, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(1092254.625, 3)
|
||||
expect(p.mensualidad).toBeCloseTo(273063.65625, 5)
|
||||
expect(p.pagoFinal).toBeCloseTo(121361.625, 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
|
||||
const unidad = porCodigo(UNIDADES_SENDA, 'SENDA-411-25-A101')
|
||||
|
||||
it('N = null y mensualidades "—" con nota; enganche y pago final sí se calculan', () => {
|
||||
expect(unidad.fechaEntrega).toBeNull()
|
||||
const [bancario] = calcularPropuestas({
|
||||
unidad,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: descuentosDefault('SENDA'),
|
||||
})
|
||||
expect(bancario.numMensualidades).toBeNull()
|
||||
expect(bancario.mensualidad).toBeNull()
|
||||
expect(bancario.mensualidadesTotal).toBeNull()
|
||||
expect(bancario.notaMensualidades).toBe('sin fecha de entrega')
|
||||
expect(bancario.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
|
||||
expect(bancario.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
|
||||
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SOLANA — casa 6 (L = 2,560,000, apartado 20,000, mesesParaFirma = 1)', () => {
|
||||
const unidad = porCodigo(UNIDADES_SOLANA, 'SOLANA-6')
|
||||
const calcular = () =>
|
||||
calcularPropuestas({
|
||||
unidad,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: descuentosDefault('SOLANA'),
|
||||
})
|
||||
|
||||
it('N viene de mesesParaFirma de la unidad', () => {
|
||||
expect(unidad.mesesParaFirma).toBe(1)
|
||||
expect(resolverNumMensualidades(unidad, DESARROLLOS['SOLANA'])).toBe(1)
|
||||
})
|
||||
|
||||
it('BANCARIO: enganche 5% sobre final', () => {
|
||||
const [p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(108000, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(2304000, 2)
|
||||
})
|
||||
|
||||
it('INFONAVIT: enganche 0% con guarda (no resta el apartado)', () => {
|
||||
const [, p] = calcular()
|
||||
expect(p.enganche).toBe(0)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(2432000, 2)
|
||||
})
|
||||
|
||||
it('CONTADO', () => {
|
||||
const [, , p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(2284000, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(256000, 2)
|
||||
})
|
||||
|
||||
it('SEMI CONTADO', () => {
|
||||
const [, , , p] = calcular()
|
||||
expect(p.enganche).toBeCloseTo(1260000, 2)
|
||||
expect(p.mensualidadesTotal).toBeCloseTo(1024000, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(256000, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SOLANA — casa 9 (L = 2,605,000, mesesParaFirma = 1)', () => {
|
||||
const unidad = porCodigo(UNIDADES_SOLANA, 'SOLANA-9')
|
||||
const calcular = () =>
|
||||
calcularPropuestas({
|
||||
unidad,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: descuentosDefault('SOLANA'),
|
||||
})
|
||||
|
||||
it('las 4 propuestas al centavo', () => {
|
||||
const [bancario, infonavit, contado, semi] = calcular()
|
||||
expect(bancario.enganche).toBeCloseTo(110250, 2)
|
||||
expect(bancario.mensualidadesTotal).toBeCloseTo(130250, 2)
|
||||
expect(bancario.pagoFinal).toBeCloseTo(2344500, 2)
|
||||
expect(infonavit.enganche).toBe(0)
|
||||
expect(infonavit.mensualidadesTotal).toBeCloseTo(130250, 2)
|
||||
expect(infonavit.pagoFinal).toBeCloseTo(2474750, 2)
|
||||
expect(contado.enganche).toBeCloseTo(2324500, 2)
|
||||
expect(contado.pagoFinal).toBeCloseTo(260500, 2)
|
||||
expect(semi.enganche).toBeCloseTo(1282500, 2)
|
||||
expect(semi.mensualidadesTotal).toBeCloseTo(1042000, 2)
|
||||
expect(semi.pagoFinal).toBeCloseTo(260500, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('configuración de desarrollos', () => {
|
||||
it('los 5 desarrollos tienen 4 propuestas', () => {
|
||||
for (const id of ['DIAGONAL', 'ATELIER', 'ESTANZA', 'SENDA', 'SOLANA']) {
|
||||
expect(DESARROLLOS[id].propuestas).toHaveLength(4)
|
||||
}
|
||||
})
|
||||
|
||||
it('id desconocido cae a DIAGONAL', () => {
|
||||
expect(obtenerDesarrollo('NO_EXISTE').id).toBe('DIAGONAL')
|
||||
expect(obtenerDesarrollo(undefined).id).toBe('DIAGONAL')
|
||||
})
|
||||
|
||||
it('descuentos default por desarrollo', () => {
|
||||
expect(descuentosDefault('DIAGONAL')).toEqual({
|
||||
creditoBancario: 0,
|
||||
creditoInfonavit: 0,
|
||||
contado: 0.02,
|
||||
semiContado: 0.01,
|
||||
})
|
||||
expect(descuentosDefault('SENDA')).toEqual({
|
||||
creditoBancario: 0,
|
||||
creditoInfonavit: 0,
|
||||
contado: 0.02,
|
||||
semiContado: 0.01,
|
||||
})
|
||||
expect(descuentosDefault('ATELIER')).toEqual({
|
||||
creditoBancario: 0,
|
||||
creditoInfonavit: 0,
|
||||
contado: 0,
|
||||
semiContado: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -129,7 +129,7 @@ describe('totalPagado', () => {
|
||||
describe('indiceMenorTotal', () => {
|
||||
it('devuelve el índice de la propuesta con menor total pagado', () => {
|
||||
const propuestas = calcular()
|
||||
const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT))
|
||||
const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT) ?? 0)
|
||||
// Contado (2% desc) tiene el menor total pagado
|
||||
expect(indiceMenorTotal(totales)).toBe(2)
|
||||
})
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import type { Unidad, TipoPropuesta, DescuentosPropuestas } from '../types'
|
||||
import type {
|
||||
Unidad,
|
||||
TipoPropuesta,
|
||||
DescuentosPropuestas,
|
||||
ParametrosDesarrollo,
|
||||
PropuestaConfig,
|
||||
} from '../types'
|
||||
import { DESARROLLO_DEFAULT, obtenerDesarrollo } from '../data/desarrollos'
|
||||
|
||||
/**
|
||||
* Cálculos replicados fielmente del Excel de producción "COTIZADOR" de
|
||||
* Frasa Desarrollos (ya validado). NO "corregir" las fórmulas: algunas
|
||||
* Cálculos replicados fielmente de los Excel de producción "COTIZADOR" de
|
||||
* Frasa Desarrollos (ya validados). NO "corregir" las fórmulas: algunas
|
||||
* usan precio de lista donde intuitivamente iría precio final; así está
|
||||
* en el Excel y así debe quedar.
|
||||
* en los Excel y así debe quedar.
|
||||
*
|
||||
* El motor está parametrizado por desarrollo (ver src/data/desarrollos.ts);
|
||||
* con los parámetros de DIAGONAL reproduce byte a byte las fórmulas
|
||||
* originales de este archivo.
|
||||
*/
|
||||
|
||||
export const APARTADO_DEFAULT = 20000
|
||||
|
||||
// Descuentos por defecto de cada propuesta (fracción, no porcentaje)
|
||||
// Descuentos por defecto de DIAGONAL (fracción, no porcentaje).
|
||||
// Para otros desarrollos usar descuentosDefault(desarrollo) de data/desarrollos.
|
||||
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
|
||||
creditoBancario: 0,
|
||||
creditoInfonavit: 0,
|
||||
@@ -19,22 +31,28 @@ export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
|
||||
|
||||
export interface PropuestaResultado {
|
||||
tipo: TipoPropuesta
|
||||
/** Título para mostrar, viene de la config del desarrollo (p. ej. 'Crédito Cofinavit') */
|
||||
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
|
||||
mensualidad: number | null // null => N/A (Contado)
|
||||
mensualidadesTotal: number | null // null => N/A (Contado)
|
||||
numMensualidades: number | null // null => sin fecha de entrega / plazo desconocido
|
||||
mensualidad: number | null // null => N/A (Contado) o plazo desconocido
|
||||
mensualidadesTotal: number | null // null => N/A (Contado) o plazo desconocido
|
||||
pagoFinal: number
|
||||
/** Nota explicativa cuando no se pueden calcular mensualidades ('sin fecha de entrega') */
|
||||
notaMensualidades?: string
|
||||
}
|
||||
|
||||
export interface CotizacionInput {
|
||||
unidad: Unidad
|
||||
acabados: boolean // true => incluye cocina y closet
|
||||
acabados: boolean // true => incluye cocina y closet (ignorado en desarrollos de precio único)
|
||||
apartado: number
|
||||
descuentos: DescuentosPropuestas
|
||||
/** Permite forzar N en tests; por defecto se calcula desde fechaEntrega */
|
||||
/** Desarrollo cuyos parámetros aplican; por defecto unidad.desarrollo ?? 'DIAGONAL' */
|
||||
desarrollo?: string
|
||||
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
|
||||
numMeses?: number
|
||||
}
|
||||
|
||||
@@ -46,6 +64,26 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
|
||||
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve N según la fuente del desarrollo:
|
||||
* - fijo: nFijo (entrega inmediata)
|
||||
* - porUnidad: mesesParaFirma del inventario (null si falta)
|
||||
* - fechaEntrega: meses desde hoy (null si la unidad no tiene fecha)
|
||||
*/
|
||||
export function resolverNumMensualidades(
|
||||
unidad: Unidad,
|
||||
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
|
||||
@@ -53,102 +91,82 @@ function baseComun(precioLista: number, descuento: number) {
|
||||
return { montoDescuento, precioFinal }
|
||||
}
|
||||
|
||||
/** PROPUESTA 1 — CRÉDITO BANCARIO */
|
||||
function calcularBancario(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
|
||||
/** 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)
|
||||
// Excel COTIZADOR!M17 — enganche 2.5% sobre precio LISTA, neto del apartado
|
||||
const enganche = 0.025 * precioLista - apartado
|
||||
// Excel COTIZADOR!M19 — pago final 90% del precio final c/desc
|
||||
const pagoFinal = 0.9 * precioFinal
|
||||
// Excel COTIZADOR!M18 — remanente a mensualidades
|
||||
const mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
|
||||
|
||||
// Enganche: pct sobre su base, neto del apartado (salvo la guarda de SOLANA)
|
||||
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) {
|
||||
// Unidad sin fecha de entrega / plazo: el Excel mostraría #N/A
|
||||
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: 'CREDITO_BANCARIO',
|
||||
tipo: cfg.tipo,
|
||||
titulo: cfg.titulo,
|
||||
descuento,
|
||||
montoDescuento,
|
||||
precioFinal,
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad: mensualidadesTotal / n,
|
||||
mensualidad,
|
||||
mensualidadesTotal,
|
||||
pagoFinal,
|
||||
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** PROPUESTA 2 — CRÉDITO INFONAVIT-COFINAVIT */
|
||||
function calcularInfonavit(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
|
||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||
// Excel COTIZADOR!M26 — enganche 2.5% sobre precio LISTA, neto del apartado
|
||||
const enganche = 0.025 * precioLista - apartado
|
||||
// Excel COTIZADOR!M28 — pago final 95% del precio final c/desc
|
||||
const pagoFinal = 0.95 * precioFinal
|
||||
// Excel COTIZADOR!M27 — OJO: el Excel usa precioLista aquí, NO precioFinal. Replicar tal cual.
|
||||
const mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
|
||||
return {
|
||||
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
|
||||
descuento,
|
||||
montoDescuento,
|
||||
precioFinal,
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad: mensualidadesTotal / n,
|
||||
mensualidadesTotal,
|
||||
pagoFinal,
|
||||
}
|
||||
}
|
||||
|
||||
/** PROPUESTA 3 — CONTADO (sin mensualidades) */
|
||||
function calcularContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
|
||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||
// Excel COTIZADOR!M36 — enganche 90% del precio final c/desc, neto del apartado
|
||||
const enganche = 0.9 * precioFinal - apartado
|
||||
// Excel COTIZADOR!M37 — pago final 10% del precio final c/desc
|
||||
const pagoFinal = 0.1 * precioFinal
|
||||
return {
|
||||
tipo: 'CONTADO',
|
||||
descuento,
|
||||
montoDescuento,
|
||||
precioFinal,
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad: null, // N/A
|
||||
mensualidadesTotal: null, // N/A
|
||||
pagoFinal,
|
||||
}
|
||||
}
|
||||
|
||||
/** PROPUESTA 4 — SEMI CONTADO */
|
||||
function calcularSemiContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
|
||||
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
|
||||
// Excel COTIZADOR!M45 — enganche 50% del precio final c/desc, neto del apartado
|
||||
const enganche = 0.5 * precioFinal - apartado
|
||||
// Excel COTIZADOR!M46 — 40% del precio final a mensualidades (= 1 - 0.5 - 0.1)
|
||||
const mensualidadesTotal = precioFinal * 0.4
|
||||
// Excel COTIZADOR!M47 — pago final 10% del precio final c/desc
|
||||
const pagoFinal = 0.1 * precioFinal
|
||||
return {
|
||||
tipo: 'SEMI_CONTADO',
|
||||
descuento,
|
||||
montoDescuento,
|
||||
precioFinal,
|
||||
enganche,
|
||||
numMensualidades: n,
|
||||
mensualidad: mensualidadesTotal / n,
|
||||
mensualidadesTotal,
|
||||
pagoFinal,
|
||||
}
|
||||
}
|
||||
|
||||
/** Precio de lista según si incluye cocina y closet (acabados) */
|
||||
/**
|
||||
* Precio de lista según si incluye cocina y closet (acabados).
|
||||
* En desarrollos de precio único la unidad trae `precioLista` y no hay
|
||||
* distinción de acabados.
|
||||
*/
|
||||
export function obtenerPrecioLista(unidad: Unidad, acabados: boolean): number {
|
||||
if (unidad.precioLista != null) return unidad.precioLista
|
||||
return acabados ? unidad.precioConAcabados : unidad.precioSinAcabados
|
||||
}
|
||||
|
||||
/**
|
||||
* Total pagado por el cliente en una propuesta:
|
||||
* apartado + enganche (neto del apartado) + mensualidades + pago final.
|
||||
* Devuelve null cuando el total no se puede conocer (plazo desconocido).
|
||||
*/
|
||||
export function totalPagado(p: PropuestaResultado, apartado: number): number {
|
||||
export function totalPagado(p: PropuestaResultado, apartado: number): number | null {
|
||||
if (p.notaMensualidades) return null
|
||||
return apartado + p.enganche + (p.mensualidadesTotal ?? 0) + p.pagoFinal
|
||||
}
|
||||
|
||||
@@ -162,15 +180,13 @@ export function indiceMenorTotal(totales: number[]): number {
|
||||
return indice
|
||||
}
|
||||
|
||||
/** Calcula las 4 propuestas comerciales */
|
||||
/** 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 ?? calcularNumMensualidades(unidad.fechaEntrega)
|
||||
return [
|
||||
calcularBancario(precioLista, apartado, descuentos.creditoBancario, n),
|
||||
calcularInfonavit(precioLista, apartado, descuentos.creditoInfonavit, n),
|
||||
calcularContado(precioLista, apartado, descuentos.contado, n),
|
||||
calcularSemiContado(precioLista, apartado, descuentos.semiContado, n),
|
||||
]
|
||||
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
|
||||
return params.propuestas.map((cfg) =>
|
||||
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { Unidad, Cliente } from '../types'
|
||||
import type { Unidad, Cliente, ParametrosDesarrollo } from '../types'
|
||||
import type { PropuestaResultado } from './calculations'
|
||||
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
|
||||
import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos'
|
||||
|
||||
export interface PdfInput {
|
||||
unidad: Unidad
|
||||
@@ -11,14 +12,11 @@ export interface PdfInput {
|
||||
precioLista: number
|
||||
propuestas: PropuestaResultado[]
|
||||
cliente: Cliente
|
||||
/** Parámetros del desarrollo; por defecto DIAGONAL */
|
||||
desarrollo?: ParametrosDesarrollo
|
||||
}
|
||||
|
||||
const TITULOS: Record<PropuestaResultado['tipo'], string> = {
|
||||
CREDITO_BANCARIO: 'CREDITO BANCARIO',
|
||||
CREDITO_INFONAVIT_COFINAVIT: 'CREDITO INFONAVIT-COFINAVIT',
|
||||
CONTADO: 'CONTADO',
|
||||
SEMI_CONTADO: 'SEMI CONTADO',
|
||||
}
|
||||
const formatoM2Pdf = (v: number | null): string => (v === null ? '—' : `${v} m2`)
|
||||
|
||||
const DISCLAIMER =
|
||||
'ESTA COTIZACION NO INCLUYE GASTOS DE ESCRITURACION. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL MONTO TOTAL DE ENGANCHE INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.'
|
||||
@@ -41,7 +39,8 @@ export interface DocumentoPdf {
|
||||
}
|
||||
|
||||
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */
|
||||
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente }: PdfInput): DocumentoPdf {
|
||||
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo }: PdfInput): DocumentoPdf {
|
||||
const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT]
|
||||
const doc = new jsPDF()
|
||||
const hoy = new Date()
|
||||
const limiteDocumentos = new Date(hoy.getTime() + 7 * 86400000)
|
||||
@@ -69,6 +68,9 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
|
||||
doc.setFontSize(10)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.text(formatoFechaLarga(hoy), 105, y, { align: 'center' })
|
||||
y += 5
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text(`DESARROLLO ${params.nombre.toUpperCase()}`, 105, y, { align: 'center' })
|
||||
y += 8
|
||||
|
||||
// 2. Tabla de unidad
|
||||
@@ -82,16 +84,16 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
|
||||
'MONTO APARTADO $ MN', 'CUOTA MANTENIMIENTO',
|
||||
]],
|
||||
body: [[
|
||||
`CASA ${unidad.codigo}`,
|
||||
`${params.prefijoUnidadPdf} ${unidad.codigo}`,
|
||||
unidad.modelo,
|
||||
acabados ? 'SI' : 'NO',
|
||||
params.precioUnico ? 'N/A' : acabados ? 'SI' : 'NO',
|
||||
formatoMoneda(precioLista),
|
||||
unidad.status,
|
||||
`${unidad.supConstruccion} m2`,
|
||||
`${unidad.supExcedente} m2`,
|
||||
`${unidad.supEstacionamiento} m2`,
|
||||
formatoM2Pdf(unidad.supConstruccion),
|
||||
formatoM2Pdf(unidad.supExcedente),
|
||||
formatoM2Pdf(unidad.supEstacionamiento),
|
||||
formatoMoneda(apartado),
|
||||
'1,500 Aprox.',
|
||||
params.cuotaMantenimiento,
|
||||
]],
|
||||
})
|
||||
y = finalY(doc) + 8
|
||||
@@ -103,8 +105,9 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
|
||||
y += 4
|
||||
|
||||
propuestas.forEach((p, i) => {
|
||||
const mensualidades =
|
||||
p.mensualidad !== null && p.mensualidadesTotal !== null
|
||||
const mensualidades = p.notaMensualidades
|
||||
? { pagos: `— (${p.notaMensualidades})`, total: '—' }
|
||||
: p.mensualidad !== null && p.mensualidadesTotal !== null
|
||||
? {
|
||||
pagos: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
|
||||
total: formatoMoneda(p.mensualidadesTotal),
|
||||
@@ -116,7 +119,7 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 8 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [[`PROPUESTA COMERCIAL #${i + 1} — ${TITULOS[p.tipo]}`, '%', 'PAGOS MENSUALES', 'TOTAL']],
|
||||
head: [[`PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`, '%', 'PAGOS MENSUALES', 'TOTAL']],
|
||||
body: [
|
||||
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
|
||||
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
|
||||
|
||||
Reference in New Issue
Block a user