feat(cotizador): mensualidades del diferimiento = meses faltantes para la entrega estimada de la unidad (captura de obra)

This commit is contained in:
2026-09-29 20:11:24 +00:00
parent 1c1e6b04e4
commit 4e3ce67ae7
6 changed files with 84 additions and 12 deletions

View File

@@ -89,12 +89,13 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3']) expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
}) })
it('completa fechaEntrega desde la captura: entrega programada > habitabilidad > inicio de obra', async () => { it('completa fechaEntrega desde la captura: reprogramada > programada > habitabilidad > inicio de obra', async () => {
prismaMock.unidad.findMany.mockResolvedValue([ prismaMock.unidad.findMany.mockResolvedValue([
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }), fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }), fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'S-1', desarrollo: 'SENDA' }), fila({ codigo: 'S-1', desarrollo: 'SENDA' }),
fila({ codigo: 'S-2', desarrollo: 'SENDA' }), fila({ codigo: 'S-2', desarrollo: 'SENDA' }),
fila({ codigo: 'S-3', desarrollo: 'SENDA' }),
]) ])
prismaMock.capturaDepartamento.findMany.mockResolvedValue([ prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' }, { unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' },
@@ -104,11 +105,16 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
// Valor vacío / no-fecha: no cuenta como dato // Valor vacío / no-fecha: no cuenta como dato
{ unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' }, { unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' },
{ unidadCodigo: 'S-2', campo: 'entrega_programada_fecha', valor: 'pendiente' }, { unidadCodigo: 'S-2', campo: 'entrega_programada_fecha', valor: 'pendiente' },
// Reprogramada: manda sobre la programada cuando existe
{ unidadCodigo: 'U-1', campo: 'entrega_reprogramada_fecha', valor: '2028-02-15' },
// Reprogramada vacía: se ignora y gana la programada
{ unidadCodigo: 'S-3', campo: 'entrega_reprogramada_fecha', valor: '' },
{ unidadCodigo: 'S-3', campo: 'entrega_programada_fecha', valor: '2027-07-01' },
]) ])
const todas = await getUnidadesLocal() const todas = await getUnidadesLocal()
expect(todas.map((u) => u.fechaEntrega)).toEqual(['2027-11-20', null, '2027-05-05', null]) expect(todas.map((u) => u.fechaEntrega)).toEqual(['2028-02-15', null, '2027-05-05', null, '2027-07-01'])
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith( expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }), expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }),
) )

View File

