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

@@ -12,7 +12,7 @@ const DIAGONAL: ParametrosDesarrollo = {
nombre: 'Diagonal',
precioUnico: false,
fuenteDeN: 'fechaEntrega',
cuotaMantenimiento: '1,500 Aprox.',
cuotaMantenimiento: 1500,
prefijoUnidadPdf: 'CASA',
propuestas: [
{
@@ -68,7 +68,7 @@ const ATELIER: ParametrosDesarrollo = {
precioUnico: true,
fuenteDeN: 'fijo', // inventario ya entregado: las mensualidades son 1 solo pago
nFijo: 1,
cuotaMantenimiento: 'PENDIENTE',
cuotaMantenimiento: null,
prefijoUnidadPdf: 'DEPTO',
propuestas: [
{
@@ -126,7 +126,7 @@ const ESTANZA: ParametrosDesarrollo = {
precioUnico: true,
fuenteDeN: 'fijo', // TERM. OBRA = 1 mes en todas las casas
nFijo: 1,
cuotaMantenimiento: 'PENDIENTE',
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [
{
@@ -181,7 +181,7 @@ const SENDA: ParametrosDesarrollo = {
nombre: 'Senda',
precioUnico: true,
fuenteDeN: 'fechaEntrega', // unidades sin fecha en TERM => N null (mensualidades "—")
cuotaMantenimiento: 'PENDIENTE DE DEFINIR',
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [
{
@@ -236,7 +236,7 @@ const SOLANA: ParametrosDesarrollo = {
nombre: 'Solana',
precioUnico: true,
fuenteDeN: 'porUnidad', // MESES PARA FIRMA APROX del inventario (1–4)
cuotaMantenimiento: 'PENDIENTE',
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [
{

View File

@@ -2,9 +2,11 @@ import { useMemo, useState } from 'react'
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
import {
APARTADO_DEFAULT,
APARTADO_MINIMO,
calcularPropuestas,
obtenerPrecioLista,
resolverNumMensualidades,
validarApartado,
type PropuestaResultado,
} from '../lib/calculations'
import { DESARROLLO_DEFAULT, descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
@@ -20,6 +22,10 @@ interface UseCotizacionResult {
setAcabados: (v: boolean) => void
apartado: number
setApartado: (v: number) => void
/** Apartado aplicado a los cálculos (acotado al mínimo cuando la captura es inválida) */
apartadoEfectivo: number
/** Mensaje de error de la captura del apartado; null si es válida */
errorApartado: string | null
descuentos: DescuentosPropuestas
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void
precioLista: number
@@ -58,10 +64,14 @@ export function useCotizacion(): UseCotizacionResult {
[unidad, acabados],
)
// Capturas por debajo del mínimo no se usan: los cálculos se acotan
const errorApartado = validarApartado(apartado)
const apartadoEfectivo = Math.max(apartado, APARTADO_MINIMO)
const propuestas = useMemo(() => {
if (!unidad) return []
return calcularPropuestas({ unidad, acabados, apartado, descuentos, desarrollo })
}, [unidad, acabados, apartado, descuentos, desarrollo])
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo })
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo])
return {
desarrollo,
@@ -73,6 +83,8 @@ export function useCotizacion(): UseCotizacionResult {
setAcabados,
apartado,
setApartado,
apartadoEfectivo,
errorApartado,
descuentos,
setDescuento,
precioLista,

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

View File

@@ -43,6 +43,8 @@ export default function CotizadorPage() {
setAcabados,
apartado,
setApartado,
apartadoEfectivo,
errorApartado,
setDescuento,
precioLista,
numMeses,
@@ -63,7 +65,7 @@ export default function CotizadorPage() {
})
const nombreCliente = watch('nombre') ?? ''
const puedeGenerarPdf = Boolean(unidad) && nombreCliente.trim().length >= 3
const puedeGenerarPdf = Boolean(unidad) && nombreCliente.trim().length >= 3 && !errorApartado
const handleGenerarPdf = () => {
if (!unidad) return
@@ -72,7 +74,7 @@ export default function CotizadorPage() {
email: watch('email') || undefined,
telefono: watch('telefono') || undefined,
}
generarPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo: params })
generarPdf({ unidad, acabados, apartado: apartadoEfectivo, precioLista, propuestas, cliente, desarrollo: params })
}
return (
@@ -189,8 +191,13 @@ export default function CotizadorPage() {
const v = Number(e.target.value)
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
}}
className={campoInput}
className={errorApartado ? campoInputError : campoInput}
/>
{errorApartado ? (
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
) : (
<p className="mt-1 text-xs text-neutral-500">{formatoMoneda(apartado)}</p>
)}
</div>
<div>
@@ -272,7 +279,7 @@ export default function CotizadorPage() {
</div>
<ComparadorPropuestas
precioLista={precioLista}
apartado={apartado}
apartado={apartadoEfectivo}
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>

View File

@@ -84,8 +84,8 @@ export interface ParametrosDesarrollo {
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
/** N fijo cuando fuenteDeN === 'fijo' (entrega inmediata / term. obra) */
nFijo?: number
/** Texto de cuota de mantenimiento para el PDF */
cuotaMantenimiento: string
/** Cuota mensual de mantenimiento (MXN); null => se muestra "PENDIENTE" */
cuotaMantenimiento: number | null
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
prefijoUnidadPdf: string
propuestas: PropuestaConfig[]