/** * 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 const DESARROLLO_DEFAULT = 'DIAGONAL' const TIPOS: ReadonlyArray = [ 'CREDITO_BANCARIO', 'CREDITO_INFONAVIT_COFINAVIT', 'CONTADO', 'SEMI_CONTADO', ] const CLAVES_DESCUENTO: ReadonlyArray = [ 'creditoBancario', 'creditoInfonavit', 'contado', 'semiContado', ] const BASES: ReadonlyArray = ['lista', 'final'] const MODOS: ReadonlyArray = [ '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).includes(valor) } function esModo(valor: unknown): valor is ModoMensualidades { return typeof valor === 'string' && (MODOS as ReadonlyArray).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 const tipo = p['tipo'] const clave = p['claveDescuento'] if (typeof tipo !== 'string' || !(TIPOS as ReadonlyArray).includes(tipo)) return null if (typeof clave !== 'string' || !(CLAVES_DESCUENTO as ReadonlyArray).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 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 /** Desarrollo.id → clave ('DIAGONAL', 'SENDA', …) */ clavePorId: Map } /** Primer valor presente de `claves` en la fila que cumpla el filtro */ function primerValor( fila: Record, claves: ReadonlyArray, 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 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 } | null { const cliente = prisma as unknown as Record 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 } } } 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 { 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( '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 { const porClave = new Map() const clavePorId = new Map() let leidas: unknown[] try { const modelo = (prisma as unknown as Record)['desarrollo'] as | { findMany?: unknown } | undefined if (modelo && typeof modelo.findMany === 'function') { leidas = await (modelo.findMany as (args?: unknown) => Promise)({ select: { id: true, clave: true, nombre: true, activoEnCotizador: true, prefijoUnidadPdf: true }, }) } else if (typeof prisma.$queryRawUnsafe === 'function') { leidas = await prisma.$queryRawUnsafe('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 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)['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> { const guardadas = new Map() 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 { 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 { const id = (desarrolloId ?? DESARROLLO_DEFAULT).trim().toUpperCase() || DESARROLLO_DEFAULT const guardadas = await getReglasGuardadas() return guardadas.get(id) ?? DESARROLLOS[id] ?? DESARROLLOS[DESARROLLO_DEFAULT]! }