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

@@ -43,7 +43,7 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js' 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 * 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() 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([ prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' }, { unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
]) ])
@@ -268,14 +268,15 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
await crearCotizacion(reqFake(bodyBase), res) await crearCotizacion(reqFake(bodyBase), res)
const esperadas = Math.min(14, calcularNumMensualidades('2027-10-08'))
const respuesta = json.mock.calls[0]?.[0] as { const respuesta = json.mock.calls[0]?.[0] as {
numMensualidades: number numMensualidades: number
mensualidad: number mensualidad: number
esquema: { numMensualidades: number; mensualidad: number; baseDiferible: 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.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 () => { 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() 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 () => { it('persiste y devuelve las opciones de crédito que manda el cotizador', async () => {
const opcionesCredito = JSON.stringify({ const opcionesCredito = JSON.stringify({
elegida: 'CONTADO', elegida: 'CONTADO',

View File

@@ -1,7 +1,7 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import { getUnidadLocal } from '../services/unidades-local.service.js' import { getUnidadLocal } from '../services/unidades-local.service.js'
import { obtenerPrecioLista } from '../services/cotizacion.service.js' import { calcularNumMensualidades, obtenerPrecioLista } from '../services/cotizacion.service.js'
import { import {
calcularEsquemaFrasa, calcularEsquemaFrasa,
MENSUALIDADES_DEFAULT, 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 // 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. // unidad: no se confía en los montos que manda el cliente.
const apartado = body.apartado ?? APARTADO_DEFAULT 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 escrituracionDiferida = body.escrituracionDiferida ?? false
const precioLista = obtenerPrecioLista(unidad, body.acabados) const precioLista = obtenerPrecioLista(unidad, body.acabados)
const esquema: EsquemaFrasaResultado = calcularEsquemaFrasa({ const esquema: EsquemaFrasaResultado = calcularEsquemaFrasa({

View File

@@ -13,6 +13,8 @@ interface Props {
esquema: EsquemaFrasaResultado esquema: EsquemaFrasaResultado
/** Fecha de entrega estimada de la unidad (captura de obra); alimenta la nota del diferimiento */ /** Fecha de entrega estimada de la unidad (captura de obra); alimenta la nota del diferimiento */
fechaEntrega?: string | null fechaEntrega?: string | null
/** Tope de mensualidades: los meses que faltan para la entrega (default 36 sin fecha) */
maxMensualidades?: number
numMensualidades: number numMensualidades: number
onNumMensualidades: (n: number) => void onNumMensualidades: (n: number) => void
escrituracionDiferida: boolean 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): * Panel único del esquema FRASA 2026 (reemplaza a las 4 propuestas):
* línea de tiempo Hoy → A la firma → Mensualidades, el detalle de conceptos * 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 * (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 * del diferimiento: número de mensualidades (1..tope, donde el tope son los
* difiere con el enganche o se paga a la firma. * 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({ export function EsquemaPanel({
esquema, esquema,
fechaEntrega, fechaEntrega,
maxMensualidades = MENSUALIDADES_MAX,
numMensualidades, numMensualidades,
onNumMensualidades, onNumMensualidades,
escrituracionDiferida, escrituracionDiferida,
onEscrituracionDiferida, onEscrituracionDiferida,
}: Props) { }: 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Línea de tiempo del esquema */} {/* Línea de tiempo del esquema */}
@@ -135,11 +143,11 @@ export function EsquemaPanel({
</label> </label>
<select <select
id="mensualidades-esquema" id="mensualidades-esquema"
value={numMensualidades} value={mensualidadEfectiva}
onChange={(e) => onNumMensualidades(Number(e.target.value))} 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]" 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) => ( (n) => (
<option key={n} value={n}> <option key={n} value={n}>
{n} mensualidades {n} mensualidades
@@ -149,8 +157,8 @@ export function EsquemaPanel({
</select> </select>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]"> <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{fechaEntrega {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)}` ? `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 ${MENSUALIDADES_MAX}; cada una de ${formatoMoneda(esquema.mensualidad)}`} : `De ${MENSUALIDADES_MIN} a ${tope}; cada una de ${formatoMoneda(esquema.mensualidad)}`}
</p> </p>
</div> </div>
<label className="flex cursor-pointer items-center gap-2 pb-2.5 text-sm text-[#374151] dark:text-[#CBD5E1]"> <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', () => { it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => {
const { result } = montar() const { result } = montar()
const n = Math.min(6, mensualidadesParaUnidad(unidadDiagonal))
act(() => { act(() => {
result.current.setNumMensualidades(12) result.current.setNumMensualidades(n)
}) })
const r = result.current.resultado! const r = result.current.resultado!
expect(r.numMensualidades).toBe(12) expect(r.numMensualidades).toBe(n)
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / 12, 6) 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', () => { 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) 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 { result } = montar()
const tope = mensualidadesParaUnidad(unidadDiagonal)
act(() => { act(() => {
result.current.restaurar({ result.current.restaurar({
@@ -141,12 +153,13 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
}) })
expect(result.current.acabados).toBe(true) expect(result.current.acabados).toBe(true)
expect(result.current.apartadoEfectivo).toBe(20000) 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.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() const { result } = montar()
act(() => { act(() => {
@@ -157,7 +170,10 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
apartado: 20000, 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) 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 const MS_POR_MES = 30.4 * 86400000
/** /**
* Mensualidades del diferimiento sugeridas para una unidad: los meses que * Tope (y default) de mensualidades del diferimiento para una unidad: los
* faltan hasta su fecha de entrega estimada (la que vive en el módulo de * meses que faltan hasta su fecha de entrega estimada (la que vive en el
* captura: entrega programada/reprogramada de obra). Si la unidad no trae * módulo de captura: entrega programada/reprogramada de obra). El diferimiento
* fecha, cae al default del esquema (14). El vendedor puede ajustarla después. * 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 { export function mensualidadesParaUnidad(u: Unidad | null, hoy: Date = new Date()): number {
const fecha = u?.fechaEntrega 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 /** 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 * 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) => { const elegirUnidad = (u: Unidad | null) => {
setUnidad(u) setUnidad(u)
setNumMensualidades(mensualidadesParaUnidad(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) => { const restaurar = (estado: EstadoRestaurar) => {
setDesarrolloState(estado.desarrollo) setDesarrolloState(estado.desarrollo)
setUnidad(estado.unidad) setUnidad(estado.unidad)
setAcabados(estado.acabados) setAcabados(estado.acabados)
setApartado(estado.apartado) 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) setEscrituracionDiferida(estado.escrituracionDiferida ?? false)
} }
@@ -160,7 +172,7 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setApartado, setApartado,
apartadoEfectivo, apartadoEfectivo,
numMensualidades, numMensualidades,
setNumMensualidades, setNumMensualidades: ajustarMensualidades,
escrituracionDiferida, escrituracionDiferida,
setEscrituracionDiferida, setEscrituracionDiferida,
precioLista, precioLista,

View File

@@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import CotizadorPage from './CotizadorPage' import CotizadorPage from './CotizadorPage'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
import { mensualidadesParaUnidad } from '../hooks/useCotizacion'
/** /**
* Smoke del cierre de la cotización con el esquema único FRASA 2026: * 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(await screen.findByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
expect(screen.getByText('Hoy')).toBeTruthy() expect(screen.getByText('Hoy')).toBeTruthy()
expect(screen.getByText('A la firma')).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 // Guardar con el servidor caído → folio local persistido en el dispositivo
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), { fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {

View File

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