feat(desarrollos): modulo de alta de desarrollos con inventario, reglas del cotizador por desarrollo y micrositio publico
Modulo nuevo (solo admin) para dar de alta desarrollos sin tocar codigo: - CRUD de la ficha, modelos e inventario (alta manual y generador por rango) - Reglas del cotizador por desarrollo, editables y persistidas en la BD - El cotizador consume las reglas desde /desarrollos/reglas-cotizador y cae a las constantes con aviso cuando un desarrollo aun no tiene reglas guardadas - Micrositio publico en /d/:clave (sin sesion) con ficha, modelos y disponibilidad; jamas expone datos de clientes ni cotizaciones Integracion: - La landing monta el modulo en el grid (clave 'desarrollos', solo admin) y el micrositio fuera del login; el rol manager NO accede (matriz y API con 403) - La tabla de roles de Configuracion ahora lista todos los modulos - Se alinea la FK de ReglaCotizadorDesarrollo con la BD viva (Desarrollo.id) Verificado: 708 pruebas del API, 115 del cotizador y 89 de la landing en verde; 12/12 cotizaciones identicas antes y despues del cambio de reglas; prueba en vivo del API con alta, modelos, inventario, reglas, micrositio y baja (30/30).
This commit is contained in:
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
|
||||
import type { Unidad, TipoPropuesta } from '../types'
|
||||
import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades'
|
||||
import { formatoMoneda } from '../lib/formatters'
|
||||
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
|
||||
import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas'
|
||||
|
||||
interface Props {
|
||||
unidades: Unidad[]
|
||||
@@ -10,6 +10,8 @@ interface Props {
|
||||
tipo: TipoPropuesta
|
||||
apartado: number
|
||||
acabados: boolean
|
||||
/** Reglas comerciales por desarrollo (vacío => constantes) */
|
||||
reglas?: ReglasDesarrollo
|
||||
}
|
||||
|
||||
const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`)
|
||||
@@ -23,17 +25,25 @@ interface Fila {
|
||||
/**
|
||||
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
|
||||
* con la propuesta activa calculada por el motor con los parámetros de cada
|
||||
* desarrollo. Solo ofrece unidades DISPONIBLES: no se compara (ni se cotiza)
|
||||
* inventario apartado, vendido o bloqueado.
|
||||
* desarrollo (las reglas del admin con respaldo en las constantes). Solo ofrece
|
||||
* unidades DISPONIBLES de desarrollos activos en el cotizador: no se compara
|
||||
* (ni se cotiza) inventario apartado, vendido, bloqueado o desactivado.
|
||||
*/
|
||||
export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props) {
|
||||
export function ComparadorUnidades({ unidades, tipo, apartado, acabados, reglas = SIN_REGLAS }: Props) {
|
||||
const [abierto, setAbierto] = useState(false)
|
||||
const [codigoA, setCodigoA] = useState('')
|
||||
const [codigoB, setCodigoB] = useState('')
|
||||
|
||||
const desarrollos = useMemo(() => idsCotizables(reglas), [reglas])
|
||||
|
||||
const disponibles = useMemo(
|
||||
() => unidades.filter((u) => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'),
|
||||
[unidades],
|
||||
() =>
|
||||
unidades.filter(
|
||||
(u) =>
|
||||
(u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' &&
|
||||
desarrollos.includes(u.desarrollo ?? 'DIAGONAL'),
|
||||
),
|
||||
[unidades, desarrollos],
|
||||
)
|
||||
|
||||
const porCodigo = useMemo(() => {
|
||||
@@ -46,8 +56,11 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
const unidadB = porCodigo.get(codigoB) ?? null
|
||||
|
||||
const comparacion = useMemo(
|
||||
() => (unidadA && unidadB ? compararUnidades(unidadA, unidadB, tipo, apartado, acabados) : null),
|
||||
[unidadA, unidadB, tipo, apartado, acabados],
|
||||
() =>
|
||||
unidadA && unidadB
|
||||
? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas)
|
||||
: null,
|
||||
[unidadA, unidadB, tipo, apartado, acabados, reglas],
|
||||
)
|
||||
|
||||
const filas: Fila[] = [
|
||||
@@ -87,11 +100,11 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
|
||||
>
|
||||
<option value="">Selecciona una unidad…</option>
|
||||
{LISTA_DESARROLLOS.map((dev) => {
|
||||
{desarrollos.map((dev) => {
|
||||
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
|
||||
if (delDesarrollo.length === 0) return null
|
||||
return (
|
||||
<optgroup key={dev} label={obtenerDesarrollo(dev).nombre}>
|
||||
<optgroup key={dev} label={desarrolloEfectivo(reglas, dev).nombre}>
|
||||
{delDesarrollo.map((u) => (
|
||||
<option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}>
|
||||
{u.codigo} — {u.modelo}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { DescuentosPropuestas, ParametrosDesarrollo } from '../types'
|
||||
|
||||
/**
|
||||
* Parámetros comerciales por desarrollo, replicados de los cotizadores Excel
|
||||
* de producción (ver reference/cotizadores/*.md). NO "corregir" fórmulas:
|
||||
* algunas usan precio de lista donde intuitivamente iría precio final; así
|
||||
* está en los Excel y así debe quedar.
|
||||
* Parámetros comerciales por desarrollo: VALORES POR DEFECTO / RESPALDO,
|
||||
* replicados de los cotizadores Excel de producción (ver
|
||||
* reference/cotizadores/*.md).
|
||||
*
|
||||
* Las reglas que usa el cotizador son las que el admin configura en la BD y
|
||||
* llegan por el API (GET /desarrollos/reglas-cotizador); estas constantes son
|
||||
* el respaldo para los desarrollos sin reglas guardadas y completan los campos
|
||||
* que falten (ver lib/reglas.ts). NO "corregir" fórmulas: algunas usan precio
|
||||
* de lista donde intuitivamente iría precio final; así está en los Excel y así
|
||||
* debe quedar.
|
||||
*/
|
||||
|
||||
const DIAGONAL: ParametrosDesarrollo = {
|
||||
@@ -309,9 +315,13 @@ export const DESARROLLO_DEFAULT = 'DIAGONAL'
|
||||
/** Ids en orden de presentación en el selector */
|
||||
export const LISTA_DESARROLLOS: string[] = ['DIAGONAL', 'ATELIER', 'ESTANZA', 'SENDA', 'SOLANA']
|
||||
|
||||
/** Parámetros de un desarrollo; cae a DIAGONAL si el id es desconocido */
|
||||
/**
|
||||
* Parámetros de un desarrollo por CONSTANTE (respaldo); cae a DIAGONAL si el id
|
||||
* es desconocido. Para las reglas efectivas (las del admin) usar
|
||||
* `desarrolloEfectivo` de lib/reglas.ts.
|
||||
*/
|
||||
export function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo {
|
||||
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]
|
||||
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]!
|
||||
}
|
||||
|
||||
/** Descuentos por defecto del desarrollo, indexados por clave de propuesta */
|
||||
|
||||
108
apps/cotizador/src/hooks/useCotizacion.test.tsx
Normal file
108
apps/cotizador/src/hooks/useCotizacion.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
// @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 { 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.
|
||||
*/
|
||||
|
||||
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 },
|
||||
})
|
||||
act(() => {
|
||||
hook.result.current.setUnidad(unidadDiagonal)
|
||||
})
|
||||
return hook
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('useCotizacion y las reglas del admin', () => {
|
||||
it('sin reglas ofrece las 4 propuestas de siempre', () => {
|
||||
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()
|
||||
})
|
||||
|
||||
it('una propuesta desactivada no se ofrece ni se puede elegir', () => {
|
||||
const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO'))
|
||||
|
||||
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)
|
||||
})
|
||||
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')
|
||||
})
|
||||
|
||||
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('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,
|
||||
),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
|
||||
import {
|
||||
APARTADO_DEFAULT,
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
validarEnganchePct,
|
||||
type PropuestaResultado,
|
||||
} from '../lib/calculations'
|
||||
import { DESARROLLO_DEFAULT, descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
|
||||
import { DESARROLLO_DEFAULT } from '../data/desarrollos'
|
||||
import {
|
||||
SIN_REGLAS,
|
||||
descuentosDefaultEfectivo,
|
||||
desarrolloEfectivo,
|
||||
propuestasVigentes,
|
||||
type ReglasDesarrollo,
|
||||
} from '../lib/reglas'
|
||||
|
||||
interface UseCotizacionResult {
|
||||
desarrollo: string
|
||||
@@ -40,6 +47,8 @@ interface UseCotizacionResult {
|
||||
/** Número efectivo de mensualidades (la elegida o la calculada) */
|
||||
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") */
|
||||
@@ -59,24 +68,47 @@ export interface EstadoRestaurar {
|
||||
numMensualidades?: number | null
|
||||
}
|
||||
|
||||
/** Estado central de la cotización: desarrollo, unidad, acabados, apartado y descuentos */
|
||||
export function useCotizacion(): UseCotizacionResult {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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>(descuentosDefault(DESARROLLO_DEFAULT))
|
||||
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 params = obtenerDesarrollo(desarrollo)
|
||||
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(descuentosDefault(id))
|
||||
setDescuentos(descuentosDefaultEfectivo(reglas, id))
|
||||
setPropuestaIndex(0)
|
||||
setNumMensualidades(null)
|
||||
}
|
||||
@@ -90,10 +122,12 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
setUnidad(estado.unidad)
|
||||
setAcabados(estado.acabados)
|
||||
setApartado(estado.apartado)
|
||||
const base = descuentosDefault(estado.desarrollo)
|
||||
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 = obtenerDesarrollo(estado.desarrollo).propuestas[idx]?.claveDescuento
|
||||
const clave = vigentes[idx]?.claveDescuento
|
||||
if (clave) base[clave] = estado.descuento
|
||||
}
|
||||
setDescuentos(base)
|
||||
@@ -106,9 +140,10 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
setAcabados(false)
|
||||
setApartado(APARTADO_DEFAULT)
|
||||
setEnganchePct(null)
|
||||
setDescuentos(descuentosDefault(desarrollo))
|
||||
setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo))
|
||||
setPropuestaIndex(0)
|
||||
setNumMensualidades(null)
|
||||
setAvisoPropuestaDesactivada(null)
|
||||
}
|
||||
|
||||
const numMesesCalculado = useMemo(
|
||||
@@ -134,8 +169,40 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
|
||||
const propuestas = useMemo(() => {
|
||||
if (!unidad) return []
|
||||
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo, numMeses: numMeses ?? undefined })
|
||||
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo, numMeses])
|
||||
return calcularPropuestas({
|
||||
unidad,
|
||||
acabados,
|
||||
apartado: apartadoEfectivo,
|
||||
descuentos,
|
||||
desarrollo,
|
||||
reglas: params,
|
||||
enganchePct: engancheEfectivo,
|
||||
numMeses: numMeses ?? undefined,
|
||||
})
|
||||
}, [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])
|
||||
|
||||
return {
|
||||
desarrollo,
|
||||
@@ -160,6 +227,7 @@ export function useCotizacion(): UseCotizacionResult {
|
||||
setNumMensualidades,
|
||||
numMeses,
|
||||
propuestas,
|
||||
avisoPropuestaDesactivada,
|
||||
restaurar,
|
||||
nueva,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Unidad } from '../types'
|
||||
import { getUnidades } from '../services/api'
|
||||
import { getReglasCotizador, getUnidades } from '../services/api'
|
||||
import { SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas'
|
||||
|
||||
interface UseUnidadesResult {
|
||||
unidades: Unidad[]
|
||||
@@ -8,21 +9,29 @@ interface UseUnidadesResult {
|
||||
error: string | null
|
||||
/** true cuando el inventario viene del seed local (sin conexión al API) */
|
||||
usandoSeed: boolean
|
||||
/** Reglas comerciales por desarrollo (vacío => se usan las constantes) */
|
||||
reglas: ReglasDesarrollo
|
||||
}
|
||||
|
||||
/** Carga el inventario de unidades desde el API Gateway, con fallback al seed local */
|
||||
/**
|
||||
* Carga el inventario de unidades desde el API Gateway, con fallback al seed
|
||||
* local, junto con las reglas comerciales configuradas por el admin (que caen a
|
||||
* los valores por defecto si no llegan).
|
||||
*/
|
||||
export function useUnidades(): UseUnidadesResult {
|
||||
const [unidades, setUnidades] = useState<Unidad[]>([])
|
||||
const [reglas, setReglas] = useState<ReglasDesarrollo>(SIN_REGLAS)
|
||||
const [cargando, setCargando] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [usandoSeed, setUsandoSeed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let activo = true
|
||||
getUnidades()
|
||||
.then(({ unidades, usandoSeed }) => {
|
||||
Promise.all([getUnidades(), getReglasCotizador()])
|
||||
.then(([{ unidades, usandoSeed }, reglasApi]) => {
|
||||
if (activo) {
|
||||
setUnidades(unidades)
|
||||
setReglas(reglasApi)
|
||||
setUsandoSeed(usandoSeed)
|
||||
setCargando(false)
|
||||
}
|
||||
@@ -38,5 +47,5 @@ export function useUnidades(): UseUnidadesResult {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { unidades, cargando, error, usandoSeed }
|
||||
return { unidades, cargando, error, usandoSeed, reglas }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PropuestaConfig,
|
||||
} from '../types'
|
||||
import { DESARROLLO_DEFAULT, obtenerDesarrollo } from '../data/desarrollos'
|
||||
import { propuestasVigentes } from './reglas'
|
||||
|
||||
/**
|
||||
* Cálculos replicados fielmente de los Excel de producción "COTIZADOR" de
|
||||
@@ -83,6 +84,11 @@ export interface CotizacionInput {
|
||||
descuentos: DescuentosPropuestas
|
||||
/** Desarrollo cuyos parámetros aplican; por defecto unidad.desarrollo ?? 'DIAGONAL' */
|
||||
desarrollo?: string
|
||||
/**
|
||||
* Reglas comerciales efectivas del desarrollo (las del admin, con respaldo en
|
||||
* las constantes). Si no vienen, se usan las constantes.
|
||||
*/
|
||||
reglas?: ParametrosDesarrollo
|
||||
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
|
||||
numMeses?: number
|
||||
/**
|
||||
@@ -273,13 +279,13 @@ export function indiceMenorTotal(totales: number[]): number {
|
||||
return indice
|
||||
}
|
||||
|
||||
/** Calcula las propuestas comerciales del desarrollo de la unidad */
|
||||
/** Propuestas comerciales vigentes del desarrollo de la unidad */
|
||||
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
|
||||
const { unidad, acabados, apartado, descuentos } = input
|
||||
const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
|
||||
const params = input.reglas ?? obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
|
||||
return params.propuestas.map((cfg) =>
|
||||
return propuestasVigentes(params).map((cfg) =>
|
||||
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n, input.enganchePct ?? null),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
totalPagado,
|
||||
type PropuestaResultado,
|
||||
} from './calculations'
|
||||
import { descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
|
||||
import {
|
||||
SIN_REGLAS,
|
||||
descuentosDefaultEfectivo,
|
||||
desarrolloEfectivo,
|
||||
type ReglasDesarrollo,
|
||||
} from './reglas'
|
||||
|
||||
export interface ColumnaComparador {
|
||||
unidad: Unidad
|
||||
@@ -36,16 +41,18 @@ export function compararUnidades(
|
||||
tipo: TipoPropuesta,
|
||||
apartado: number,
|
||||
acabados: boolean,
|
||||
reglas: ReglasDesarrollo = SIN_REGLAS,
|
||||
): ComparacionUnidades {
|
||||
const columna = (unidad: Unidad): ColumnaComparador => {
|
||||
const desarrolloId = unidad.desarrollo ?? 'DIAGONAL'
|
||||
const params = obtenerDesarrollo(desarrolloId)
|
||||
const params = desarrolloEfectivo(reglas, desarrolloId)
|
||||
const propuestas = calcularPropuestas({
|
||||
unidad,
|
||||
acabados,
|
||||
apartado,
|
||||
descuentos: descuentosDefault(desarrolloId),
|
||||
descuentos: descuentosDefaultEfectivo(reglas, desarrolloId),
|
||||
desarrollo: desarrolloId,
|
||||
reglas: params,
|
||||
})
|
||||
const propuesta = propuestas.find((p) => p.tipo === tipo) ?? propuestas[0]
|
||||
if (!propuesta) throw new Error(`El desarrollo ${desarrolloId} no tiene propuestas`)
|
||||
|
||||
164
apps/cotizador/src/lib/reglas.test.ts
Normal file
164
apps/cotizador/src/lib/reglas.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { APARTADO_DEFAULT, calcularPropuestas, totalPagado } from './calculations'
|
||||
import {
|
||||
SIN_REGLAS,
|
||||
descuentosDefaultEfectivo,
|
||||
desarrolloEfectivo,
|
||||
fusionarReglas,
|
||||
idsCotizables,
|
||||
propuestasVigentes,
|
||||
reglasDesdeApi,
|
||||
} from './reglas'
|
||||
import { DESARROLLOS, LISTA_DESARROLLOS, descuentosDefault } from '../data/desarrollos'
|
||||
import { INVENTARIO_SEED } from '../data/unidades'
|
||||
import type { DescuentosPropuestas, ParametrosDesarrollo, Unidad } from '../types'
|
||||
|
||||
/**
|
||||
* Reglas comerciales configurables: el cotizador usa las que manda el API (con
|
||||
* respaldo en las constantes). Las unidades son las del inventario real.
|
||||
*/
|
||||
|
||||
const DESCUENTOS: DescuentosPropuestas = {
|
||||
creditoBancario: 0.01,
|
||||
creditoInfonavit: 0.005,
|
||||
contado: 0.03,
|
||||
semiContado: 0.015,
|
||||
}
|
||||
|
||||
/** Tres unidades reales del inventario por desarrollo */
|
||||
function unidadesDe(desarrollo: string): Unidad[] {
|
||||
return INVENTARIO_SEED.filter((u) => u.desarrollo === desarrollo).slice(0, 3)
|
||||
}
|
||||
|
||||
/** Reglas tal como las devuelve el API: JSON serializado con campos extra del admin */
|
||||
function reglasDeApi(extras: Record<string, Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
const mapa: Record<string, unknown> = {}
|
||||
for (const [id, params] of Object.entries(DESARROLLOS)) {
|
||||
mapa[id] = JSON.parse(
|
||||
JSON.stringify({
|
||||
...params,
|
||||
activoEnCotizador: true,
|
||||
propuestas: params.propuestas.map((p) => ({ ...p, activa: true })),
|
||||
...(extras[id] ?? {}),
|
||||
}),
|
||||
) as unknown
|
||||
}
|
||||
return mapa
|
||||
}
|
||||
|
||||
describe('reglasDesdeApi / desarrolloEfectivo', () => {
|
||||
it('sin reglas del API todo se calcula con las constantes', () => {
|
||||
expect(idsCotizables(SIN_REGLAS)).toEqual(LISTA_DESARROLLOS)
|
||||
for (const id of LISTA_DESARROLLOS) {
|
||||
expect(desarrolloEfectivo(SIN_REGLAS, id)).toEqual(DESARROLLOS[id])
|
||||
expect(descuentosDefaultEfectivo(SIN_REGLAS, id)).toEqual(descuentosDefault(id))
|
||||
}
|
||||
})
|
||||
|
||||
it('las reglas del API ganan a la constante y completan los campos que falten', () => {
|
||||
const reglas = reglasDesdeApi({
|
||||
DIAGONAL: { propuestas: [{ tipo: 'CONTADO', descuentoDefault: 0.08 }] },
|
||||
})
|
||||
|
||||
const params = desarrolloEfectivo(reglas, 'DIAGONAL')
|
||||
// El campo que vino del API manda...
|
||||
expect(params.propuestas).toHaveLength(1)
|
||||
expect(params.propuestas[0]?.descuentoDefault).toBe(0.08)
|
||||
// ...y lo que falta se completa con la constante del desarrollo
|
||||
expect(params.propuestas[0]?.titulo).toBe('Contado')
|
||||
expect(params.propuestas[0]?.pctEnganche).toBe(0.9)
|
||||
expect(params.nombre).toBe('Diagonal')
|
||||
expect(params.nDefault).toBe(13)
|
||||
expect(params.cuotaMantenimiento).toBe(1500)
|
||||
expect(descuentosDefaultEfectivo(reglas, 'DIAGONAL').contado).toBe(0.08)
|
||||
})
|
||||
|
||||
it('un JSON que no se puede usar deja al desarrollo con su constante', () => {
|
||||
const reglas = reglasDesdeApi({
|
||||
SENDA: null,
|
||||
ATELIER: 'roto',
|
||||
ESTANZA: { propuestas: [] },
|
||||
SOLANA: { propuestas: [{ tipo: 'NO_EXISTE' }] },
|
||||
})
|
||||
|
||||
expect(reglas).toEqual({})
|
||||
expect(desarrolloEfectivo(reglas, 'SENDA')).toEqual(DESARROLLOS['SENDA'])
|
||||
})
|
||||
|
||||
it('un desarrollo con activoEnCotizador false no se ofrece (el API también lo rechaza)', () => {
|
||||
const reglas = reglasDesdeApi(reglasDeApi({ SOLANA: { activoEnCotizador: false } }))
|
||||
|
||||
expect(idsCotizables(reglas)).toEqual(['DIAGONAL', 'ATELIER', 'ESTANZA', 'SENDA'])
|
||||
expect(desarrolloEfectivo(reglas, 'SOLANA').activoEnCotizador).toBe(false)
|
||||
})
|
||||
|
||||
it('una propuesta desactivada no se ofrece ni se calcula; el resto sigue vigente', () => {
|
||||
const base = DESARROLLOS['DIAGONAL']!
|
||||
const reglas = reglasDesdeApi(
|
||||
reglasDeApi({
|
||||
DIAGONAL: {
|
||||
propuestas: base.propuestas.map((p) => ({ ...p, activa: p.tipo !== 'CONTADO' })),
|
||||
},
|
||||
}),
|
||||
)
|
||||
const params = desarrolloEfectivo(reglas, 'DIAGONAL')
|
||||
const unidad = unidadesDe('DIAGONAL')[0]!
|
||||
|
||||
expect(propuestasVigentes(params).map((p) => p.tipo)).toEqual([
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'SEMI_CONTADO',
|
||||
])
|
||||
const propuestas = calcularPropuestas({
|
||||
unidad,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: descuentosDefaultEfectivo(reglas, 'DIAGONAL'),
|
||||
reglas: params,
|
||||
})
|
||||
expect(propuestas.map((p) => p.tipo)).toEqual([
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'SEMI_CONTADO',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reglas equivalentes de BD vs constantes: mismos números', () => {
|
||||
it('los 5 desarrollos cotizan igual con las reglas del API que con las constantes', () => {
|
||||
const reglas = reglasDesdeApi(reglasDeApi())
|
||||
|
||||
for (const id of LISTA_DESARROLLOS) {
|
||||
const unidades = unidadesDe(id)
|
||||
expect(unidades).toHaveLength(3)
|
||||
for (const unidad of unidades) {
|
||||
for (const acabados of [false, true]) {
|
||||
const conConstantes = calcularPropuestas({
|
||||
unidad,
|
||||
acabados,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: DESCUENTOS,
|
||||
desarrollo: id,
|
||||
})
|
||||
const conReglas = calcularPropuestas({
|
||||
unidad,
|
||||
acabados,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: DESCUENTOS,
|
||||
desarrollo: id,
|
||||
reglas: desarrolloEfectivo(reglas, id),
|
||||
})
|
||||
expect(conReglas).toEqual(conConstantes)
|
||||
for (const p of conReglas) {
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).not.toBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('fusionarReglas no cambia un solo número cuando la BD tiene los valores de hoy', () => {
|
||||
const fusionadas = fusionarReglas('SENDA', reglasDeApi()['SENDA']) as ParametrosDesarrollo
|
||||
expect(fusionadas).toEqual(DESARROLLOS['SENDA'])
|
||||
})
|
||||
})
|
||||
227
apps/cotizador/src/lib/reglas.ts
Normal file
227
apps/cotizador/src/lib/reglas.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import type {
|
||||
BasePrecio,
|
||||
DescuentosPropuestas,
|
||||
ModoMensualidades,
|
||||
ParametrosDesarrollo,
|
||||
PropuestaConfig,
|
||||
TipoPropuesta,
|
||||
} from '../types'
|
||||
import { DESARROLLOS, LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
|
||||
|
||||
/**
|
||||
* Reglas comerciales del cotizador.
|
||||
*
|
||||
* Las reglas "efectivas" las configura el admin y llegan del API
|
||||
* (`GET /desarrollos/reglas-cotizador`); `data/desarrollos.ts` son los valores
|
||||
* por defecto/respaldo: si un desarrollo no trae reglas (o llegan a medias), se
|
||||
* completan campo por campo con su constante. Sin reglas del API, la app se
|
||||
* comporta exactamente como antes.
|
||||
*
|
||||
* Igual que en el motor, las fórmulas no se tocan: aquí solo se decide de dónde
|
||||
* salen los parámetros.
|
||||
*/
|
||||
|
||||
/** Reglas efectivas indexadas por id de desarrollo ('DIAGONAL', 'SENDA', …) */
|
||||
export type ReglasDesarrollo = Record<string, ParametrosDesarrollo>
|
||||
|
||||
/** Sin reglas del API: todo se calcula con las constantes */
|
||||
export const SIN_REGLAS: ReglasDesarrollo = {}
|
||||
|
||||
const TIPOS: TipoPropuesta[] = [
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'CONTADO',
|
||||
'SEMI_CONTADO',
|
||||
]
|
||||
|
||||
const CLAVES: (keyof DescuentosPropuestas)[] = [
|
||||
'creditoBancario',
|
||||
'creditoInfonavit',
|
||||
'contado',
|
||||
'semiContado',
|
||||
]
|
||||
|
||||
/** Clave de descuento que le toca a cada tipo cuando ni la BD ni la constante la traen */
|
||||
const CLAVE_POR_TIPO: Record<TipoPropuesta, keyof DescuentosPropuestas> = {
|
||||
CREDITO_BANCARIO: 'creditoBancario',
|
||||
CREDITO_INFONAVIT_COFINAVIT: 'creditoInfonavit',
|
||||
CONTADO: 'contado',
|
||||
SEMI_CONTADO: 'semiContado',
|
||||
}
|
||||
|
||||
function texto(valor: unknown): string | null {
|
||||
return typeof valor === 'string' && valor.trim() !== '' ? valor.trim() : null
|
||||
}
|
||||
|
||||
function numero(valor: unknown): number | null {
|
||||
return typeof valor === 'number' && Number.isFinite(valor) ? valor : null
|
||||
}
|
||||
|
||||
function tipoPropuesta(valor: unknown): TipoPropuesta | null {
|
||||
return typeof valor === 'string' && (TIPOS as string[]).includes(valor)
|
||||
? (valor as TipoPropuesta)
|
||||
: null
|
||||
}
|
||||
|
||||
function claveDescuento(valor: unknown): keyof DescuentosPropuestas | null {
|
||||
return typeof valor === 'string' && (CLAVES as string[]).includes(valor)
|
||||
? (valor as keyof DescuentosPropuestas)
|
||||
: null
|
||||
}
|
||||
|
||||
function basePrecio(valor: unknown): BasePrecio | null {
|
||||
return valor === 'lista' || valor === 'final' ? valor : null
|
||||
}
|
||||
|
||||
function modoMensualidades(valor: unknown): ModoMensualidades | null {
|
||||
return valor === 'ninguna' || valor === 'remanente' || valor === 'remanenteLista' || valor === 'porcentaje'
|
||||
? valor
|
||||
: null
|
||||
}
|
||||
|
||||
/** Propuestas vigentes: el admin puede desactivar una propuesta por desarrollo */
|
||||
export function propuestasVigentes(params: ParametrosDesarrollo): PropuestaConfig[] {
|
||||
return params.propuestas.filter(propuestaVigente)
|
||||
}
|
||||
|
||||
/** ¿La propuesta se ofrece en el cotizador? (activa !== false) */
|
||||
export function propuestaVigente(propuesta: PropuestaConfig): boolean {
|
||||
return propuesta.activa !== false
|
||||
}
|
||||
|
||||
/** Una propuesta de la BD, completando lo que falte con la constante del tipo */
|
||||
function fusionarPropuesta(
|
||||
bruta: unknown,
|
||||
constante: ParametrosDesarrollo | undefined,
|
||||
): PropuestaConfig | null {
|
||||
if (typeof bruta !== 'object' || bruta === null) return null
|
||||
const p = bruta as Record<string, unknown>
|
||||
const tipo = tipoPropuesta(p['tipo'])
|
||||
if (tipo === null) return null
|
||||
const base = constante?.propuestas.find((c) => c.tipo === tipo)
|
||||
|
||||
const propuesta: PropuestaConfig = {
|
||||
tipo,
|
||||
claveDescuento: claveDescuento(p['claveDescuento']) ?? base?.claveDescuento ?? CLAVE_POR_TIPO[tipo],
|
||||
titulo: texto(p['titulo']) ?? base?.titulo ?? tipo,
|
||||
descuentoDefault: numero(p['descuentoDefault']) ?? base?.descuentoDefault ?? 0,
|
||||
pctEnganche: numero(p['pctEnganche']) ?? base?.pctEnganche ?? 0,
|
||||
engancheBase: basePrecio(p['engancheBase']) ?? base?.engancheBase ?? 'final',
|
||||
pctPagoFinal: numero(p['pctPagoFinal']) ?? base?.pctPagoFinal ?? 0,
|
||||
pagoFinalBase: basePrecio(p['pagoFinalBase']) ?? base?.pagoFinalBase ?? 'final',
|
||||
modoMensualidades: modoMensualidades(p['modoMensualidades']) ?? base?.modoMensualidades ?? 'porcentaje',
|
||||
}
|
||||
const ceroSinApartado = p['engancheCeroSinApartado'] ?? base?.engancheCeroSinApartado
|
||||
if (ceroSinApartado === true) propuesta.engancheCeroSinApartado = true
|
||||
// Solo se conserva el "false" (desactivada): true/ausente es lo mismo y así una
|
||||
// configuración igual a la constante da exactamente el mismo objeto
|
||||
const activa = p['activa'] ?? p['activo'] ?? base?.activa
|
||||
if (activa === false) propuesta.activa = false
|
||||
return propuesta
|
||||
}
|
||||
|
||||
/**
|
||||
* Reglas de un desarrollo que llegan del API, completadas con su constante.
|
||||
* Devuelve null si el JSON no es usable: el desarrollo se queda con su
|
||||
* constante (comportamiento de siempre).
|
||||
*/
|
||||
export function fusionarReglas(id: string, bruto: unknown): ParametrosDesarrollo | null {
|
||||
if (typeof bruto !== 'object' || bruto === null || Array.isArray(bruto)) return null
|
||||
const r = bruto as Record<string, unknown>
|
||||
const brutoPropuestas = r['propuestas']
|
||||
if (!Array.isArray(brutoPropuestas) || brutoPropuestas.length === 0) return null
|
||||
|
||||
const constante = DESARROLLOS[id]
|
||||
const propuestas: PropuestaConfig[] = []
|
||||
for (const bruta of brutoPropuestas) {
|
||||
const propuesta = fusionarPropuesta(bruta, constante)
|
||||
if (propuesta === null) return null
|
||||
propuestas.push(propuesta)
|
||||
}
|
||||
|
||||
const fuenteDeN = r['fuenteDeN']
|
||||
const params: ParametrosDesarrollo = {
|
||||
id,
|
||||
nombre: texto(r['nombre']) ?? constante?.nombre ?? id,
|
||||
precioUnico:
|
||||
typeof r['precioUnico'] === 'boolean' ? r['precioUnico'] : (constante?.precioUnico ?? true),
|
||||
fuenteDeN:
|
||||
fuenteDeN === 'fechaEntrega' || fuenteDeN === 'fijo' || fuenteDeN === 'porUnidad'
|
||||
? fuenteDeN
|
||||
: (constante?.fuenteDeN ?? 'fijo'),
|
||||
cuotaMantenimiento:
|
||||
'cuotaMantenimiento' in r
|
||||
? (numero(r['cuotaMantenimiento']) ?? null)
|
||||
: (constante?.cuotaMantenimiento ?? null),
|
||||
prefijoUnidadPdf: texto(r['prefijoUnidadPdf']) ?? constante?.prefijoUnidadPdf ?? 'CASA',
|
||||
propuestas,
|
||||
}
|
||||
|
||||
const nFijo = numero(r['nFijo']) ?? constante?.nFijo
|
||||
if (typeof nFijo === 'number') params.nFijo = nFijo
|
||||
const nDefault = numero(r['nDefault']) ?? constante?.nDefault
|
||||
if (typeof nDefault === 'number') params.nDefault = nDefault
|
||||
// Solo se conserva el "false" (desactivado): true/ausente es lo mismo
|
||||
const activo = r['activoEnCotizador'] ?? r['activo']
|
||||
if (activo === false) params.activoEnCotizador = false
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte el mapa `reglasPorDesarrollo` del API en reglas efectivas. Los
|
||||
* desarrollos sin reglas usables se omiten (quedan en su constante).
|
||||
*/
|
||||
export function reglasDesdeApi(bruto: unknown): ReglasDesarrollo {
|
||||
const reglas: ReglasDesarrollo = {}
|
||||
if (typeof bruto !== 'object' || bruto === null) return reglas
|
||||
for (const [id, valor] of Object.entries(bruto as Record<string, unknown>)) {
|
||||
const fusionado = fusionarReglas(id, valor)
|
||||
if (fusionado !== null) reglas[id] = fusionado
|
||||
}
|
||||
return reglas
|
||||
}
|
||||
|
||||
/** Reglas del desarrollo: las del admin si las hay; si no, la constante (o DIAGONAL) */
|
||||
export function desarrolloEfectivo(
|
||||
reglas: ReglasDesarrollo | undefined,
|
||||
id?: string | null,
|
||||
): ParametrosDesarrollo {
|
||||
if (id !== undefined && id !== null) {
|
||||
const delDesarrollo = reglas?.[id]
|
||||
if (delDesarrollo) return delDesarrollo
|
||||
}
|
||||
return obtenerDesarrollo(id)
|
||||
}
|
||||
|
||||
/** Descuentos por defecto del desarrollo, indexados por clave de propuesta */
|
||||
export function descuentosDefaultEfectivo(
|
||||
reglas: ReglasDesarrollo | undefined,
|
||||
id?: string | null,
|
||||
): DescuentosPropuestas {
|
||||
const params = desarrolloEfectivo(reglas, id)
|
||||
const descuentos: DescuentosPropuestas = {
|
||||
creditoBancario: 0,
|
||||
creditoInfonavit: 0,
|
||||
contado: 0,
|
||||
semiContado: 0,
|
||||
}
|
||||
for (const propuesta of params.propuestas) descuentos[propuesta.claveDescuento] = propuesta.descuentoDefault
|
||||
return descuentos
|
||||
}
|
||||
|
||||
/**
|
||||
* Desarrollos que se pueden ofrecer en el cotizador: los conocidos más los que
|
||||
* el admin haya configurado, sin los desactivados ni los que se quedaron sin
|
||||
* propuestas vigentes.
|
||||
*/
|
||||
export function idsCotizables(reglas: ReglasDesarrollo = SIN_REGLAS): string[] {
|
||||
const ids = [
|
||||
...LISTA_DESARROLLOS,
|
||||
...Object.keys(reglas).filter((id) => !LISTA_DESARROLLOS.includes(id)),
|
||||
]
|
||||
return ids.filter((id) => {
|
||||
const params = desarrolloEfectivo(reglas, id)
|
||||
return params.activoEnCotizador !== false && propuestasVigentes(params).length > 0
|
||||
})
|
||||
}
|
||||
89
apps/cotizador/src/pages/CotizadorPage.reglas.test.tsx
Normal file
89
apps/cotizador/src/pages/CotizadorPage.reglas.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import CotizadorPage from './CotizadorPage'
|
||||
import { DESARROLLOS } from '../data/desarrollos'
|
||||
import { INVENTARIO_SEED } from '../data/unidades'
|
||||
|
||||
/**
|
||||
* Reglas configurables por el admin en la página del cotizador:
|
||||
* - un desarrollo con activoEnCotizador: false no se ofrece
|
||||
* - una propuesta desactivada no se muestra ni se puede elegir
|
||||
*/
|
||||
|
||||
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
|
||||
const unidadSolana = INVENTARIO_SEED.find((u) => u.desarrollo === 'SOLANA')!
|
||||
|
||||
const respuesta = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
/** Reglas del API con los ajustes del test (JSON serializado, como en la BD) */
|
||||
function reglasDeApi(): { reglasPorDesarrollo: Record<string, unknown> } {
|
||||
const mapa: Record<string, unknown> = {}
|
||||
for (const [id, params] of Object.entries(DESARROLLOS)) {
|
||||
mapa[id] = JSON.parse(JSON.stringify(params)) as unknown
|
||||
}
|
||||
const solana = mapa['SOLANA'] as Record<string, unknown>
|
||||
solana['activoEnCotizador'] = false
|
||||
const diagonal = mapa['DIAGONAL'] as { propuestas: Record<string, unknown>[] }
|
||||
for (const propuesta of diagonal.propuestas) {
|
||||
if (propuesta['tipo'] === 'CONTADO') propuesta['activa'] = false
|
||||
}
|
||||
return { reglasPorDesarrollo: mapa }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const almacenamiento = new Map<string, string>()
|
||||
window.history.replaceState({}, '', '/')
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => almacenamiento.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
|
||||
removeItem: (k: string) => void almacenamiento.delete(k),
|
||||
clear: () => almacenamiento.clear(),
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/desarrollos/reglas-cotizador')) return Promise.resolve(respuesta(reglasDeApi()))
|
||||
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidadDiagonal, unidadSolana]))
|
||||
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([]))
|
||||
return Promise.reject(new TypeError(`fetch inesperado: ${url}`))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('CotizadorPage con reglas del admin', () => {
|
||||
it('no ofrece el desarrollo desactivado ni la propuesta desactivada', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CotizadorPage />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
const inputUnidad = (await screen.findByLabelText('Unidad')) as HTMLInputElement
|
||||
|
||||
// SOLANA está desactivado: no aparece su botón en el selector de desarrollos
|
||||
expect(screen.queryByRole('button', { name: /Solana/ })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy()
|
||||
|
||||
// Cargar las 4 propuestas de DIAGONAL: solo las 3 vigentes
|
||||
fireEvent.focus(inputUnidad)
|
||||
fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } })
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Crédito Bancario')).toBeTruthy()
|
||||
})
|
||||
expect(screen.getByText('Crédito Infonavit-Cofinavit')).toBeTruthy()
|
||||
expect(screen.getByText('Semi Contado')).toBeTruthy()
|
||||
expect(screen.queryByText('Contado')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,7 @@ import { calcularVigencia } from '../lib/vigencia'
|
||||
import { folioCotizacion } from '../lib/folio'
|
||||
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
||||
import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations'
|
||||
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
|
||||
import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas'
|
||||
import {
|
||||
getMisCotizaciones,
|
||||
guardarCotizacion,
|
||||
@@ -56,8 +56,8 @@ const campoInputError = `${campoInputBase} border-red-400`
|
||||
const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
|
||||
|
||||
export default function CotizadorPage() {
|
||||
const { unidades, cargando, error, usandoSeed } = useUnidades()
|
||||
const cotizacion = useCotizacion()
|
||||
const { unidades, cargando, error, usandoSeed, reglas } = useUnidades()
|
||||
const cotizacion = useCotizacion(reglas)
|
||||
const {
|
||||
desarrollo,
|
||||
setDesarrollo,
|
||||
@@ -79,10 +79,13 @@ export default function CotizadorPage() {
|
||||
setNumMensualidades,
|
||||
numMeses,
|
||||
propuestas,
|
||||
avisoPropuestaDesactivada,
|
||||
restaurar,
|
||||
nueva,
|
||||
} = cotizacion
|
||||
|
||||
/** Desarrollos que se ofrecen: los activos en las reglas del admin */
|
||||
const desarrollos = idsCotizables(reglas)
|
||||
const unidadesDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === desarrollo)
|
||||
const disponiblesDesarrollo = unidadesDesarrollo.filter((u) => u.status === 'DISPONIBLE').length
|
||||
|
||||
@@ -101,6 +104,24 @@ export default function CotizadorPage() {
|
||||
const [oportunidadId, setOportunidadId] = useState<string | null>(null)
|
||||
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
|
||||
|
||||
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
|
||||
// primero disponible para no dejar el cotizador en un desarrollo muerto.
|
||||
useEffect(() => {
|
||||
if (desarrollos.includes(desarrollo)) return
|
||||
const alterno = desarrollos[0]
|
||||
if (alterno === undefined) return
|
||||
setDesarrollo(alterno)
|
||||
setAviso(
|
||||
`El desarrollo ${desarrollo} ya no está disponible en el cotizador; se cambió a ${alterno}.`,
|
||||
)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [desarrollos, desarrollo])
|
||||
|
||||
// La propuesta elegida dejó de estar disponible (la desactivó el admin)
|
||||
useEffect(() => {
|
||||
if (avisoPropuestaDesactivada !== null) setAviso(avisoPropuestaDesactivada)
|
||||
}, [avisoPropuestaDesactivada])
|
||||
|
||||
useEffect(() => {
|
||||
let vivo = true
|
||||
/** Combina servidor + locales, sin duplicados, más recientes primero */
|
||||
@@ -159,7 +180,13 @@ export default function CotizadorPage() {
|
||||
if (cliente) setValue('nombre', cliente, { shouldValidate: true })
|
||||
if (telefono) setValue('telefono', telefono, { shouldValidate: true })
|
||||
if (email) setValue('email', email, { shouldValidate: true })
|
||||
if (desarrolloParam && LISTA_DESARROLLOS.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
|
||||
if (desarrolloParam) {
|
||||
if (desarrollos.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
|
||||
else
|
||||
setAviso(
|
||||
`El desarrollo ${desarrolloParam} no está disponible en el cotizador. Elige un desarrollo de la lista.`,
|
||||
)
|
||||
}
|
||||
if (unidadParam) setUnidadPendiente(unidadParam)
|
||||
if (opId) setOportunidadId(opId)
|
||||
// Solo al montar: es el punto de entrada desde Mi día
|
||||
@@ -180,6 +207,10 @@ export default function CotizadorPage() {
|
||||
setAviso(
|
||||
`La unidad ${encontrada.codigo} ya no está disponible (estatus: ${encontrada.status}). Elige otra unidad disponible.`,
|
||||
)
|
||||
} else if (!desarrollos.includes(encontrada.desarrollo ?? 'DIAGONAL')) {
|
||||
setAviso(
|
||||
`El desarrollo de la unidad ${encontrada.codigo} no está disponible en el cotizador: elige otra unidad disponible.`,
|
||||
)
|
||||
} else {
|
||||
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
|
||||
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
|
||||
@@ -418,7 +449,13 @@ export default function CotizadorPage() {
|
||||
return
|
||||
}
|
||||
const tipo = TIPO_DE_API[c.tipoPropuesta]
|
||||
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
|
||||
const paramsGuardados = desarrolloEfectivo(reglas, c.desarrollo)
|
||||
const idx = paramsGuardados.propuestas.filter(propuestaVigente).findIndex((p) => p.tipo === tipo)
|
||||
if (idx < 0) {
|
||||
setAviso(
|
||||
`La cotización ${c.folio ?? ''} usa la propuesta ${c.tipoPropuesta}, que ya no está disponible para ${c.desarrollo}. Elige otra propuesta.`,
|
||||
)
|
||||
}
|
||||
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
|
||||
restaurar({
|
||||
desarrollo: c.desarrollo,
|
||||
@@ -498,7 +535,7 @@ export default function CotizadorPage() {
|
||||
<div>
|
||||
<span className={etiquetaCampo}>Desarrollo</span>
|
||||
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
|
||||
{LISTA_DESARROLLOS.map((id) => {
|
||||
{desarrollos.map((id) => {
|
||||
const activo = id === desarrollo
|
||||
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
|
||||
const disponibles = unidades.filter(
|
||||
@@ -516,7 +553,7 @@ export default function CotizadorPage() {
|
||||
: 'bg-white text-[#4B5563] dark:text-[#CBD5E1] hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
|
||||
}`}
|
||||
>
|
||||
{obtenerDesarrollo(id).nombre}
|
||||
{desarrolloEfectivo(reglas, id).nombre}
|
||||
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B] dark:text-[#94A3B8]'}`}>
|
||||
{disponibles}/{total}
|
||||
</span>
|
||||
@@ -649,7 +686,9 @@ export default function CotizadorPage() {
|
||||
if (lead.telefono) setValue('telefono', lead.telefono, { shouldValidate: true })
|
||||
if (lead.email) setValue('email', lead.email, { shouldValidate: true })
|
||||
if (lead.fuente === 'oportunidad') setOportunidadId(lead.id)
|
||||
if (lead.desarrollo && LISTA_DESARROLLOS.includes(lead.desarrollo)) setDesarrollo(lead.desarrollo)
|
||||
if (lead.desarrollo && desarrollos.includes(lead.desarrollo)) {
|
||||
setDesarrollo(lead.desarrollo)
|
||||
}
|
||||
setAviso(
|
||||
`Datos de ${lead.nombre} cargados${
|
||||
lead.fuente === 'oportunidad' ? ' — la cotización quedará vinculada a su oportunidad' : ''
|
||||
@@ -768,6 +807,7 @@ export default function CotizadorPage() {
|
||||
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
|
||||
apartado={apartadoEfectivo}
|
||||
acabados={acabados}
|
||||
reglas={reglas}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { getUnidades } from './api'
|
||||
import { getReglasCotizador, getUnidades } from './api'
|
||||
import { INVENTARIO_SEED } from '../data/unidades'
|
||||
import type { Unidad } from '../types'
|
||||
|
||||
@@ -65,3 +65,43 @@ describe('getUnidades', () => {
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReglasCotizador', () => {
|
||||
it('devuelve las reglas por desarrollo que manda el API', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
respuestaOk({
|
||||
reglasPorDesarrollo: {
|
||||
DIAGONAL: { nombre: 'Diagonal', propuestas: [{ tipo: 'CONTADO', descuentoDefault: 0.05 }] },
|
||||
},
|
||||
}),
|
||||
)
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const reglas = await getReglasCotizador()
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/desarrollos/reglas-cotizador', {})
|
||||
// Lo que manda el API gana; lo que falta se completa con la constante
|
||||
expect(reglas['DIAGONAL']?.propuestas[0]?.descuentoDefault).toBe(0.05)
|
||||
expect(reglas['DIAGONAL']?.propuestas[0]?.pctEnganche).toBe(0.9)
|
||||
expect(reglas['DIAGONAL']?.nDefault).toBe(13)
|
||||
})
|
||||
|
||||
it('sin respuesta del API devuelve un mapa vacío (todo cae a las constantes)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('error', { status: 500 })))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
const reglas = await getReglasCotizador()
|
||||
|
||||
expect(reglas).toEqual({})
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('respuesta con reglas corruptas: esas entradas se descartan', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(respuestaOk({ reglasPorDesarrollo: { SENDA: 'roto', ATELIER: null } })),
|
||||
)
|
||||
|
||||
expect(await getReglasCotizador()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { Unidad, TipoPropuesta } from '../types'
|
||||
import { INVENTARIO_SEED } from '../data/unidades'
|
||||
import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas'
|
||||
import { conAuth, manejar401 } from './auth'
|
||||
import { guardarUnidades, cargarUnidadesLocal } from './offline-db'
|
||||
import {
|
||||
guardarUnidades,
|
||||
cargarUnidadesLocal,
|
||||
guardarReglasLocal,
|
||||
cargarReglasLocal,
|
||||
} from './offline-db'
|
||||
|
||||
// TODO produccion: '/api' same-origin detras de NGINX
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
@@ -38,6 +44,32 @@ export async function getUnidades(): Promise<GetUnidadesResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reglas comerciales del cotizador ----------
|
||||
|
||||
/**
|
||||
* Reglas comerciales EFECTIVAS por desarrollo (`GET /desarrollos/reglas-cotizador`):
|
||||
* las que el admin configuró en la BD, con las constantes de respaldo para los
|
||||
* desarrollos sin reglas guardadas. Nunca lanza: sin servidor devuelve la
|
||||
* última copia guardada y, si no hay, un mapa vacío (todo cae a las constantes).
|
||||
*/
|
||||
export async function getReglasCotizador(): Promise<ReglasDesarrollo> {
|
||||
try {
|
||||
const resp = await fetch(`${API_URL}/desarrollos/reglas-cotizador`, conAuth())
|
||||
manejar401(resp)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const cuerpo = (await resp.json()) as { reglasPorDesarrollo?: unknown } | null
|
||||
const reglas = reglasDesdeApi(cuerpo?.reglasPorDesarrollo)
|
||||
void guardarReglasLocal(reglas)
|
||||
return reglas
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[api] No se pudieron obtener las reglas del cotizador; se usan los valores por defecto.',
|
||||
err,
|
||||
)
|
||||
return reglasDesdeApi(await cargarReglasLocal())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Cotizaciones guardadas (historial del vendedor) ----------
|
||||
|
||||
/** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */
|
||||
|
||||
@@ -32,6 +32,26 @@ export async function cargarUnidadesLocal(): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
// Las reglas del cotizador se guardan en la misma tienda que el inventario
|
||||
// (clave propia), sin subir la versión de la BD.
|
||||
export async function guardarReglasLocal(reglas: unknown) {
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.put('unidades', reglas, 'reglas')
|
||||
} catch {
|
||||
// Sin almacenamiento: la próxima sesión las recarga del API
|
||||
}
|
||||
}
|
||||
|
||||
export async function cargarReglasLocal(): Promise<unknown> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
return db.get('unidades', 'reglas')
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function guardarCotizaciones(cotizaciones: unknown[]) {
|
||||
try {
|
||||
const db = await getDB()
|
||||
|
||||
@@ -75,6 +75,11 @@ export interface PropuestaConfig {
|
||||
modoMensualidades: ModoMensualidades
|
||||
/** SOLANA Infonavit: si pctEnganche es 0, el enganche es 0 (no se resta el apartado) */
|
||||
engancheCeroSinApartado?: boolean
|
||||
/**
|
||||
* Regla del admin: false => la propuesta no se ofrece ni se puede elegir en
|
||||
* el cotizador (tampoco se calcula en el API). Ausente/true => vigente.
|
||||
*/
|
||||
activa?: boolean
|
||||
}
|
||||
|
||||
export interface ParametrosDesarrollo {
|
||||
@@ -96,5 +101,10 @@ export interface ParametrosDesarrollo {
|
||||
cuotaMantenimiento: number | null
|
||||
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
|
||||
prefijoUnidadPdf: string
|
||||
/**
|
||||
* Regla del admin: false => el desarrollo no se ofrece en el cotizador (el
|
||||
* API también rechaza cotizarlo). Ausente/true => disponible.
|
||||
*/
|
||||
activoEnCotizador?: boolean
|
||||
propuestas: PropuestaConfig[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user