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:
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user