feat(cotizador): mensualidades del diferimiento = meses faltantes para la entrega estimada de la unidad (captura de obra)
This commit is contained in:
@@ -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] } } }),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user