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:
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>
|
||||
|
||||
Reference in New Issue
Block a user