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:
@@ -43,7 +43,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
|
||||
import { DESARROLLOS } from '../services/cotizacion.service.js'
|
||||
import { DESARROLLOS, calcularNumMensualidades } from '../services/cotizacion.service.js'
|
||||
|
||||
/**
|
||||
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
|
||||
@@ -260,7 +260,7 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
||||
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sin numMensualidades usa el default del esquema (14)', async () => {
|
||||
it('sin numMensualidades usa el default (14), acotado a los meses que faltan para la entrega', async () => {
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
|
||||
])
|
||||
@@ -268,14 +268,15 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
const esperadas = Math.min(14, calcularNumMensualidades('2027-10-08'))
|
||||
const respuesta = json.mock.calls[0]?.[0] as {
|
||||
numMensualidades: number
|
||||
mensualidad: number
|
||||
esquema: { numMensualidades: number; mensualidad: number; baseDiferible: number }
|
||||
}
|
||||
expect(respuesta.numMensualidades).toBe(14)
|
||||
expect(respuesta.numMensualidades).toBe(esperadas)
|
||||
expect(respuesta.numMensualidades).toBe(respuesta.esquema.numMensualidades)
|
||||
expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / 14, 6)
|
||||
expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / esperadas, 6)
|
||||
})
|
||||
|
||||
it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => {
|
||||
@@ -299,6 +300,24 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('acota las mensualidades a los meses que faltan para la entrega estimada de la unidad', async () => {
|
||||
// La captura de obra marca la entrega de U-101 a ~6 meses (fija vs hoy)
|
||||
const entrega = new Date(Date.now() + 6 * 30.4 * 86400000).toISOString().slice(0, 10)
|
||||
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
|
||||
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: entrega },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 20 }), res)
|
||||
|
||||
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } }
|
||||
expect(respuesta.numMensualidades).toBe(6)
|
||||
expect(respuesta.esquema.numMensualidades).toBe(6)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ numMensualidades: 6 }),
|
||||
})
|
||||
})
|
||||
|
||||
it('persiste y devuelve las opciones de crédito que manda el cotizador', async () => {
|
||||
const opcionesCredito = JSON.stringify({
|
||||
elegida: 'CONTADO',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { getUnidadLocal } from '../services/unidades-local.service.js'
|
||||
import { obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||
import { calcularNumMensualidades, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||
import {
|
||||
calcularEsquemaFrasa,
|
||||
MENSUALIDADES_DEFAULT,
|
||||
@@ -109,7 +109,12 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
// El API RECALCULA el esquema server-side con el precio de lista de la
|
||||
// unidad: no se confía en los montos que manda el cliente.
|
||||
const apartado = body.apartado ?? APARTADO_DEFAULT
|
||||
const numMensualidades = body.numMensualidades ?? MENSUALIDADES_DEFAULT
|
||||
// El diferimiento no puede rebasar la entrega estimada de la unidad (captura
|
||||
// de obra): se acota a los meses que faltan (36 sigue siendo el tope global)
|
||||
const topeMensualidades = unidad.fechaEntrega
|
||||
? Math.min(MENSUALIDADES_MAX, Math.max(MENSUALIDADES_MIN, calcularNumMensualidades(unidad.fechaEntrega)))
|
||||
: MENSUALIDADES_MAX
|
||||
const numMensualidades = Math.min(body.numMensualidades ?? MENSUALIDADES_DEFAULT, topeMensualidades)
|
||||
const escrituracionDiferida = body.escrituracionDiferida ?? false
|
||||
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
||||
const esquema: EsquemaFrasaResultado = calcularEsquemaFrasa({
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/), {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user