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:
2026-09-21 09:52:47 +00:00
parent 2fc80e7f9e
commit d6548999bf
68 changed files with 10016 additions and 66 deletions

View File

@@ -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}

View File

@@ -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 */

View 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)
})
})

View File

@@ -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,
}

View File

@@ -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 }
}

View File

@@ -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),
)
}

View File

@@ -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`)

View 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'])
})
})

View 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
})
}

View 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()
})
})

View File

@@ -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>

View File

@@ -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({})
})
})

View File

@@ -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) */

View File

@@ -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()

View File

@@ -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[]
}