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).
418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
/**
|
|
* Reglas comerciales del cotizador por desarrollo.
|
|
*
|
|
* Las reglas las configura el admin y viven en la tabla `ReglaCotizadorDesarrollo`
|
|
* (una fila por desarrollo, con el JSON de parámetros). Este servicio las lee y
|
|
* las valida; **nunca** puede tumbar una cotización: si el desarrollo no tiene
|
|
* reglas guardadas, la tabla no existe todavía o el JSON viene corrupto, se cae
|
|
* a las constantes de `cotizacion.service.ts` (los valores históricos) y se
|
|
* registra un warning.
|
|
*/
|
|
import { prisma } from './prisma.js'
|
|
import {
|
|
DESARROLLOS,
|
|
type BasePrecio,
|
|
type DescuentosPropuestas,
|
|
type ModoMensualidades,
|
|
type ParametrosDesarrollo,
|
|
type PropuestaConfig,
|
|
type TipoPropuesta,
|
|
} from './cotizacion.service.js'
|
|
|
|
/** Reglas efectivas indexadas por id de desarrollo ('DIAGONAL', 'SENDA', …) */
|
|
export type ReglasDesarrollo = Record<string, ParametrosDesarrollo>
|
|
|
|
const DESARROLLO_DEFAULT = 'DIAGONAL'
|
|
|
|
const TIPOS: ReadonlyArray<TipoPropuesta> = [
|
|
'CREDITO_BANCARIO',
|
|
'CREDITO_INFONAVIT_COFINAVIT',
|
|
'CONTADO',
|
|
'SEMI_CONTADO',
|
|
]
|
|
|
|
const CLAVES_DESCUENTO: ReadonlyArray<keyof DescuentosPropuestas> = [
|
|
'creditoBancario',
|
|
'creditoInfonavit',
|
|
'contado',
|
|
'semiContado',
|
|
]
|
|
|
|
const BASES: ReadonlyArray<BasePrecio> = ['lista', 'final']
|
|
|
|
const MODOS: ReadonlyArray<ModoMensualidades> = [
|
|
'ninguna',
|
|
'remanente',
|
|
'remanenteLista',
|
|
'porcentaje',
|
|
]
|
|
|
|
function esTexto(valor: unknown): valor is string {
|
|
return typeof valor === 'string' && valor.trim() !== ''
|
|
}
|
|
|
|
function esNumero(valor: unknown): valor is number {
|
|
return typeof valor === 'number' && Number.isFinite(valor)
|
|
}
|
|
|
|
function esBase(valor: unknown): valor is BasePrecio {
|
|
return typeof valor === 'string' && (BASES as ReadonlyArray<string>).includes(valor)
|
|
}
|
|
|
|
function esModo(valor: unknown): valor is ModoMensualidades {
|
|
return typeof valor === 'string' && (MODOS as ReadonlyArray<string>).includes(valor)
|
|
}
|
|
|
|
/** Normaliza una propuesta del JSON de la BD; null si está corrupta */
|
|
function normalizarPropuesta(bruto: unknown): PropuestaConfig | null {
|
|
if (typeof bruto !== 'object' || bruto === null) return null
|
|
const p = bruto as Record<string, unknown>
|
|
const tipo = p['tipo']
|
|
const clave = p['claveDescuento']
|
|
if (typeof tipo !== 'string' || !(TIPOS as ReadonlyArray<string>).includes(tipo)) return null
|
|
if (typeof clave !== 'string' || !(CLAVES_DESCUENTO as ReadonlyArray<string>).includes(clave))
|
|
return null
|
|
if (!esTexto(p['titulo'])) return null
|
|
if (!esNumero(p['descuentoDefault']) || !esNumero(p['pctEnganche']) || !esNumero(p['pctPagoFinal']))
|
|
return null
|
|
if (!esBase(p['engancheBase']) || !esBase(p['pagoFinalBase'])) return null
|
|
if (!esModo(p['modoMensualidades'])) return null
|
|
|
|
const propuesta: PropuestaConfig = {
|
|
tipo: tipo as TipoPropuesta,
|
|
claveDescuento: clave as keyof DescuentosPropuestas,
|
|
titulo: p['titulo'].trim(),
|
|
descuentoDefault: p['descuentoDefault'],
|
|
pctEnganche: p['pctEnganche'],
|
|
engancheBase: p['engancheBase'],
|
|
pctPagoFinal: p['pctPagoFinal'],
|
|
pagoFinalBase: p['pagoFinalBase'],
|
|
modoMensualidades: p['modoMensualidades'],
|
|
}
|
|
if (p['engancheCeroSinApartado'] === true) propuesta.engancheCeroSinApartado = true
|
|
const activa = p['activa'] ?? p['activo']
|
|
if (typeof activa === 'boolean') propuesta.activa = activa
|
|
return propuesta
|
|
}
|
|
|
|
/**
|
|
* Valida y normaliza el JSON de reglas de un desarrollo. Devuelve null (para
|
|
* que el llamador caiga a las constantes) si el JSON no tiene la forma esperada.
|
|
*/
|
|
export function normalizarReglas(
|
|
desarrolloId: string,
|
|
bruto: unknown,
|
|
): ParametrosDesarrollo | null {
|
|
if (typeof bruto !== 'object' || bruto === null || Array.isArray(bruto)) return null
|
|
const r = bruto as Record<string, unknown>
|
|
|
|
if (typeof r['precioUnico'] !== 'boolean') return null
|
|
const fuente = r['fuenteDeN']
|
|
if (fuente !== 'fechaEntrega' && fuente !== 'fijo' && fuente !== 'porUnidad') return null
|
|
|
|
const propuestasBrutas = r['propuestas']
|
|
if (!Array.isArray(propuestasBrutas) || propuestasBrutas.length === 0) return null
|
|
const propuestas: PropuestaConfig[] = []
|
|
for (const propuestaBruta of propuestasBrutas) {
|
|
const propuesta = normalizarPropuesta(propuestaBruta)
|
|
if (!propuesta) return null // una propuesta corrupta invalida el JSON completo
|
|
propuestas.push(propuesta)
|
|
}
|
|
|
|
const id = desarrolloId.trim().toUpperCase()
|
|
const params: ParametrosDesarrollo = {
|
|
id,
|
|
precioUnico: r['precioUnico'],
|
|
fuenteDeN: fuente,
|
|
propuestas,
|
|
}
|
|
if (esTexto(r['nombre'])) params.nombre = r['nombre'].trim()
|
|
if (esNumero(r['nFijo'])) params.nFijo = r['nFijo']
|
|
if (esNumero(r['nDefault'])) params.nDefault = r['nDefault']
|
|
if (r['cuotaMantenimiento'] === null) params.cuotaMantenimiento = null
|
|
else if (esNumero(r['cuotaMantenimiento'])) params.cuotaMantenimiento = r['cuotaMantenimiento']
|
|
if (esTexto(r['prefijoUnidadPdf'])) params.prefijoUnidadPdf = r['prefijoUnidadPdf'].trim()
|
|
const activo = r['activoEnCotizador'] ?? r['activo']
|
|
if (typeof activo === 'boolean') params.activoEnCotizador = activo
|
|
return params
|
|
}
|
|
|
|
/** Fila de reglas ya mapeada a nombres de campo conocidos */
|
|
interface FilaReglas {
|
|
desarrolloId: string
|
|
parametros: unknown
|
|
activoEnCotizador: boolean | undefined
|
|
}
|
|
|
|
/** Ficha mínima del desarrollo (tabla `Desarrollo`): la clave y sus banderas */
|
|
interface FichaDesarrollo {
|
|
clave: string
|
|
activoEnCotizador: boolean | undefined
|
|
nombre: string | undefined
|
|
prefijoUnidadPdf: string | undefined
|
|
}
|
|
|
|
interface CatalogoDesarrollos {
|
|
porClave: Map<string, FichaDesarrollo>
|
|
/** Desarrollo.id → clave ('DIAGONAL', 'SENDA', …) */
|
|
clavePorId: Map<string, string>
|
|
}
|
|
|
|
/** Primer valor presente de `claves` en la fila que cumpla el filtro */
|
|
function primerValor(
|
|
fila: Record<string, unknown>,
|
|
claves: ReadonlyArray<string>,
|
|
acepta: (valor: unknown) => boolean,
|
|
): unknown {
|
|
for (const clave of claves) {
|
|
const valor = fila[clave]
|
|
if (valor !== undefined && valor !== null && acepta(valor)) return valor
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Booleanos de SQLite crudo: 1/0 además de true/false */
|
|
function esBandera(valor: unknown): valor is boolean | 0 | 1 {
|
|
return typeof valor === 'boolean' || valor === 0 || valor === 1
|
|
}
|
|
|
|
function aBandera(valor: unknown): boolean | undefined {
|
|
if (valor === true || valor === 1) return true
|
|
if (valor === false || valor === 0) return false
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Mapea una fila cruda (Prisma o SQL) a `FilaReglas`. Los nombres de columna se
|
|
* resuelven por heurística para tolerar cambios del modelo mientras otro agente
|
|
* construye el módulo de administración: `desarrolloId`/`desarrollo` y
|
|
* `parametrosJson`/`parametros`/`reglas`.
|
|
*/
|
|
function mapearFila(fila: unknown): FilaReglas | null {
|
|
if (typeof fila !== 'object' || fila === null) return null
|
|
const f = fila as Record<string, unknown>
|
|
const desarrollo = primerValor(
|
|
f,
|
|
['desarrolloId', 'desarrollo', 'desarrollo_id', 'proyecto', 'id'],
|
|
esTexto,
|
|
)
|
|
const parametros = primerValor(
|
|
f,
|
|
['parametrosJson', 'parametros_json', 'parametros', 'reglas'],
|
|
(v) => typeof v === 'string' || typeof v === 'object',
|
|
)
|
|
if (typeof desarrollo !== 'string') return null
|
|
const activo = primerValor(f, ['activoEnCotizador', 'activo'], esBandera)
|
|
return {
|
|
desarrolloId: desarrollo,
|
|
parametros,
|
|
activoEnCotizador: aBandera(activo),
|
|
}
|
|
}
|
|
|
|
/** Cliente Prisma con el modelo de reglas, si ya está generado */
|
|
function modeloReglas(): { findMany: (args?: unknown) => Promise<unknown[]> } | null {
|
|
const cliente = prisma as unknown as Record<string, unknown>
|
|
for (const nombre of ['reglaCotizadorDesarrollo', 'reglaCotizador', 'reglasCotizador']) {
|
|
const modelo = cliente[nombre] as { findMany?: unknown } | undefined
|
|
if (modelo && typeof modelo.findMany === 'function') {
|
|
return modelo as { findMany: (args?: unknown) => Promise<unknown[]> }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Filas de reglas guardadas. Primero intenta el modelo de Prisma; si el cliente
|
|
* todavía no lo tiene generado, lee la tabla por SQL crudo. Cualquier fallo se
|
|
* traduce en "no hay reglas guardadas" (nunca en un error de cotización).
|
|
*/
|
|
async function leerFilasReglas(): Promise<FilaReglas[]> {
|
|
const modelo = modeloReglas()
|
|
if (modelo) {
|
|
const filas = await modelo.findMany()
|
|
return filas.map(mapearFila).filter((f): f is FilaReglas => f !== null)
|
|
}
|
|
const crudo = (prisma as unknown as { $queryRawUnsafe?: unknown }).$queryRawUnsafe
|
|
if (typeof crudo !== 'function') return []
|
|
const filas = await prisma.$queryRawUnsafe<unknown[]>(
|
|
'SELECT * FROM "ReglaCotizadorDesarrollo"',
|
|
)
|
|
return (Array.isArray(filas) ? filas : []).map(mapearFila).filter((f): f is FilaReglas => f !== null)
|
|
}
|
|
|
|
let avisoCatalogoEmitido = false
|
|
let avisoLecturaEmitido = false
|
|
|
|
/**
|
|
* Catálogo de desarrollos (tabla `Desarrollo`): de ahí sale la clave real de
|
|
* cada fila de reglas (las reglas apuntan al id del desarrollo) y la bandera
|
|
* `activoEnCotizador`. Tolerante: si la tabla no existe todavía, el catálogo
|
|
* queda vacío y las reglas se resuelven por la clave que traiga el JSON.
|
|
*/
|
|
async function leerCatalogo(): Promise<CatalogoDesarrollos> {
|
|
const porClave = new Map<string, FichaDesarrollo>()
|
|
const clavePorId = new Map<string, string>()
|
|
let leidas: unknown[]
|
|
try {
|
|
const modelo = (prisma as unknown as Record<string, unknown>)['desarrollo'] as
|
|
| { findMany?: unknown }
|
|
| undefined
|
|
if (modelo && typeof modelo.findMany === 'function') {
|
|
leidas = await (modelo.findMany as (args?: unknown) => Promise<unknown[]>)({
|
|
select: { id: true, clave: true, nombre: true, activoEnCotizador: true, prefijoUnidadPdf: true },
|
|
})
|
|
} else if (typeof prisma.$queryRawUnsafe === 'function') {
|
|
leidas = await prisma.$queryRawUnsafe<unknown[]>('SELECT * FROM "Desarrollo"')
|
|
} else {
|
|
return { porClave, clavePorId }
|
|
}
|
|
} catch (err) {
|
|
if (!avisoCatalogoEmitido) {
|
|
avisoCatalogoEmitido = true
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
'[reglas-cotizador] No se pudo leer el catálogo de desarrollos; se usan las constantes.',
|
|
err,
|
|
)
|
|
}
|
|
return { porClave, clavePorId }
|
|
}
|
|
|
|
for (const fila of Array.isArray(leidas) ? leidas : []) {
|
|
if (typeof fila !== 'object' || fila === null) continue
|
|
const f = fila as Record<string, unknown>
|
|
const clave = primerValor(f, ['clave', 'desarrolloId', 'desarrollo'], esTexto)
|
|
if (typeof clave !== 'string') continue
|
|
const nombre = primerValor(f, ['nombre'], esTexto)
|
|
const prefijo = primerValor(f, ['prefijoUnidadPdf'], esTexto)
|
|
const ficha: FichaDesarrollo = {
|
|
clave: clave.trim().toUpperCase(),
|
|
activoEnCotizador: aBandera(primerValor(f, ['activoEnCotizador', 'activo'], esBandera)),
|
|
nombre: typeof nombre === 'string' ? nombre : undefined,
|
|
prefijoUnidadPdf: typeof prefijo === 'string' ? prefijo : undefined,
|
|
}
|
|
porClave.set(ficha.clave, ficha)
|
|
const id = primerValor(f, ['id'], esTexto)
|
|
if (typeof id === 'string') clavePorId.set(id, ficha.clave)
|
|
}
|
|
return { porClave, clavePorId }
|
|
}
|
|
|
|
/**
|
|
* Clave del desarrollo de una fila de reglas: primero el id guardado (uuid) que
|
|
* se resuelve con el catálogo, luego el `id` del propio JSON y, si no, la clave
|
|
* que venga en la columna.
|
|
*/
|
|
function claveDeFila(
|
|
fila: FilaReglas,
|
|
bruto: unknown,
|
|
catalogo: CatalogoDesarrollos,
|
|
): string | null {
|
|
const crudo = fila.desarrolloId.trim()
|
|
const delCatalogo = catalogo.clavePorId.get(crudo)
|
|
if (delCatalogo !== undefined) return delCatalogo
|
|
if (typeof bruto === 'object' && bruto !== null) {
|
|
const idJson = (bruto as Record<string, unknown>)['id']
|
|
if (esTexto(idJson)) return idJson.trim().toUpperCase()
|
|
}
|
|
return crudo === '' ? null : crudo.toUpperCase()
|
|
}
|
|
|
|
/**
|
|
* Reglas configuradas por el admin, indexadas por clave de desarrollo. Se
|
|
* descartan (con warning) las filas con JSON corrupto: esos desarrollos usan sus
|
|
* constantes.
|
|
*/
|
|
export async function getReglasGuardadas(): Promise<Map<string, ParametrosDesarrollo>> {
|
|
const guardadas = new Map<string, ParametrosDesarrollo>()
|
|
let filas: FilaReglas[]
|
|
try {
|
|
filas = await leerFilasReglas()
|
|
} catch (err) {
|
|
if (!avisoLecturaEmitido) {
|
|
avisoLecturaEmitido = true
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
'[reglas-cotizador] No se pudieron leer las reglas guardadas (¿tabla ReglaCotizadorDesarrollo sin crear?); se usan las constantes.',
|
|
err,
|
|
)
|
|
}
|
|
return guardadas
|
|
}
|
|
if (filas.length === 0) return guardadas
|
|
|
|
const catalogo = await leerCatalogo()
|
|
|
|
for (const fila of filas) {
|
|
if (fila.parametros === undefined) continue
|
|
let bruto = fila.parametros
|
|
if (typeof bruto === 'string') {
|
|
try {
|
|
bruto = JSON.parse(bruto)
|
|
} catch {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[reglas-cotizador] JSON inválido para el desarrollo ${fila.desarrolloId}; se usan las constantes.`,
|
|
)
|
|
continue
|
|
}
|
|
}
|
|
const clave = claveDeFila(fila, bruto, catalogo)
|
|
if (clave === null) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn('[reglas-cotizador] Fila de reglas sin desarrollo; se ignora.')
|
|
continue
|
|
}
|
|
const reglas = normalizarReglas(clave, bruto)
|
|
if (!reglas) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[reglas-cotizador] Reglas con forma inesperada para el desarrollo ${clave}; se usan las constantes.`,
|
|
)
|
|
continue
|
|
}
|
|
|
|
// La ficha del desarrollo completa lo que el JSON no traiga
|
|
const ficha = catalogo.porClave.get(clave)
|
|
if (reglas.nombre === undefined && ficha?.nombre !== undefined) reglas.nombre = ficha.nombre
|
|
if (reglas.prefijoUnidadPdf === undefined && ficha?.prefijoUnidadPdf !== undefined) {
|
|
reglas.prefijoUnidadPdf = ficha.prefijoUnidadPdf
|
|
}
|
|
// El desarrollo está activo solo si TODAS las fuentes que hablan de él lo
|
|
// dejan activo (la bandera vive en la ficha del desarrollo)
|
|
const banderas = [fila.activoEnCotizador, reglas.activoEnCotizador, ficha?.activoEnCotizador].filter(
|
|
(valor): valor is boolean => typeof valor === 'boolean',
|
|
)
|
|
if (banderas.length > 0) reglas.activoEnCotizador = banderas.every((valor) => valor)
|
|
|
|
guardadas.set(reglas.id, reglas)
|
|
}
|
|
return guardadas
|
|
}
|
|
|
|
/**
|
|
* Reglas efectivas de TODOS los desarrollos: lo guardado por el admin gana; los
|
|
* desarrollos sin reglas (o con JSON corrupto) caen a las constantes actuales.
|
|
*/
|
|
export async function getReglasEfectivas(): Promise<ReglasDesarrollo> {
|
|
const guardadas = await getReglasGuardadas()
|
|
const efectivas: ReglasDesarrollo = {}
|
|
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
|
|
efectivas[id] = guardadas.get(id) ?? constantes
|
|
}
|
|
for (const [id, reglas] of guardadas) {
|
|
if (efectivas[id] === undefined) efectivas[id] = reglas
|
|
}
|
|
return efectivas
|
|
}
|
|
|
|
/** Reglas efectivas de un desarrollo; cae a las constantes (DIAGONAL si es desconocido) */
|
|
export async function getReglasDesarrollo(
|
|
desarrolloId: string | null | undefined,
|
|
): Promise<ParametrosDesarrollo> {
|
|
const id = (desarrolloId ?? DESARROLLO_DEFAULT).trim().toUpperCase() || DESARROLLO_DEFAULT
|
|
const guardadas = await getReglasGuardadas()
|
|
return guardadas.get(id) ?? DESARROLLOS[id] ?? DESARROLLOS[DESARROLLO_DEFAULT]!
|
|
}
|