feat(cotizador): apartado editable con mínimo $20,000 + cuota de mantenimiento por desarrollo

- Input de apartado con validación (mínimo 20k), clamp en el motor y bloqueo de PDF si inválido
- El cliente puede apartar con más; solo se recorre del enganche, el total no cambia
- cuotaMantenimiento tipada (number|null) en config por desarrollo; null → PENDIENTE
- Knowledge IA actualizada: apartado mínimo 20k (FAQ + proceso de venta) reindexada
- Tests 61/61 verdes (8 nuevos)
This commit is contained in:
2026-08-24 23:23:57 +00:00
parent 122c4d6102
commit 29c3947313
10 changed files with 122 additions and 19 deletions

View File

@@ -5,7 +5,9 @@ import {
obtenerPrecioLista,
totalPagado,
indiceMenorTotal,
validarApartado,
APARTADO_DEFAULT,
APARTADO_MINIMO,
} from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
import type { Unidad } from '../types'
@@ -14,11 +16,14 @@ import type { Unidad } from '../types'
const U101: Unidad = UNIDADES_SEED.find((u) => u.codigo === 'U-101')!
const N = 14
function calcular(descuentos = { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }) {
function calcular(
descuentos = { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 },
apartado = APARTADO_DEFAULT,
) {
return calcularPropuestas({
unidad: U101,
acabados: true,
apartado: APARTADO_DEFAULT,
apartado,
descuentos,
numMeses: N,
})
@@ -89,6 +94,51 @@ describe('comportamiento documentado del Excel', () => {
})
})
describe('apartado editable', () => {
const APARTADO_ALTO = 50000
it('recalcula el enganche neto del apartado elegido (bancario: 114475 − 50000)', () => {
const [bancario] = calcular(undefined, APARTADO_ALTO)
expect(bancario.enganche).toBeCloseTo(0.025 * 4579000 - APARTADO_ALTO, 2)
expect(bancario.enganche).toBeCloseTo(64475, 2)
})
it('el total pagado por propuesta no cambia: solo se recorre del enganche al apartado', () => {
const conDefault = calcular()
const conAlto = calcular(undefined, APARTADO_ALTO)
conAlto.forEach((p, i) => {
expect(totalPagado(p, APARTADO_ALTO)).toBeCloseTo(
totalPagado(conDefault[i], APARTADO_DEFAULT) ?? NaN,
2,
)
})
// Bancario: el total sigue siendo el precio de lista (desc 0)
expect(totalPagado(conAlto[0], APARTADO_ALTO)).toBeCloseTo(4579000, 2)
})
it('las mensualidades del remanente se mantienen (apartado + enganche es constante)', () => {
const [bancarioDefault] = calcular()
const [bancarioAlto] = calcular(undefined, APARTADO_ALTO)
expect(bancarioAlto.mensualidadesTotal).toBeCloseTo(bancarioDefault.mensualidadesTotal ?? NaN, 2)
})
})
describe('validarApartado', () => {
it('acepta el mínimo y montos superiores', () => {
expect(validarApartado(APARTADO_MINIMO)).toBeNull()
expect(validarApartado(50000)).toBeNull()
})
it('rechaza montos por debajo del mínimo', () => {
expect(validarApartado(19999)).toBe('El apartado mínimo es $20,000')
expect(validarApartado(0)).toBe('El apartado mínimo es $20,000')
})
it('rechaza valores no finitos', () => {
expect(validarApartado(NaN)).toBe('El apartado mínimo es $20,000')
})
})
describe('calcularNumMensualidades', () => {
it('calcula meses redondeados con 30.4 días por mes', () => {
const hoy = new Date('2026-08-20T00:00:00')

View File

@@ -20,6 +20,20 @@ import { DESARROLLO_DEFAULT, obtenerDesarrollo } from '../data/desarrollos'
export const APARTADO_DEFAULT = 20000
/** Monto mínimo de apartado aceptado por el cotizador */
export const APARTADO_MINIMO = 20000
/**
* Mensaje de error si el apartado capturado es inválido; null si es válido.
* La UI acota el valor al mínimo para los cálculos y muestra este mensaje.
*/
export function validarApartado(apartado: number): string | null {
if (!Number.isFinite(apartado) || apartado < APARTADO_MINIMO) {
return 'El apartado mínimo es $20,000'
}
return null
}
// Descuentos por defecto de DIAGONAL (fracción, no porcentaje).
// Para otros desarrollos usar descuentosDefault(desarrollo) de data/desarrollos.
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {

View File

@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest'
import { crearDocumentoPdf } from './pdf'
import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf'
import { calcularPropuestas, APARTADO_DEFAULT, DESCUENTOS_DEFAULT } from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters'
describe('crearDocumentoPdf', () => {
it('construye el documento ANEXO A sin errores y con el nombre correcto', () => {
@@ -28,3 +30,17 @@ describe('crearDocumentoPdf', () => {
expect(texto.length).toBeGreaterThan(0)
})
})
describe('cuota de mantenimiento', () => {
it('DIAGONAL tiene 1500 y los demás desarrollos null', () => {
expect(DESARROLLOS.DIAGONAL.cuotaMantenimiento).toBe(1500)
for (const id of LISTA_DESARROLLOS.filter((d) => d !== 'DIAGONAL')) {
expect(DESARROLLOS[id].cuotaMantenimiento).toBeNull()
}
})
it('textoCuotaMantenimiento: null => PENDIENTE, número => moneda', () => {
expect(textoCuotaMantenimiento(null)).toBe('PENDIENTE')
expect(textoCuotaMantenimiento(1500)).toBe(formatoMoneda(1500))
})
})

View File

@@ -18,6 +18,10 @@ export interface PdfInput {
const formatoM2Pdf = (v: number | null): string => (v === null ? '—' : `${v} m2`)
/** Texto de la cuota de mantenimiento para el PDF; null => 'PENDIENTE' */
export const textoCuotaMantenimiento = (cuota: number | null): string =>
cuota === null ? 'PENDIENTE' : formatoMoneda(cuota)
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.'
@@ -93,7 +97,7 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
formatoM2Pdf(unidad.supExcedente),
formatoM2Pdf(unidad.supEstacionamiento),
formatoMoneda(apartado),
params.cuotaMantenimiento,
textoCuotaMantenimiento(params.cuotaMantenimiento),
]],
})
y = finalY(doc) + 8