Files
FRASA-CAS/api-gateway/src/services/reglas-desarrollo.service.ts
Consult AS Technology d6548999bf 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).
2026-09-21 09:52:47 +00:00

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]!
}