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,25 +1,20 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
import {
APARTADO_DEFAULT,
calcularPropuestas,
obtenerPrecioLista,
resolverNumMensualidades,
validarEnganchePct,
type PropuestaResultado,
} from '../lib/calculations'
import { useMemo, useState } from 'react'
import type { Unidad, ParametrosDesarrollo } from '../types'
import { obtenerPrecioLista, resolverNumMensualidades } from '../lib/calculations'
import { DESARROLLO_DEFAULT } from '../data/desarrollos'
import {
SIN_REGLAS,
descuentosDefaultEfectivo,
desarrolloEfectivo,
propuestasVigentes,
type ReglasDesarrollo,
} from '../lib/reglas'
MENSUALIDADES_DEFAULT,
calcularEsquemaFrasa,
type EsquemaFrasaResultado,
} from '../lib/esquema-frasa'
import { SIN_REGLAS, desarrolloEfectivo, type ReglasDesarrollo } from '../lib/reglas'
/** Apartado con el que arranca (y se reinicia) el cotizador: cantidad libre */
export const APARTADO_ESQUEMA_DEFAULT = 15000
interface UseCotizacionResult {
desarrollo: string
/** Cambia de desarrollo: limpia la unidad elegida y reinicia los descuentos */
/** Cambia de desarrollo: limpia la unidad elegida */
setDesarrollo: (id: string) => void
params: ParametrosDesarrollo
unidad: Unidad | null
@@ -30,25 +25,17 @@ interface UseCotizacionResult {
setApartado: (v: number) => void
/** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */
apartadoEfectivo: number
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */
enganchePct: number | null
setEnganchePct: (v: number | null) => void
/** Mensaje de error del enganche capturado; null si es válido */
errorEnganche: string | null
descuentos: DescuentosPropuestas
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void
/** Índice de la propuesta elegida (para guardar/compartir) */
propuestaIndex: number
setPropuestaIndex: (i: number) => void
/** Número de mensualidades del diferimiento (elección del vendedor, 1..36) */
numMensualidades: number
setNumMensualidades: (n: number) => void
/** true = la escrituración (5.5%) se difiere con el enganche; false = se paga a la firma */
escrituracionDiferida: boolean
setEscrituracionDiferida: (v: boolean) => void
precioLista: number
/** Número de mensualidades elegido por el vendedor; null = usa el calculado */
numMensualidades: number | null
setNumMensualidades: (n: number | null) => void
/** Número efectivo de mensualidades (la elegida o la calculada) */
/** Esquema FRASA 2026 calculado; null mientras no hay unidad elegida */
resultado: EsquemaFrasaResultado | null
/** Meses restantes para la entrega de la unidad (ficha técnica); null si no se pueden resolver */
numMeses: number | null
propuestas: PropuestaResultado[]
/** Aviso cuando el admin desactivó la propuesta que estaba elegida; null si no aplica */
avisoPropuestaDesactivada: string | null
/** Restaura el estado completo desde una cotización guardada (historial) */
restaurar: (estado: EstadoRestaurar) => void
/** Reinicia la cotización (botón "Nueva") */
@@ -61,60 +48,34 @@ export interface EstadoRestaurar {
unidad: Unidad
acabados: boolean
apartado: number
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */
descuento?: number
propuestaIndex?: number
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */
numMensualidades?: number | null
/** Mensualidades del diferimiento guardadas (default 14) */
numMensualidades?: number
/** Escrituración diferida guardada (default false) */
escrituracionDiferida?: boolean
}
/**
* Estado central de la cotización: desarrollo, unidad, acabados, apartado y
* descuentos. Las reglas comerciales llegan del API (con respaldo en las
* constantes): si el admin desactiva una propuesta, deja de ofrecerse y, si era
* la elegida, se avisa y se cae a la primera vigente.
* Estado central de la cotización con el esquema único FRASA 2026:
* desarrollo, unidad, acabados, apartado libre, mensualidades del
* diferimiento (1..36, default 14) y si la escrituración se difiere o se paga
* a la firma. El resultado sale de calcularEsquemaFrasa (módulo puro, mismo
* motor que el API). Ya no hay propuestas: descuentos, enganche en % e índice
* de propuesta desaparecieron con las 4 propuestas.
*/
export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult {
const [desarrollo, setDesarrolloState] = useState<string>(DESARROLLO_DEFAULT)
const [unidad, setUnidad] = useState<Unidad | null>(null)
const [acabados, setAcabados] = useState(false)
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT)
const [enganchePct, setEnganchePct] = useState<number | null>(null)
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(
descuentosDefaultEfectivo(reglas, DESARROLLO_DEFAULT),
)
const [propuestaIndex, setPropuestaIndex] = useState(0)
const [numMensualidades, setNumMensualidades] = useState<number | null>(null)
const [avisoPropuestaDesactivada, setAvisoPropuestaDesactivada] = useState<string | null>(null)
const [apartado, setApartado] = useState<number>(APARTADO_ESQUEMA_DEFAULT)
const [numMensualidades, setNumMensualidades] = useState<number>(MENSUALIDADES_DEFAULT)
const [escrituracionDiferida, setEscrituracionDiferida] = useState(false)
const params = desarrolloEfectivo(reglas, desarrollo)
// Los descuentos por defecto se re-aplican cuando cambian las reglas del
// desarrollo (p. ej. al llegar las del API), sin pisar lo que capturó el
// vendedor mientras las reglas no cambian.
const descuentosAplicados = useRef<string>('')
const defaults = useMemo(
() => descuentosDefaultEfectivo(reglas, desarrollo),
[reglas, desarrollo],
)
useEffect(() => {
const clave = JSON.stringify(defaults)
if (descuentosAplicados.current === clave) return
descuentosAplicados.current = clave
setDescuentos(defaults)
}, [defaults])
const setDesarrollo = (id: string) => {
setDesarrolloState(id)
setUnidad(null)
setAcabados(false)
setDescuentos(descuentosDefaultEfectivo(reglas, id))
setPropuestaIndex(0)
setNumMensualidades(null)
}
const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => {
setDescuentos((prev) => ({ ...prev, [clave]: valor }))
}
const restaurar = (estado: EstadoRestaurar) => {
@@ -122,38 +83,23 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setUnidad(estado.unidad)
setAcabados(estado.acabados)
setApartado(estado.apartado)
const base = descuentosDefaultEfectivo(reglas, estado.desarrollo)
const paramsRestaurados = desarrolloEfectivo(reglas, estado.desarrollo)
const vigentes = propuestasVigentes(paramsRestaurados)
if (estado.descuento !== undefined) {
const idx = estado.propuestaIndex ?? 0
const clave = vigentes[idx]?.claveDescuento
if (clave) base[clave] = estado.descuento
}
setDescuentos(base)
setPropuestaIndex(estado.propuestaIndex ?? 0)
setNumMensualidades(estado.numMensualidades ?? null)
setNumMensualidades(estado.numMensualidades ?? MENSUALIDADES_DEFAULT)
setEscrituracionDiferida(estado.escrituracionDiferida ?? false)
}
const nueva = () => {
setUnidad(null)
setAcabados(false)
setApartado(APARTADO_DEFAULT)
setEnganchePct(null)
setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo))
setPropuestaIndex(0)
setNumMensualidades(null)
setAvisoPropuestaDesactivada(null)
setApartado(APARTADO_ESQUEMA_DEFAULT)
setNumMensualidades(MENSUALIDADES_DEFAULT)
setEscrituracionDiferida(false)
}
const numMesesCalculado = useMemo(
const numMeses = useMemo(
() => (unidad ? resolverNumMensualidades(unidad, params) : null),
[unidad, params],
)
/** Número efectivo de mensualidades: la elegida por el vendedor, o la calculada */
const numMeses = numMensualidades ?? numMesesCalculado
const precioLista = useMemo(
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
[unidad, acabados],
@@ -163,46 +109,15 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
// incluso $0; solo se acota a >= 0 como guarda contra capturas raras
const apartadoEfectivo = Math.max(0, apartado)
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo
const errorEnganche = validarEnganchePct(enganchePct)
const engancheEfectivo = errorEnganche === null && enganchePct !== null ? enganchePct / 100 : null
const propuestas = useMemo(() => {
if (!unidad) return []
return calcularPropuestas({
unidad,
acabados,
const resultado = useMemo<EsquemaFrasaResultado | null>(() => {
if (!unidad || precioLista <= 0) return null
return calcularEsquemaFrasa({
precio: precioLista,
apartado: apartadoEfectivo,
descuentos,
desarrollo,
reglas: params,
enganchePct: engancheEfectivo,
numMeses: numMeses ?? undefined,
numMensualidades,
escrituracionDiferida,
})
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, params, engancheEfectivo, numMeses])
// Propuesta elegida: si el índice se sale de la lista vigente (p. ej. el admin
// desactivó una propuesta) se cae a la primera y, si la que estaba elegida ya
// no existe, se avisa con claridad.
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
const tipoPrevio = useRef<{ tipo: string; titulo: string } | null>(null)
useEffect(() => {
const previo = tipoPrevio.current
const vigentes = propuestasVigentes(params)
if (previo !== null && !vigentes.some((p) => p.tipo === previo.tipo)) {
const sustituto = propuestas[0]?.titulo ?? 'otra propuesta'
setAvisoPropuestaDesactivada(
`El administrador desactivó la propuesta ${previo.titulo} para ${params.nombre}. Se seleccionó ${sustituto}; elige otra si lo prefieres.`,
)
setPropuestaIndex(0)
} else if (propuestaIndex >= propuestas.length) {
setPropuestaIndex(0)
}
tipoPrevio.current = propuestaActiva
? { tipo: propuestaActiva.tipo, titulo: propuestaActiva.titulo }
: null
}, [params, propuestas, propuestaActiva, propuestaIndex])
}, [unidad, precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida])
return {
desarrollo,
@@ -215,19 +130,13 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
apartado,
setApartado,
apartadoEfectivo,
enganchePct,
setEnganchePct,
errorEnganche,
descuentos,
setDescuento,
propuestaIndex,
setPropuestaIndex,
precioLista,
numMensualidades,
setNumMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
precioLista,
resultado,
numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar,
nueva,
}