feat: esquema unico de cotizacion FRASA 2026 (reemplaza las 4 propuestas)

Flujo aprobado por el cliente (28 sep 2026), porcentajes globales para
todos los desarrollos:
- Apartado: cantidad libre (sin minimo), se acredita dentro del enganche
  de firma; el excedente se descuenta del enganche
- Enganche de firma: 2.5% del precio (saldo a firma = max(0, 2.5% - apartado))
- Enganche: 7.5%, diferible en N mensualidades (N a eleccion del vendedor, 1-36)
- Gastos de escrituracion: 5.5%, pago unico a la firma o diferido con el
  enganche (toggle)

- lib/esquema-frasa.ts (frontend) + cotizacion-esquema.service.ts (backend):
  modulos puros espejo, caso dorado M/apartado 5k/N=14 en tests de ambos
- Migracion 20260928120000: 7 columnas nuevas en Cotizacion (esquema,
  saldoFirma, engancheDiferible, escrituracion, etc.); legacy se rellena
  para compatibilidad de reportes (tipoPropuesta=FRASA_2026)
- UI: panel de linea de tiempo (Hoy -> Firma -> Mensualidades) sustituye a
  propuestas/comparadores; PDF y WhatsApp con el esquema unico
- API recalcula server-side; smoke end-to-end verificado contra inventario
- 743 tests API + 128 cotizador en verde
This commit is contained in:
2026-09-28 13:00:22 +00:00
parent 23d288d0aa
commit 61d1b84fc3
23 changed files with 1160 additions and 1051 deletions

View File

