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:
2026-08-22 04:31:21 +00:00
parent 05c44478ec
commit afb454b54c
33 changed files with 2074 additions and 338 deletions

View 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,
})
})
})

View File

@@ -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)
})

View File

@@ -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),
)
}

View File

@@ -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)],