diff --git a/api-gateway/knowledge/faq-vendedores.md b/api-gateway/knowledge/faq-vendedores.md index fb81b08..6d9830f 100644 --- a/api-gateway/knowledge/faq-vendedores.md +++ b/api-gateway/knowledge/faq-vendedores.md @@ -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"? diff --git a/api-gateway/knowledge/proceso-venta-apartado.md b/api-gateway/knowledge/proceso-venta-apartado.md index 73f338b..9ae135f 100644 --- a/api-gateway/knowledge/proceso-venta-apartado.md +++ b/api-gateway/knowledge/proceso-venta-apartado.md @@ -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**. diff --git a/apps/cotizador/src/data/desarrollos.ts b/apps/cotizador/src/data/desarrollos.ts index 33f303f..d949c36 100644 --- a/apps/cotizador/src/data/desarrollos.ts +++ b/apps/cotizador/src/data/desarrollos.ts @@ -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: [ { diff --git a/apps/cotizador/src/hooks/useCotizacion.ts b/apps/cotizador/src/hooks/useCotizacion.ts index d188b6c..c27b754 100644 --- a/apps/cotizador/src/hooks/useCotizacion.ts +++ b/apps/cotizador/src/hooks/useCotizacion.ts @@ -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, diff --git a/apps/cotizador/src/lib/calculations.test.ts b/apps/cotizador/src/lib/calculations.test.ts index a47eaf0..59dd413 100644 --- a/apps/cotizador/src/lib/calculations.test.ts +++ b/apps/cotizador/src/lib/calculations.test.ts @@ -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') diff --git a/apps/cotizador/src/lib/calculations.ts b/apps/cotizador/src/lib/calculations.ts index d8404a7..5fe078e 100644 --- a/apps/cotizador/src/lib/calculations.ts +++ b/apps/cotizador/src/lib/calculations.ts @@ -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 = { diff --git a/apps/cotizador/src/lib/pdf.test.ts b/apps/cotizador/src/lib/pdf.test.ts index dec835a..1b7c644 100644 --- a/apps/cotizador/src/lib/pdf.test.ts +++ b/apps/cotizador/src/lib/pdf.test.ts @@ -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)) + }) +}) diff --git a/apps/cotizador/src/lib/pdf.ts b/apps/cotizador/src/lib/pdf.ts index 26cf1fa..2235490 100644 --- a/apps/cotizador/src/lib/pdf.ts +++ b/apps/cotizador/src/lib/pdf.ts @@ -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 diff --git a/apps/cotizador/src/pages/CotizadorPage.tsx b/apps/cotizador/src/pages/CotizadorPage.tsx index ae33fbb..f55b1d6 100644 --- a/apps/cotizador/src/pages/CotizadorPage.tsx +++ b/apps/cotizador/src/pages/CotizadorPage.tsx @@ -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 ? ( +
{errorApartado}
+ ) : ( +{formatoMoneda(apartado)}
+ )}