@@ -1,31 +1,22 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { useCotizacion } from './useCotizacion'
import { reglasDesdeApi, SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas'
import { DESARROLLOS } from '../data/desarrollos'
import { APARTADO_ESQUEMA_DEFAULT, useCotizacion } from './useCotizacion'
import { SIN_REGLAS } from '../lib/reglas'
import { MENSUALIDADES_DEFAULT, calcularEsquemaFrasa } from '../lib/esquema-frasa'
import { INVENTARIO_SEED } from '../data/unidades'
/**
* Reglas comerciales configurables en el cotizador: las propuestas desactivadas
* por el admin dejan de ofrecerse y, si la elegida se desactiva, se avisa y se
* cae a la primera vigente.
* Estado central con el esquema único FRASA 2026: apartado libre (default
* $15,000), mensualidades del diferimiento (default 14) y escrituración a la
* firma o diferida. Ya no hay propuestas: el resultado sale de
* calcularEsquemaFrasa (mismo motor que el API).
*/
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
function reglasDiagonales(activa: (tipo: string) => boolean): ReglasDesarrollo {
return reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => ({ ...p, activa: activa(p.tipo) })),
},
})
}
function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
const hook = renderHook(({ reglas }) => useCotizacion(reglas), {
initialProps: { reglas: reglas as ReglasDesarrollo },
})
function montar() {
const hook = renderHook(() => useCotizacion(SIN_REGLAS))
act(() => {
hook.result.current.setUnidad(unidadDiagonal)
})
@@ -34,75 +25,128 @@ function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
afterEach(cleanup)
describe('useCotizacion y las reglas del admin', () => {
it('sin reglas ofrece las 4 propuestas de siempre', () => {
describe('useCotizacion — esquema único FRASA 2026', () => {
it('arranca con apartado $15,000, 14 mensualidades y escrituración a la firma', () => {
const { result } = montar()
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
])
expect(result.current.avisoPropuestaDesactivada).toBeNull()
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
expect(result.current.apartadoEfectivo).toBe(15000)
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
})
it('una propuesta desactivada no se ofrece ni se puede elegir', () => {
const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO'))
it('el resultado cuadra con calcularEsquemaFrasa sobre el precio de lista', () => {
const { result } = montar()
const { unidad, acabados } = result.current
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
})
it('si el admin desactiva la propuesta elegida, avisa y cae a la primera vigente', () => {
const { result, rerender } = montar()
// El vendedor elige Contado (índice 2)
act(() => {
result.current.setPropuestaIndex(2)
const esperado = calcularEsquemaFrasa({
precio: result.current.precioLista,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: false,
})
expect(result.current.propuestas[2]?.tipo).toBe('CONTADO')
// El admin la desactiva (llegan reglas nuevas)
rerender({ reglas: reglasDiagonales((tipo) => tipo !== 'CONTADO') })
expect(result.current.propuestas.map((p) => p.tipo)).not.toContain('CONTADO')
expect(result.current.propuestaIndex).toBe(0)
expect(result.current.avisoPropuestaDesactivada).toContain('Contado')
expect(result.current.avisoPropuestaDesactivada).toContain('Crédito Bancario')
expect(result.current.resultado).toEqual(esperado)
// El esquema siempre liquida el 15.5% del precio (2.5 + 7.5 + 5.5)
expect(result.current.resultado?.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
expect(unidad).toBe(unidadDiagonal)
expect(acabados).toBe(false)
})
it('los descuentos por defecto salen de las reglas configuradas', () => {
const { result } = montar(
reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.06 } : p,
),
},
}),
)
expect(result.current.descuentos.contado).toBe(0.06)
expect(result.current.descuentos.semiContado).toBe(0.01)
it('sin unidad no hay resultado (panel vacío)', () => {
const { result } = montar()
act(() => {
result.current.setUnidad(null)
})
expect(result.current.resultado).toBeNull()
expect(result.current.precioLista).toBe(0)
})
it('las reglas cambian los números de las propuestas', () => {
const { result } = montar(
reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.05 } : p,
),
},
}),
)
it('el apartado libre alimenta el saldo a firma; el excedente acredita al enganche', () => {
const { result } = montar()
const excedente = Math.round(result.current.precioLista * 0.025) + 5000
const bancario = result.current.propuestas.find((p) => p.tipo === 'CREDITO_BANCARIO')
const precioLista = result.current.precioLista
expect(bancario?.enganche).toBeCloseTo(0.05 * precioLista - result.current.apartadoEfectivo, 2)
act(() => {
result.current.setApartado(excedente)
})
const r = result.current.resultado!
expect(r.excedenteApartado).toBeGreaterThan(0)
expect(r.saldoFirma).toBe(0)
// El total no cambia: el excedente se mueve de concepto, no se cobra dos veces
expect(r.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
})
it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => {
const { result } = montar()
act(() => {
result.current.setNumMensualidades(12)
})
const r = result.current.resultado!
expect(r.numMensualidades).toBe(12)
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / 12, 6)
})
it('escrituración diferida: sube el diferible y baja el pago a la firma', () => {
const { result } = montar()
act(() => {
result.current.setEscrituracionDiferida(true)
})
const r = result.current.resultado!
expect(r.escrituracionDiferida).toBe(true)
expect(r.baseDiferible).toBeCloseTo(r.enganche + r.escrituracion, 6)
expect(r.pagoUnicoFirma).toBeCloseTo(r.saldoFirma, 6)
})
it('restaurar carga una cotización guardada del esquema (N y escrituración)', () => {
const { result } = montar()
act(() => {
result.current.restaurar({
desarrollo: 'DIAGONAL',
unidad: unidadDiagonal,
acabados: true,
apartado: 20000,
numMensualidades: 24,
escrituracionDiferida: true,
})
})
expect(result.current.acabados).toBe(true)
expect(result.current.apartadoEfectivo).toBe(20000)
expect(result.current.numMensualidades).toBe(24)
expect(result.current.escrituracionDiferida).toBe(true)
expect(result.current.resultado?.numMensualidades).toBe(24)
})
it('restaurar una cotización legacy (sin N ni escrituración) cae a los defaults', () => {
const { result } = montar()
act(() => {
result.current.restaurar({
desarrollo: 'DIAGONAL',
unidad: unidadDiagonal,
acabados: false,
apartado: 20000,
})
})
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
})
it('nueva reinicia apartado, mensualidades y escrituración, y limpia la unidad', () => {
const { result } = montar()
act(() => {
result.current.setApartado(40000)
result.current.setNumMensualidades(6)
result.current.setEscrituracionDiferida(true)
})
act(() => {
result.current.nueva()
})
expect(result.current.unidad).toBeNull()
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
})
})