feat(cotizador): tope de mensualidades = meses que faltan para la entrega (UI y API); nunca se rebasa la fecha de entrega

This commit is contained in:
2026-09-29 20:50:25 +00:00
parent 4e3ce67ae7
commit ed367582c3
7 changed files with 94 additions and 30 deletions

View File

@@ -13,6 +13,8 @@ interface Props {
esquema: EsquemaFrasaResultado
/** Fecha de entrega estimada de la unidad (captura de obra); alimenta la nota del diferimiento */
fechaEntrega?: string | null
/** Tope de mensualidades: los meses que faltan para la entrega (default 36 sin fecha) */
maxMensualidades?: number
numMensualidades: number
onNumMensualidades: (n: number) => void
escrituracionDiferida: boolean
@@ -28,17 +30,23 @@ const pasoMonto = 'mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]
* Panel único del esquema FRASA 2026 (reemplaza a las 4 propuestas):
* línea de tiempo Hoy → A la firma → Mensualidades, el detalle de conceptos
* (enganche de firma 2.5%, enganche 7.5%, escrituración 5.5%) y los controles
* del diferimiento: número de mensualidades (1..36) y si la escrituración se
* difiere con el enganche o se paga a la firma.
* del diferimiento: número de mensualidades (1..tope, donde el tope son los
* meses que faltan para la entrega estimada de la unidad) y si la escrituración
* se difiere con el enganche o se paga a la firma.
*/
export function EsquemaPanel({
esquema,
fechaEntrega,
maxMensualidades = MENSUALIDADES_MAX,
numMensualidades,
onNumMensualidades,
escrituracionDiferida,
onEscrituracionDiferida,
}: Props) {
// El diferimiento no puede rebasar la entrega de la vivienda: se ofrecen
// sólo las mensualidades dentro del tope (con fecha, meses que faltan)
const tope = Math.max(MENSUALIDADES_MIN, Math.min(MENSUALIDADES_MAX, maxMensualidades))
const mensualidadEfectiva = Math.min(numMensualidades, tope)
return (
<div className="space-y-6">
{/* Línea de tiempo del esquema */}
@@ -135,11 +143,11 @@ export function EsquemaPanel({
</label>
<select
id="mensualidades-esquema"
value={numMensualidades}
onChange={(e) => onNumMensualidades(Number(e.target.value))}
value={mensualidadEfectiva}
onChange={(e) => onNumMensualidades(Math.min(Number(e.target.value), tope))}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
>
{Array.from({ length: MENSUALIDADES_MAX - MENSUALIDADES_MIN + 1 }, (_, i) => MENSUALIDADES_MIN + i).map(
{Array.from({ length: tope - MENSUALIDADES_MIN + 1 }, (_, i) => MENSUALIDADES_MIN + i).map(
(n) => (
<option key={n} value={n}>
{n} mensualidades
@@ -149,8 +157,8 @@ export function EsquemaPanel({
</select>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{fechaEntrega
? `Sugeridas por la entrega estimada (${new Date(`${fechaEntrega}T00:00:00`).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}); cada una de ${formatoMoneda(esquema.mensualidad)}`
: `De ${MENSUALIDADES_MIN} a ${MENSUALIDADES_MAX}; cada una de ${formatoMoneda(esquema.mensualidad)}`}
? `Tope: la entrega estimada es el ${new Date(`${fechaEntrega}T00:00:00`).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })} (máx. ${tope} mensualidades); cada una de ${formatoMoneda(esquema.mensualidad)}`
: `De ${MENSUALIDADES_MIN} a ${tope}; cada una de ${formatoMoneda(esquema.mensualidad)}`}
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 pb-2.5 text-sm text-[#374151] dark:text-[#CBD5E1]">

View File

@@ -105,13 +105,24 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => {
const { result } = montar()
const n = Math.min(6, mensualidadesParaUnidad(unidadDiagonal))
act(() => {
result.current.setNumMensualidades(12)
result.current.setNumMensualidades(n)
})
const r = result.current.resultado!
expect(r.numMensualidades).toBe(12)
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / 12, 6)
expect(r.numMensualidades).toBe(n)
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / n, 6)
})
it('el ajuste de mensualidades respeta el tope de la entrega estimada', () => {
const { result } = montar()
const tope = mensualidadesParaUnidad(unidadDiagonal)
act(() => {
result.current.setNumMensualidades(tope + 10)
})
expect(result.current.numMensualidades).toBe(tope)
})
it('escrituración diferida: sube el diferible y baja el pago a la firma', () => {
@@ -126,8 +137,9 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
expect(r.pagoUnicoFirma).toBeCloseTo(r.saldoFirma, 6)
})
it('restaurar carga una cotización guardada del esquema (N y escrituración)', () => {
it('restaurar carga una cotización guardada (N acotado a la entrega y escrituración)', () => {
const { result } = montar()
const tope = mensualidadesParaUnidad(unidadDiagonal)
act(() => {
result.current.restaurar({
@@ -141,12 +153,13 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
})
expect(result.current.acabados).toBe(true)
expect(result.current.apartadoEfectivo).toBe(20000)
expect(result.current.numMensualidades).toBe(24)
// El diferimiento no puede rebasar la entrega: 24 se acota a las que faltan
expect(result.current.numMensualidades).toBe(tope)
expect(result.current.escrituracionDiferida).toBe(true)
expect(result.current.resultado?.numMensualidades).toBe(24)
expect(result.current.resultado?.numMensualidades).toBe(tope)
})
it('restaurar una cotización legacy (sin N ni escrituración) cae a los defaults', () => {
it('restaurar una cotización legacy (sin N ni escrituración): default acotado a la entrega', () => {
const { result } = montar()
act(() => {
@@ -157,7 +170,10 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
apartado: 20000,
})
})
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
// Sin N guardado cae al default (14), pero nunca rebasando la entrega
expect(result.current.numMensualidades).toBe(
Math.min(MENSUALIDADES_DEFAULT, mensualidadesParaUnidad(unidadDiagonal)),
)
expect(result.current.escrituracionDiferida).toBe(false)
})

View File

@@ -17,10 +17,11 @@ export const APARTADO_ESQUEMA_DEFAULT = 15000
const MS_POR_MES = 30.4 * 86400000
/**
* Mensualidades del diferimiento sugeridas para una unidad: los meses que
* faltan hasta su fecha de entrega estimada (la que vive en el módulo de
* captura: entrega programada/reprogramada de obra). Si la unidad no trae
* fecha, cae al default del esquema (14). El vendedor puede ajustarla después.
* Tope (y default) de mensualidades del diferimiento para una unidad: los
* meses que faltan hasta su fecha de entrega estimada (la que vive en el
* módulo de captura: entrega programada/reprogramada de obra). El diferimiento
* nunca puede rebasar la entrega de la vivienda. Si la unidad no trae fecha,
* cae al default global del esquema (14; tope 36).
*/
export function mensualidadesParaUnidad(u: Unidad | null, hoy: Date = new Date()): number {
const fecha = u?.fechaEntrega
@@ -101,18 +102,29 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
/** Elige unidad y, con ella, las mensualidades del diferimiento: los meses
* que faltan para la entrega estimada de esa vivienda (dato del módulo de
* captura). El vendedor puede ajustarlas después. */
* captura). El vendedor puede ajustarlas después, sin rebasar la entrega. */
const elegirUnidad = (u: Unidad | null) => {
setUnidad(u)
setNumMensualidades(mensualidadesParaUnidad(u))
}
/** Tope de mensualidades: las que faltan para la entrega (36 sin fecha) */
const topeMensualidades = () => mensualidadesParaUnidad(unidad)
const ajustarMensualidades = (n: number) => {
setNumMensualidades(Math.max(MENSUALIDADES_MIN, Math.min(n, topeMensualidades())))
}
const restaurar = (estado: EstadoRestaurar) => {
setDesarrolloState(estado.desarrollo)
setUnidad(estado.unidad)
setAcabados(estado.acabados)
setApartado(estado.apartado)
setNumMensualidades(estado.numMensualidades ?? MENSUALIDADES_DEFAULT)
// El diferimiento no puede rebasar la entrega: una cotización guardada con
// más mensualidades se acota a las que faltan hoy para esa unidad
setNumMensualidades(
Math.min(estado.numMensualidades ?? MENSUALIDADES_DEFAULT, mensualidadesParaUnidad(estado.unidad)),
)
setEscrituracionDiferida(estado.escrituracionDiferida ?? false)
}
@@ -160,7 +172,7 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setApartado,
apartadoEfectivo,
numMensualidades,
setNumMensualidades,
setNumMensualidades: ajustarMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
precioLista,

View File

@@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { MemoryRouter } from 'react-router-dom'
import CotizadorPage from './CotizadorPage'
import { INVENTARIO_SEED } from '../data/unidades'
import { mensualidadesParaUnidad } from '../hooks/useCotizacion'
/**
* Smoke del cierre de la cotización con el esquema único FRASA 2026:
@@ -70,7 +71,8 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
expect(await screen.findByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
expect(screen.getByText('Hoy')).toBeTruthy()
expect(screen.getByText('A la firma')).toBeTruthy()
expect(screen.getByRole('option', { name: '14 mensualidades' })).toBeTruthy()
// El tope de mensualidades son los meses que faltan para la entrega
expect(screen.getByRole('option', { name: `${mensualidadesParaUnidad(unidad)} mensualidades` })).toBeTruthy()
// Guardar con el servidor caído → folio local persistido en el dispositivo
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {

View File

@@ -4,7 +4,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import QRCode from 'qrcode'
import { useUnidades } from '../hooks/useUnidades'
import { useCotizacion } from '../hooks/useCotizacion'
import { useCotizacion, mensualidadesParaUnidad } from '../hooks/useCotizacion'
import { MENSUALIDADES_MAX } from '../lib/esquema-frasa'
import DarkModeToggle from '../components/DarkModeToggle'
import { SkeletonList } from "../components/Skeleton"
import toast from "react-hot-toast"
@@ -783,6 +784,7 @@ export default function CotizadorPage() {
<EsquemaPanel
esquema={resultado}
fechaEntrega={unidad?.fechaEntrega ?? null}
maxMensualidades={unidad ? mensualidadesParaUnidad(unidad) : MENSUALIDADES_MAX}
numMensualidades={numMensualidades}
onNumMensualidades={setNumMensualidades}
escrituracionDiferida={escrituracionDiferida}