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

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