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:
@@ -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')
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user