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:
@@ -2,7 +2,7 @@
|
||||
|
||||
## ¿Cuál es el apartado y cómo se aplica?
|
||||
|
||||
El apartado estándar es de $20,000 MXN. No es un cargo adicional: se descuenta del enganche en las 4 propuestas del cotizador.
|
||||
El apartado mínimo es de $20,000 MXN, pero el cliente puede apartar con una cantidad mayor si lo desea. No es un cargo adicional: se descuenta del enganche en las 4 propuestas del cotizador.
|
||||
|
||||
## ¿Qué incluye el precio "con acabados"?
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Apartado
|
||||
|
||||
1. El cliente elige unidad y se genera la cotización con las 4 propuestas (Bancario, Infonavit-Cofinavit, Contado y Semi-contado).
|
||||
2. El cliente paga el apartado (por defecto $20,000 MXN) y firma la carta de apartado.
|
||||
2. El cliente paga el apartado (mínimo $20,000 MXN; puede pagar una cantidad mayor si lo desea) y firma la carta de apartado.
|
||||
3. A partir del apartado, el cliente tiene un plazo de hasta **15 días naturales** para acudir a las oficinas, formalizar la compra y firmar el contrato, además de realizar los trámites correspondientes.
|
||||
4. Si el cliente no continúa con el proceso dentro de ese plazo, el desarrollador puede disponer de la unidad apartada y comercializarla, sin responsabilidad alguna.
|
||||
5. En caso de cancelación y solicitud de devolución (o de liberación de la unidad por vencimiento del plazo), la devolución de la cantidad entregada —o la resultante en caso de penalización— tiene un plazo máximo de **30 días naturales**.
|
||||
|
||||
@@ -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