@@ -14,12 +14,15 @@ import type { UnidadCotizable } from './cotizacion.service.js'
/** /**
* Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda, * Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda,
* en orden de prioridad: * en orden de prioridad (el primero con fecha válida gana):
* 1. `entrega_programada_fecha` — Fecha Programada Entrega Vivienda (ESCRITURACION) * 1. `entrega_reprogramada_fecha` — Fecha Reprogramada Entrega (la más reciente,
* 2. `habitabilidad` — fecha de habitabilidad (OBRA), la de DIAGONAL * cuando obra movió la entrega es la fecha que manda)
* 3. `inicio_obra_fecha` — último recurso (ESCRITURACION) * 2. `entrega_programada_fecha` — Fecha Programada Entrega Vivienda
* 3. `habitabilidad` — fecha de habitabilidad (OBRA), la de DIAGONAL
* 4. `inicio_obra_fecha` — último recurso (ESCRITURACION)
*/ */
export const CAMPOS_FECHA_ENTREGA = [ export const CAMPOS_FECHA_ENTREGA = [
'entrega_reprogramada_fecha',
'entrega_programada_fecha', 'entrega_programada_fecha',
'habitabilidad', 'habitabilidad',
'inicio_obra_fecha', 'inicio_obra_fecha',

View File

@@ -11,6 +11,8 @@ import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props { interface Props {
/** Esquema FRASA 2026 calculado (siempre presente: solo se renderiza con unidad) */ /** Esquema FRASA 2026 calculado (siempre presente: solo se renderiza con unidad) */
esquema: EsquemaFrasaResultado esquema: EsquemaFrasaResultado
/** Fecha de entrega estimada de la unidad (captura de obra); alimenta la nota del diferimiento */
fechaEntrega?: string | null
numMensualidades: number numMensualidades: number
onNumMensualidades: (n: number) => void onNumMensualidades: (n: number) => void
escrituracionDiferida: boolean escrituracionDiferida: boolean
@@ -31,6 +33,7 @@ const pasoMonto = 'mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]
*/ */
export function EsquemaPanel({ export function EsquemaPanel({
esquema, esquema,
fechaEntrega,
numMensualidades, numMensualidades,
onNumMensualidades, onNumMensualidades,
escrituracionDiferida, escrituracionDiferida,
@@ -145,7 +148,9 @@ 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]">
De {MENSUALIDADES_MIN} a {MENSUALIDADES_MAX}; cada una de {formatoMoneda(esquema.mensualidad)} {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)}`}
</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

@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react' import { act, cleanup, renderHook } from '@testing-library/react'
import { APARTADO_ESQUEMA_DEFAULT, useCotizacion } from './useCotizacion' import { APARTADO_ESQUEMA_DEFAULT, mensualidadesParaUnidad, useCotizacion } from './useCotizacion'
import { SIN_REGLAS } from '../lib/reglas' import { SIN_REGLAS } from '../lib/reglas'
import { MENSUALIDADES_DEFAULT, calcularEsquemaFrasa } from '../lib/esquema-frasa' import { MENSUALIDADES_DEFAULT, calcularEsquemaFrasa } from '../lib/esquema-frasa'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
@@ -27,7 +27,7 @@ afterEach(cleanup)
describe('useCotizacion — esquema único FRASA 2026', () => { describe('useCotizacion — esquema único FRASA 2026', () => {
it('arranca con apartado $15,000, 14 mensualidades y escrituración a la firma', () => { it('arranca con apartado $15,000, 14 mensualidades y escrituración a la firma', () => {
const { result } = montar() const { result } = renderHook(() => useCotizacion(SIN_REGLAS))
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT) expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
expect(result.current.apartadoEfectivo).toBe(15000) expect(result.current.apartadoEfectivo).toBe(15000)
@@ -35,6 +35,34 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
expect(result.current.escrituracionDiferida).toBe(false) expect(result.current.escrituracionDiferida).toBe(false)
}) })
it('elegir unidad con fecha de entrega: las mensualidades = meses que faltan para entregarla', () => {
const { result } = renderHook(() => useCotizacion(SIN_REGLAS))
act(() => {
result.current.setUnidad(unidadDiagonal)
})
expect(result.current.numMensualidades).toBe(mensualidadesParaUnidad(unidadDiagonal))
expect(result.current.numMensualidades).not.toBe(MENSUALIDADES_DEFAULT)
})
it('elegir unidad SIN fecha de entrega: cae al default del esquema (14)', () => {
const { result } = renderHook(() => useCotizacion(SIN_REGLAS))
act(() => {
result.current.setUnidad({ ...unidadDiagonal, fechaEntrega: null })
})
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
})
it('mensualidadesParaUnidad: acota a 1..36 y cae al default sin fecha', () => {
const hoy = new Date('2026-09-29T12:00:00')
const base = { ...unidadDiagonal }
expect(mensualidadesParaUnidad({ ...base, fechaEntrega: '2026-10-15' }, hoy)).toBe(1)
expect(mensualidadesParaUnidad({ ...base, fechaEntrega: '2035-12-31' }, hoy)).toBe(36)
expect(mensualidadesParaUnidad({ ...base, fechaEntrega: '2027-09-29' }, hoy)).toBe(12)
expect(mensualidadesParaUnidad(null, hoy)).toBe(MENSUALIDADES_DEFAULT)
})
it('el resultado cuadra con calcularEsquemaFrasa sobre el precio de lista', () => { it('el resultado cuadra con calcularEsquemaFrasa sobre el precio de lista', () => {
const { result } = montar() const { result } = montar()
const { unidad, acabados } = result.current const { unidad, acabados } = result.current
@@ -42,7 +70,7 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
const esperado = calcularEsquemaFrasa({ const esperado = calcularEsquemaFrasa({
precio: result.current.precioLista, precio: result.current.precioLista,
apartado: 15000, apartado: 15000,
numMensualidades: 14, numMensualidades: mensualidadesParaUnidad(unidadDiagonal),
escrituracionDiferida: false, escrituracionDiferida: false,
}) })
expect(result.current.resultado).toEqual(esperado) expect(result.current.resultado).toEqual(esperado)

View File

@@ -4,6 +4,8 @@ import { obtenerPrecioLista, resolverNumMensualidades } from '../lib/calculation
import { DESARROLLO_DEFAULT } from '../data/desarrollos' import { DESARROLLO_DEFAULT } from '../data/desarrollos'
import { import {
MENSUALIDADES_DEFAULT, MENSUALIDADES_DEFAULT,
MENSUALIDADES_MAX,
MENSUALIDADES_MIN,
calcularEsquemaFrasa, calcularEsquemaFrasa,
type EsquemaFrasaResultado, type EsquemaFrasaResultado,
} from '../lib/esquema-frasa' } from '../lib/esquema-frasa'
@@ -12,6 +14,23 @@ import { SIN_REGLAS, desarrolloEfectivo, type ReglasDesarrollo } from '../lib/re
/** Apartado con el que arranca (y se reinicia) el cotizador: cantidad libre */ /** Apartado con el que arranca (y se reinicia) el cotizador: cantidad libre */
export const APARTADO_ESQUEMA_DEFAULT = 15000 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.
*/
export function mensualidadesParaUnidad(u: Unidad | null, hoy: Date = new Date()): number {
const fecha = u?.fechaEntrega
if (!fecha) return MENSUALIDADES_DEFAULT
const entrega = new Date(`${fecha}T00:00:00`).getTime()
if (!Number.isFinite(entrega)) return MENSUALIDADES_DEFAULT
const sugeridas = Math.max(MENSUALIDADES_MIN, Math.round((entrega - hoy.getTime()) / MS_POR_MES))
return Math.min(MENSUALIDADES_MAX, sugeridas)
}
interface UseCotizacionResult { interface UseCotizacionResult {
desarrollo: string desarrollo: string
/** Cambia de desarrollo: limpia la unidad elegida */ /** Cambia de desarrollo: limpia la unidad elegida */
@@ -25,7 +44,9 @@ interface UseCotizacionResult {
setApartado: (v: number) => void setApartado: (v: number) => void
/** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */ /** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */
apartadoEfectivo: number apartadoEfectivo: number
/** Número de mensualidades del diferimiento (elección del vendedor, 1..36) */ /** Número de mensualidades del diferimiento (1..36): al elegir unidad se
* prellena con los meses que faltan para su entrega estimada; el vendedor
* puede ajustarla */
numMensualidades: number numMensualidades: number
setNumMensualidades: (n: number) => void setNumMensualidades: (n: number) => void
/** true = la escrituración (5.5%) se difiere con el enganche; false = se paga a la firma */ /** true = la escrituración (5.5%) se difiere con el enganche; false = se paga a la firma */
@@ -78,6 +99,14 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setAcabados(false) setAcabados(false)
} }
/** 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. */
const elegirUnidad = (u: Unidad | null) => {
setUnidad(u)
setNumMensualidades(mensualidadesParaUnidad(u))
}
const restaurar = (estado: EstadoRestaurar) => { const restaurar = (estado: EstadoRestaurar) => {
setDesarrolloState(estado.desarrollo) setDesarrolloState(estado.desarrollo)
setUnidad(estado.unidad) setUnidad(estado.unidad)
@@ -124,7 +153,7 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setDesarrollo, setDesarrollo,
params, params,
unidad, unidad,
setUnidad, setUnidad: elegirUnidad,
acabados, acabados,
setAcabados, setAcabados,
apartado, apartado,

View File

@@ -782,6 +782,7 @@ export default function CotizadorPage() {
<> <>
<EsquemaPanel <EsquemaPanel
esquema={resultado} esquema={resultado}
fechaEntrega={unidad?.fechaEntrega ?? null}
numMensualidades={numMensualidades} numMensualidades={numMensualidades}
onNumMensualidades={setNumMensualidades} onNumMensualidades={setNumMensualidades}
escrituracionDiferida={escrituracionDiferida} escrituracionDiferida={escrituracionDiferida}