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:
@@ -15,6 +15,7 @@
|
||||
"seed:ai": "tsx scripts/seed-ai.ts",
|
||||
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
|
||||
"seed:config": "tsx scripts/seed-config.js",
|
||||
"seed:desarrollos": "tsx scripts/seed-desarrollos.js",
|
||||
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"seed:catalogos": "node scripts/actualizar-catalogos-ia.js"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Módulo Desarrollos: catálogo de desarrollos (ficha, modelos y reglas del
|
||||
-- cotizador) más el piso (`nivel`) del inventario.
|
||||
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
-- CreateTable Desarrollo
|
||||
CREATE TABLE IF NOT EXISTS "Desarrollo" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"clave" TEXT NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"estado" TEXT NOT NULL DEFAULT 'ACTIVO',
|
||||
"tipo" TEXT NOT NULL DEFAULT 'VERTICAL',
|
||||
"constructora" TEXT,
|
||||
"direccion" TEXT,
|
||||
"zona" TEXT,
|
||||
"descripcion" TEXT,
|
||||
"amenidades" TEXT,
|
||||
"fechaInicioObra" DATETIME,
|
||||
"fechaEntregaProgramada" DATETIME,
|
||||
"fechaEntregaReprogramada" DATETIME,
|
||||
"activoEnCotizador" BOOLEAN NOT NULL DEFAULT true,
|
||||
"activoEnAlta" BOOLEAN NOT NULL DEFAULT true,
|
||||
"prefijoUnidadPdf" TEXT,
|
||||
"imagenUrl" TEXT,
|
||||
"logoUrl" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "Desarrollo_clave_key" ON "Desarrollo"("clave");
|
||||
CREATE INDEX IF NOT EXISTS "Desarrollo_estado_idx" ON "Desarrollo"("estado");
|
||||
|
||||
-- CreateTable ModeloDesarrollo
|
||||
CREATE TABLE IF NOT EXISTS "ModeloDesarrollo" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"desarrolloId" TEXT NOT NULL,
|
||||
"nombre" TEXT NOT NULL,
|
||||
"recamaras" INTEGER,
|
||||
"banos" REAL,
|
||||
"estacionamientos" INTEGER,
|
||||
"supConstruccion" REAL,
|
||||
"activo" BOOLEAN NOT NULL DEFAULT true,
|
||||
CONSTRAINT "ModeloDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_nombre_key" ON "ModeloDesarrollo"("desarrolloId", "nombre");
|
||||
CREATE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_idx" ON "ModeloDesarrollo"("desarrolloId");
|
||||
|
||||
-- CreateTable ReglaCotizadorDesarrollo
|
||||
-- `desarrolloId` guarda el uuid de `Desarrollo.id` (lo que escriben el seed y
|
||||
-- PUT /desarrollos/:id/reglas). El endpoint del cotizador traduce uuid → clave.
|
||||
CREATE TABLE IF NOT EXISTS "ReglaCotizadorDesarrollo" (
|
||||
"desarrolloId" TEXT NOT NULL PRIMARY KEY,
|
||||
"parametrosJson" TEXT NOT NULL,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"updatedPorId" TEXT,
|
||||
CONSTRAINT "ReglaCotizadorDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- AlterTable Unidad: piso del inventario ('' si no aplica)
|
||||
ALTER TABLE "Unidad" ADD COLUMN "nivel" TEXT NOT NULL DEFAULT '';
|
||||
@@ -211,6 +211,7 @@ model Unidad {
|
||||
modelo String @default("")
|
||||
manzana String @default("")
|
||||
lote String @default("")
|
||||
nivel String @default("") // piso (desarrollos verticales); '' si no aplica
|
||||
supConstruccion Float @default(0)
|
||||
supExcedente Float @default(0)
|
||||
supEstacionamiento Float @default(0)
|
||||
@@ -530,3 +531,61 @@ model SolicitudSoporte {
|
||||
@@index([usuarioId])
|
||||
@@index([departamento])
|
||||
}
|
||||
|
||||
// ── Módulo Desarrollos: catálogo de desarrollos nuevos ────────────────
|
||||
// La BD es la fuente de verdad: la ficha del desarrollo, sus modelos y las
|
||||
// reglas del cotizador (antes constantes fijas en el código de las apps).
|
||||
|
||||
model Desarrollo {
|
||||
id String @id @default(uuid())
|
||||
clave String @unique // SENDA, DIAGONAL… (forma los códigos de unidad)
|
||||
nombre String // nombre comercial
|
||||
estado String @default("ACTIVO") // ACTIVO | PREVENTA | CERRADO
|
||||
tipo String @default("VERTICAL") // VERTICAL | HORIZONTAL
|
||||
constructora String?
|
||||
direccion String?
|
||||
zona String?
|
||||
descripcion String?
|
||||
amenidades String? // texto libre o lista separada por comas
|
||||
fechaInicioObra DateTime?
|
||||
fechaEntregaProgramada DateTime?
|
||||
fechaEntregaReprogramada DateTime?
|
||||
activoEnCotizador Boolean @default(true) // aparece en el cotizador
|
||||
activoEnAlta Boolean @default(true) // aparece en altas de lead/afluencia/cliente
|
||||
prefijoUnidadPdf String? // p. ej. CASA (lo usa el PDF del cotizador)
|
||||
imagenUrl String?
|
||||
logoUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
modelos ModeloDesarrollo[]
|
||||
reglas ReglaCotizadorDesarrollo?
|
||||
|
||||
@@index([estado])
|
||||
}
|
||||
|
||||
model ModeloDesarrollo {
|
||||
id String @id @default(uuid())
|
||||
desarrolloId String
|
||||
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
|
||||
nombre String
|
||||
recamaras Int?
|
||||
banos Float?
|
||||
estacionamientos Int?
|
||||
supConstruccion Float?
|
||||
activo Boolean @default(true)
|
||||
|
||||
@@unique([desarrolloId, nombre])
|
||||
@@index([desarrolloId])
|
||||
}
|
||||
|
||||
model ReglaCotizadorDesarrollo {
|
||||
// `desarrolloId` guarda el uuid de `Desarrollo.id` (es lo que escriben el seed
|
||||
// y PUT /desarrollos/:id/reglas). El endpoint que consume el cotizador
|
||||
// (`/desarrollos/reglas-cotizador`) traduce uuid → clave antes de responder.
|
||||
desarrolloId String @id
|
||||
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
|
||||
parametrosJson String // JSON con la forma ParametrosDesarrollo
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedPorId String?
|
||||
}
|
||||
|
||||
157
api-gateway/scripts/seed-desarrollos.js
Normal file
157
api-gateway/scripts/seed-desarrollos.js
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Siembra el catálogo `Desarrollo` + `ModeloDesarrollo` + `ReglaCotizadorDesarrollo`
|
||||
* desde la realidad actual del sistema:
|
||||
*
|
||||
* - Desarrollos y modelos: lo que hoy hay en la tabla `Unidad` (espejo del
|
||||
* inventario), más SOLE y MUTUO, que existen en captura y altas pero todavía
|
||||
* no tienen inventario local.
|
||||
* - Reglas del cotizador: copiadas (replicadas, no importadas) de las
|
||||
* constantes de apps/cotizador/src/data/desarrollos.ts, que es la config con
|
||||
* la que hoy cotiza producción.
|
||||
*
|
||||
* Idempotente: upsert por `clave`. NUNCA pisa lo ya editado: si la ficha o las
|
||||
* reglas existen, el seed no las toca (el admin manda).
|
||||
*
|
||||
* Uso: npm run seed:desarrollos (tsx: la fuente de las reglas es TypeScript)
|
||||
*/
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import dotenv from 'dotenv'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { DESARROLLOS as PARAMETROS_COTIZADOR } from '../../apps/cotizador/src/data/desarrollos.ts'
|
||||
|
||||
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
/**
|
||||
* Desarrollos sin inventario local que sí existen en el ecosistema (captura y
|
||||
* altas de lead/afluencia/cliente). Los modelos de SOLE vienen de la lista
|
||||
* real del alta de clientes (apps/alta-clientes/src/data/constantes.ts);
|
||||
* MUTUO captura el modelo en texto libre, así que se siembra sin modelos.
|
||||
*/
|
||||
const SIN_INVENTARIO = [
|
||||
{ clave: 'SOLE', nombre: 'Sole', tipo: 'VERTICAL', modelos: ['ALBA', 'HORIZONTE', 'OCASO'] },
|
||||
{ clave: 'MUTUO', nombre: 'Mutuo', tipo: 'VERTICAL', modelos: [] },
|
||||
]
|
||||
|
||||
const normalizar = (v) => String(v ?? '').trim().toUpperCase()
|
||||
|
||||
/** Nombre comercial: el del cotizador si existe; si no, la clave capitalizada */
|
||||
function nombreComercial(clave) {
|
||||
const delCotizador = PARAMETROS_COTIZADOR[clave]?.nombre
|
||||
if (typeof delCotizador === 'string' && delCotizador !== '') return delCotizador
|
||||
return clave.charAt(0) + clave.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [porDesarrollo, modelosInventario] = await Promise.all([
|
||||
prisma.unidad.groupBy({ by: ['desarrollo'], _count: { codigo: true } }),
|
||||
prisma.unidad.groupBy({ by: ['desarrollo', 'modelo'], _count: { codigo: true } }),
|
||||
])
|
||||
|
||||
if (porDesarrollo.length === 0) {
|
||||
throw new Error('La tabla Unidad está vacía: corre primero `npm run seed:unidades`')
|
||||
}
|
||||
|
||||
const claves = new Set(porDesarrollo.map((d) => normalizar(d.desarrollo)))
|
||||
for (const extra of SIN_INVENTARIO) claves.add(extra.clave)
|
||||
|
||||
const modelosPorClave = new Map()
|
||||
for (const fila of modelosInventario) {
|
||||
const clave = normalizar(fila.desarrollo)
|
||||
const modelo = String(fila.modelo ?? '').trim()
|
||||
if (modelo === '') continue
|
||||
if (!modelosPorClave.has(clave)) modelosPorClave.set(clave, new Set())
|
||||
modelosPorClave.get(clave).add(modelo)
|
||||
}
|
||||
for (const extra of SIN_INVENTARIO) {
|
||||
if (!modelosPorClave.has(extra.clave)) modelosPorClave.set(extra.clave, new Set())
|
||||
for (const modelo of extra.modelos) modelosPorClave.get(extra.clave).add(modelo)
|
||||
}
|
||||
|
||||
let fichasNuevas = 0
|
||||
let fichasExistentes = 0
|
||||
let modelosNuevos = 0
|
||||
let reglasNuevas = 0
|
||||
|
||||
for (const clave of [...claves].sort()) {
|
||||
const extra = SIN_INVENTARIO.find((e) => e.clave === clave)
|
||||
const parametros = PARAMETROS_COTIZADOR[clave]
|
||||
|
||||
const previa = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
|
||||
const ficha = await prisma.desarrollo.upsert({
|
||||
where: { clave },
|
||||
// No pisa lo ya editado por el admin: si existe, se deja como está
|
||||
update: {},
|
||||
create: {
|
||||
clave,
|
||||
nombre: extra?.nombre ?? nombreComercial(clave),
|
||||
// `tipo` no se deduce del inventario actual (los códigos y el prefijo
|
||||
// del PDF no lo distinguen): queda el default VERTICAL y el admin lo
|
||||
// ajusta desde el módulo.
|
||||
tipo: extra?.tipo ?? 'VERTICAL',
|
||||
prefijoUnidadPdf: typeof parametros?.prefijoUnidadPdf === 'string' ? parametros.prefijoUnidadPdf : null,
|
||||
},
|
||||
})
|
||||
if (previa) fichasExistentes++
|
||||
else fichasNuevas++
|
||||
|
||||
for (const nombre of [...(modelosPorClave.get(clave) ?? [])].sort()) {
|
||||
const modeloPrevio = await prisma.modeloDesarrollo.findUnique({
|
||||
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
|
||||
select: { id: true },
|
||||
})
|
||||
await prisma.modeloDesarrollo.upsert({
|
||||
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
|
||||
update: {},
|
||||
create: { desarrolloId: ficha.id, nombre },
|
||||
})
|
||||
if (!modeloPrevio) modelosNuevos++
|
||||
}
|
||||
|
||||
if (parametros) {
|
||||
const reglaPrevia = await prisma.reglaCotizadorDesarrollo.findUnique({
|
||||
where: { desarrolloId: ficha.id },
|
||||
select: { desarrolloId: true },
|
||||
})
|
||||
if (!reglaPrevia) {
|
||||
await prisma.reglaCotizadorDesarrollo.create({
|
||||
data: { desarrolloId: ficha.id, parametrosJson: JSON.stringify(parametros) },
|
||||
})
|
||||
reglasNuevas++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = await prisma.desarrollo.count()
|
||||
const conReglas = await prisma.reglaCotizadorDesarrollo.count()
|
||||
const totalModelos = await prisma.modeloDesarrollo.count()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[seed-desarrollos] fichas: ${fichasNuevas} nueva(s), ${fichasExistentes} ya existían; ` +
|
||||
`${modelosNuevos} modelo(s) nuevo(s); ${reglasNuevas} regla(s) de cotizador copiada(s)`,
|
||||
)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[seed-desarrollos] total en BD: ${total} desarrollos, ${totalModelos} modelos, ${conReglas} con reglas`)
|
||||
|
||||
for (const d of await prisma.desarrollo.findMany({
|
||||
orderBy: { clave: 'asc' },
|
||||
include: { modelos: { orderBy: { nombre: 'asc' } }, reglas: { select: { desarrolloId: true } } },
|
||||
})) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
` ${d.clave} (${d.estado}/${d.tipo}) — ${d.modelos.length} modelo(s)` +
|
||||
`${d.reglas ? '' : ' — SIN reglas de cotizador'}: ${d.modelos.map((m) => m.nombre).join(', ') || '—'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('[seed-desarrollos] error:', err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -15,6 +15,7 @@ export const MODULOS = [
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
'desarrollos',
|
||||
] as const
|
||||
export type Modulo = (typeof MODULOS)[number]
|
||||
|
||||
@@ -50,7 +51,8 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
// manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos
|
||||
manager: MODULOS,
|
||||
// ni dar de alta desarrollos (módulo exclusivo del admin)
|
||||
manager: MODULOS.filter((m) => m !== 'desarrollos'),
|
||||
admin: MODULOS,
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,17 @@ const prismaMock = vi.hoisted(() => ({
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
// Reglas comerciales configurables por el admin (vacías = constantes)
|
||||
reglaCotizadorDesarrollo: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
|
||||
import { DESARROLLOS } from '../services/cotizacion.service.js'
|
||||
|
||||
/**
|
||||
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
|
||||
@@ -91,6 +96,7 @@ beforeEach(() => {
|
||||
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
|
||||
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
|
||||
prismaMock.automatizacionState.upsert.mockResolvedValue({})
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
|
||||
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
|
||||
})
|
||||
|
||||
@@ -233,6 +239,107 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('crearCotizacion — reglas comerciales del admin', () => {
|
||||
/** Reglas guardadas del desarrollo, con los ajustes del test */
|
||||
function reglasGuardadas(
|
||||
desarrollo: string,
|
||||
ajustes: { propuestaInactiva?: string; activoEnCotizador?: boolean; descuentoContado?: number } = {},
|
||||
) {
|
||||
const base = DESARROLLOS[desarrollo]!
|
||||
return [
|
||||
{
|
||||
desarrolloId: desarrollo,
|
||||
parametrosJson: JSON.stringify({
|
||||
...base,
|
||||
...(ajustes.activoEnCotizador !== undefined
|
||||
? { activoEnCotizador: ajustes.activoEnCotizador }
|
||||
: {}),
|
||||
propuestas: base.propuestas.map((p) => ({
|
||||
...p,
|
||||
activa: p.tipo !== ajustes.propuestaInactiva,
|
||||
...(p.tipo === 'CONTADO' && ajustes.descuentoContado !== undefined
|
||||
? { descuentoDefault: ajustes.descuentoContado }
|
||||
: {}),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
it('sin reglas guardadas cotiza igual que siempre (respaldo en constantes)', async () => {
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0 }),
|
||||
})
|
||||
})
|
||||
|
||||
it('usa el descuento configurado por el admin', async () => {
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
||||
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
|
||||
)
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ descuento: 0.1, precioFinal: 900_000 }),
|
||||
})
|
||||
})
|
||||
|
||||
it('propuesta desactivada → 409 con el mensaje del servidor y sin persistir', async () => {
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
||||
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
|
||||
)
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining('La propuesta BANCARIO no está disponible'),
|
||||
})
|
||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
||||
|
||||
// Las otras propuestas siguen cotizándose
|
||||
const { res: res2, status } = resFake()
|
||||
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res2)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
})
|
||||
|
||||
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
|
||||
reglasGuardadas('DIAGONAL', { activoEnCotizador: false }),
|
||||
)
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining('no está disponible en el cotizador'),
|
||||
})
|
||||
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('JSON corrupto en la BD: cotiza con las constantes (un warning, sin fallar)', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: '{corrupto' },
|
||||
])
|
||||
const { res, status } = resFake()
|
||||
|
||||
await crearCotizacion(reqFake(bodyBase), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ descuento: 0, precioLista: 1_000_000 }),
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => {
|
||||
it('crea una ActividadLocal al vendedor de cada cotización reciente, con dedup', async () => {
|
||||
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { getUnidadLocal } from '../services/unidades-local.service.js'
|
||||
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
|
||||
import {
|
||||
calcularPropuesta,
|
||||
obtenerPrecioLista,
|
||||
propuestasVigentes,
|
||||
TIPO_PROPUESTA_API,
|
||||
} from '../services/cotizacion.service.js'
|
||||
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
|
||||
import {
|
||||
procesarAlertasInventario,
|
||||
type ResultadoAlertas,
|
||||
@@ -76,6 +82,24 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
// el mismo inmueble ya apartado/vendido
|
||||
validarUnidadDisponible(unidad)
|
||||
|
||||
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
|
||||
// respaldo en las constantes históricas. Un desarrollo o una propuesta
|
||||
// desactivados se rechazan con el mensaje que muestra el cotizador.
|
||||
const reglas = await getReglasDesarrollo(unidad.desarrollo)
|
||||
if (reglas.activoEnCotizador === false) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
|
||||
)
|
||||
}
|
||||
const tipoInterno = TIPO_PROPUESTA_API[body.tipoPropuesta]
|
||||
if (!propuestasVigentes(reglas).some((p) => p.tipo === tipoInterno)) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`La propuesta ${body.tipoPropuesta} no está disponible para el desarrollo ${reglas.id}. Elige otra propuesta.`,
|
||||
)
|
||||
}
|
||||
|
||||
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
|
||||
const propuesta = calcularPropuesta(
|
||||
unidad,
|
||||
@@ -85,6 +109,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
body.descuento,
|
||||
body.enganchePct,
|
||||
body.numMensualidades,
|
||||
reglas,
|
||||
)
|
||||
const precioLista = obtenerPrecioLista(unidad, body.acabados)
|
||||
|
||||
|
||||
524
api-gateway/src/controllers/desarrollos.controller.test.ts
Normal file
524
api-gateway/src/controllers/desarrollos.controller.test.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// ---------- Prisma mockeado (el servicio corre de verdad) ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
desarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
modeloDesarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
reglaCotizadorDesarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
unidad: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
cotizacion: { count: vi.fn(), findMany: vi.fn() },
|
||||
leadCaptura: { count: vi.fn(), findMany: vi.fn() },
|
||||
cliente: { count: vi.fn(), findMany: vi.fn() },
|
||||
planPago: { count: vi.fn(), findMany: vi.fn() },
|
||||
auditoriaLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { requireAdminEstricto } from './config.controller.js'
|
||||
import {
|
||||
actualizarDesarrolloEndpoint,
|
||||
agregarModeloEndpoint,
|
||||
crearDesarrolloEndpoint,
|
||||
crearUnidadesEndpoint,
|
||||
eliminarDesarrolloEndpoint,
|
||||
eliminarUnidadEndpoint,
|
||||
guardarReglasEndpoint,
|
||||
listarDesarrollosEndpoint,
|
||||
listarDesarrollosPublicosEndpoint,
|
||||
obtenerDesarrolloPublicoEndpoint,
|
||||
} from './desarrollos.controller.js'
|
||||
|
||||
function reqFake(opts: { body?: unknown; params?: Record<string, string>; query?: Record<string, string>; token?: { sub: string; rol: string } }): Request {
|
||||
return {
|
||||
body: opts.body ?? {},
|
||||
params: opts.params ?? {},
|
||||
query: opts.query ?? {},
|
||||
usuarioToken: opts.token,
|
||||
} as unknown as Request
|
||||
}
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
const end = vi.fn()
|
||||
const statusFn = vi.fn().mockReturnValue({ json, end })
|
||||
return { res: { json, end, status: statusFn } as unknown as Response, status: statusFn, json, end }
|
||||
}
|
||||
|
||||
const ADMIN = { sub: 'u-admin', rol: 'admin' }
|
||||
|
||||
function filaDesarrollo(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd-1',
|
||||
clave: 'SENDA',
|
||||
nombre: 'Senda',
|
||||
estado: 'ACTIVO',
|
||||
tipo: 'VERTICAL',
|
||||
constructora: null,
|
||||
direccion: null,
|
||||
zona: 'Zapopan',
|
||||
descripcion: null,
|
||||
amenidades: null,
|
||||
fechaInicioObra: null,
|
||||
fechaEntregaProgramada: null,
|
||||
fechaEntregaReprogramada: null,
|
||||
activoEnCotizador: true,
|
||||
activoEnAlta: true,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
imagenUrl: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
modelos: [],
|
||||
reglas: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function filaUnidad(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'u-1',
|
||||
codigo: 'SENDA-1',
|
||||
desarrollo: 'SENDA',
|
||||
modelo: 'HIMALAYA',
|
||||
manzana: '',
|
||||
lote: '',
|
||||
nivel: '',
|
||||
supConstruccion: 120,
|
||||
supExcedente: 0,
|
||||
supEstacionamiento: 1,
|
||||
precioLista: 3000000,
|
||||
precioAcabados: 3200000,
|
||||
status: 'DISPONIBLE',
|
||||
etapa: '',
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
prismaMock.unidad.count.mockResolvedValue(0)
|
||||
prismaMock.cotizacion.count.mockResolvedValue(0)
|
||||
prismaMock.leadCaptura.count.mockResolvedValue(0)
|
||||
prismaMock.cliente.count.mockResolvedValue(0)
|
||||
prismaMock.planPago.count.mockResolvedValue(0)
|
||||
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
id: `id-${String(data['codigo'])}`,
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
...data,
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Permisos ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdminEstricto', () => {
|
||||
it('deja pasar al admin', () => {
|
||||
const next = vi.fn()
|
||||
requireAdminEstricto(reqFake({ token: ADMIN }), {} as Response, next)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('403 para manager y para vendedor', () => {
|
||||
for (const rol of ['manager', 'vendedor', 'coordinador', 'corporativo']) {
|
||||
expect(() => requireAdminEstricto(reqFake({ token: { sub: 'u-1', rol } }), {} as Response, vi.fn())).toThrow(
|
||||
'Solo un administrador puede hacer esta operación',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('403 sin token', () => {
|
||||
expect(() => requireAdminEstricto(reqFake({}), {} as Response, vi.fn())).toThrow()
|
||||
})
|
||||
|
||||
it('los endpoints de escritura quedan detrás del guard (403 con rol manager)', async () => {
|
||||
// El guard se monta en las rutas; aquí se comprueba que corta antes del servicio
|
||||
const req = reqFake({ token: { sub: 'u-1', rol: 'manager' }, body: { clave: 'NUEVO', nombre: 'Nuevo' } })
|
||||
expect(() => requireAdminEstricto(req, {} as Response, vi.fn())).toThrow()
|
||||
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Fichas ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/desarrollos', () => {
|
||||
it('201 con la ficha cuando la clave es nueva', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(filaDesarrollo())
|
||||
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
|
||||
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ accion: 'CREAR_DESARROLLO', entidad: 'desarrollo', usuarioId: 'u-admin' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('409 con clave duplicada y sin crear', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
})
|
||||
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('400 si la clave no es mayúsculas sin espacios', async () => {
|
||||
const { res } = resFake()
|
||||
await expect(
|
||||
crearDesarrolloEndpoint(reqFake({ body: { clave: 'senda nueva', nombre: 'Senda' }, token: ADMIN }), res),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/desarrollos y detalle', () => {
|
||||
it('devuelve el contrato del listado', async () => {
|
||||
prismaMock.desarrollo.findMany.mockResolvedValue([
|
||||
filaDesarrollo({
|
||||
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
|
||||
reglas: { desarrolloId: 'd-1', parametrosJson: '{}', updatedAt: new Date(), updatedPorId: null },
|
||||
}),
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
|
||||
{ desarrollo: 'SENDA', status: 'APARTADO' },
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarDesarrollosEndpoint(reqFake({ token: ADMIN }), res)
|
||||
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
desarrollos: [
|
||||
expect.objectContaining({
|
||||
clave: 'SENDA',
|
||||
unidadesTotales: 2,
|
||||
unidadesDisponibles: 1,
|
||||
tieneReglas: true,
|
||||
modelos: [
|
||||
{ id: 'm-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /api/desarrollos/:id', () => {
|
||||
it('404 si no existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
|
||||
await expect(actualizarDesarrolloEndpoint(reqFake({ params: { id: 'nope' }, body: { zona: 'X' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it('actualiza solo los campos enviados', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' }).mockResolvedValueOnce(filaDesarrollo({ zona: 'Tlajomulco' }))
|
||||
prismaMock.desarrollo.update.mockResolvedValue(filaDesarrollo())
|
||||
const { res, json } = resFake()
|
||||
|
||||
await actualizarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, body: { zona: 'Tlajomulco' }, token: ADMIN }), res)
|
||||
|
||||
expect(prismaMock.desarrollo.update).toHaveBeenCalledWith({ where: { id: 'd-1' }, data: { zona: 'Tlajomulco' } })
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ zona: 'Tlajomulco' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/desarrollos/:id', () => {
|
||||
it('409 con inventario y operación; 204 sin dependencias', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
|
||||
prismaMock.cotizacion.count.mockResolvedValue(3)
|
||||
|
||||
await expect(eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: 'El desarrollo tiene 2 unidades y 3 registros de operación; desactívalo en vez de borrarlo',
|
||||
})
|
||||
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('204 y auditoría cuando el desarrollo está limpio', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
const { res, status } = resFake()
|
||||
|
||||
await eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), res)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(204)
|
||||
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
|
||||
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ accion: 'ELIMINAR_DESARROLLO', entidadId: 'd-1' }),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── Modelos y reglas ──────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/desarrollos/:id/modelos', () => {
|
||||
it('201 con el modelo creado', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue(null)
|
||||
prismaMock.modeloDesarrollo.create.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'PANORAMA' })
|
||||
const { res, status } = resFake()
|
||||
|
||||
await agregarModeloEndpoint(reqFake({ params: { id: 'd-1' }, body: { nombre: 'PANORAMA', recamaras: 3 }, token: ADMIN }), res)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/desarrollos/:id/reglas', () => {
|
||||
const reglas = {
|
||||
precioUnico: false,
|
||||
fuenteDeN: 'fechaEntrega',
|
||||
nDefault: 13,
|
||||
cuotaMantenimiento: 1500,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
propuestas: [
|
||||
{
|
||||
tipo: 'CREDITO_BANCARIO',
|
||||
claveDescuento: 'creditoBancario',
|
||||
titulo: 'Crédito Bancario',
|
||||
descuentoDefault: 0,
|
||||
pctEnganche: 0.025,
|
||||
engancheBase: 'lista',
|
||||
pctPagoFinal: 0.9,
|
||||
pagoFinalBase: 'final',
|
||||
modoMensualidades: 'remanente',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('guarda y devuelve las reglas parseadas', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
|
||||
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
|
||||
desarrolloId: 'd-1',
|
||||
parametrosJson: JSON.stringify(reglas),
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
updatedPorId: 'u-admin',
|
||||
})
|
||||
const { res, json } = resFake()
|
||||
|
||||
await guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: reglas, token: ADMIN }), res)
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ desarrolloId: 'd-1', parametros: expect.objectContaining({ nDefault: 13 }) }))
|
||||
})
|
||||
|
||||
it('400 si un porcentaje se sale de 0..1', async () => {
|
||||
const malas = { ...reglas, propuestas: [{ ...reglas.propuestas[0], pctPagoFinal: 1.2 }] }
|
||||
await expect(guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: malas, token: ADMIN }), resFake().res)).rejects.toThrow()
|
||||
expect(prismaMock.reglaCotizadorDesarrollo.upsert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Inventario ────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/desarrollos/:id/unidades', () => {
|
||||
it('genera el rango con {clave} y {n}: 201 con las creadas', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
const { res, status, json } = resFake()
|
||||
|
||||
await crearUnidadesEndpoint(
|
||||
reqFake({
|
||||
params: { id: 'd-1' },
|
||||
body: { generar: { patron: 'SENDA-401-{n}', desde: 1, hasta: 3, precioLista: 2000000 } },
|
||||
token: ADMIN,
|
||||
}),
|
||||
res,
|
||||
)
|
||||
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(json).toHaveBeenCalledWith({
|
||||
creadas: 3,
|
||||
unidades: expect.arrayContaining([expect.objectContaining({ codigo: 'SENDA-401-1', status: 'DISPONIBLE' })]),
|
||||
})
|
||||
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ accion: 'CREAR_UNIDADES', entidad: 'unidad' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('{clave} se sustituye por la clave del desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
const { json } = resFake()
|
||||
|
||||
await crearUnidadesEndpoint(
|
||||
reqFake({ params: { id: 'd-1' }, body: { generar: { patron: '{clave}-{n}', desde: 7, hasta: 8, precioLista: 1 } }, token: ADMIN }),
|
||||
resFake().res,
|
||||
)
|
||||
void json
|
||||
const creadas = prismaMock.unidad.create.mock.calls.map((c) => (c[0] as { data: { codigo: string } }).data.codigo)
|
||||
expect(creadas).toEqual(['SENDA-7', 'SENDA-8'])
|
||||
})
|
||||
|
||||
it('409 sin crear nada si algún código ya existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([{ codigo: 'SENDA-5' }])
|
||||
const { res } = resFake()
|
||||
|
||||
await expect(
|
||||
crearUnidadesEndpoint(
|
||||
reqFake({
|
||||
params: { id: 'd-1' },
|
||||
body: {
|
||||
unidades: [
|
||||
{ codigo: 'SENDA-5', precioLista: 1 },
|
||||
{ codigo: 'SENDA-6', precioLista: 1 },
|
||||
],
|
||||
},
|
||||
token: ADMIN,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('409 si el mismo código se repite en la alta', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
|
||||
await expect(
|
||||
crearUnidadesEndpoint(
|
||||
reqFake({
|
||||
params: { id: 'd-1' },
|
||||
body: { unidades: [{ codigo: 'SENDA-1', precioLista: 1 }, { codigo: 'SENDA-1', precioLista: 2 }] },
|
||||
token: ADMIN,
|
||||
}),
|
||||
resFake().res,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
|
||||
it('404 si el desarrollo no existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
|
||||
await expect(
|
||||
crearUnidadesEndpoint(reqFake({ params: { id: 'nope' }, body: { generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }, token: ADMIN }), resFake().res),
|
||||
).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/desarrollos/:id/unidades/:codigo', () => {
|
||||
it('409 si la unidad tiene cotizaciones o ventas', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
|
||||
prismaMock.planPago.count.mockResolvedValue(1)
|
||||
|
||||
await expect(
|
||||
eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), resFake().res),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('204 si la unidad está libre', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
|
||||
const { res, status } = resFake()
|
||||
|
||||
await eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), res)
|
||||
expect(status).toHaveBeenCalledWith(204)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Público (sin auth) ────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/publico/desarrollos', () => {
|
||||
it('publica solo el desarrollo activo con inventario disponible y sin datos personales', async () => {
|
||||
prismaMock.desarrollo.findMany.mockResolvedValue([
|
||||
filaDesarrollo({
|
||||
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
|
||||
}),
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
filaUnidad({ codigo: 'SENDA-1', nivel: '3' }),
|
||||
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 5000000 }),
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await listarDesarrollosPublicosEndpoint(reqFake({}), res)
|
||||
|
||||
const payload = (json.mock.calls[0] as [ { desarrollos: Record<string, unknown>[] } ])[0]
|
||||
const desarrollo = payload.desarrollos[0] as Record<string, unknown>
|
||||
|
||||
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
|
||||
)
|
||||
expect(Object.keys(desarrollo).sort()).toEqual(
|
||||
[
|
||||
'amenidades',
|
||||
'clave',
|
||||
'descripcion',
|
||||
'direccion',
|
||||
'estado',
|
||||
'fechaEntregaProgramada',
|
||||
'imagenUrl',
|
||||
'logoUrl',
|
||||
'modelos',
|
||||
'nombre',
|
||||
'tipo',
|
||||
'unidadesDisponibles',
|
||||
'zona',
|
||||
].sort(),
|
||||
)
|
||||
expect(desarrollo['unidadesDisponibles']).toEqual([
|
||||
{ codigo: 'SENDA-1', modelo: 'HIMALAYA', nivel: '3', precioLista: 3000000, precioAcabados: 3200000, supConstruccion: 120 },
|
||||
])
|
||||
|
||||
// El camino público nunca consulta clientes, cotizaciones ni planes
|
||||
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
|
||||
expect(prismaMock.cliente.findMany).not.toHaveBeenCalled()
|
||||
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
|
||||
expect(prismaMock.planPago.count).not.toHaveBeenCalled()
|
||||
expect(prismaMock.leadCaptura.count).not.toHaveBeenCalled()
|
||||
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado', 'rfc']) {
|
||||
expect(JSON.stringify(payload).toLowerCase()).not.toContain(prohibido)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/publico/desarrollos/:clave', () => {
|
||||
it('404 si no existe o no es público', async () => {
|
||||
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
|
||||
await expect(obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'NOEXISTE' } }), resFake().res)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it('resuelve la clave sin importar mayúsculas', async () => {
|
||||
prismaMock.desarrollo.findFirst.mockResolvedValue(filaDesarrollo())
|
||||
const { res, json } = resFake()
|
||||
|
||||
await obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'senda' } }), res)
|
||||
|
||||
expect(prismaMock.desarrollo.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { clave: 'SENDA', activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
|
||||
)
|
||||
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
|
||||
})
|
||||
})
|
||||
141
api-gateway/src/controllers/desarrollos.controller.ts
Normal file
141
api-gateway/src/controllers/desarrollos.controller.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Endpoints del módulo Desarrollos.
|
||||
*
|
||||
* Todo el CRUD y el inventario son solo admin (`requireAdminEstricto`, montado
|
||||
* en las rutas); el micrositio público lee /api/publico/desarrollos sin auth,
|
||||
* con información exclusivamente comercial (nunca clientes ni cotizaciones).
|
||||
*/
|
||||
import type { Request, Response } from 'express'
|
||||
import * as service from '../services/desarrollos.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
import { AppError } from '../utils/errors.js'
|
||||
|
||||
/** Usuario del token para la bitácora (modo local sin auth: 'desconocido') */
|
||||
function usuarioDe(req: Request): string {
|
||||
return req.usuarioToken?.sub ?? 'desconocido'
|
||||
}
|
||||
|
||||
function idDe(req: Request): string {
|
||||
const id = String(req.params['id'] ?? '')
|
||||
if (id === '') throw new AppError(400, 'Falta el parámetro :id')
|
||||
return id
|
||||
}
|
||||
|
||||
// ── Fichas ────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/desarrollos */
|
||||
export async function listarDesarrollosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await service.listarDesarrollos())
|
||||
}
|
||||
|
||||
/** GET /api/desarrollos/:id */
|
||||
export async function obtenerDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
res.json(await service.obtenerDesarrollo(idDe(req)))
|
||||
}
|
||||
|
||||
/** POST /api/desarrollos */
|
||||
export async function crearDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = service.crearDesarrolloSchema.parse(req.body)
|
||||
const ficha = await service.crearDesarrollo(body)
|
||||
res.status(201).json(ficha)
|
||||
auditoria.registrar(usuarioDe(req), 'CREAR_DESARROLLO', 'desarrollo', ficha.id, ficha.clave)
|
||||
}
|
||||
|
||||
/** PATCH /api/desarrollos/:id */
|
||||
export async function actualizarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = service.actualizarDesarrolloSchema.parse(req.body)
|
||||
const ficha = await service.actualizarDesarrollo(idDe(req), body)
|
||||
res.json(ficha)
|
||||
auditoria.registrar(
|
||||
usuarioDe(req),
|
||||
'ACTUALIZAR_DESARROLLO',
|
||||
'desarrollo',
|
||||
ficha.id,
|
||||
Object.keys(req.body as Record<string, unknown>).join(', '),
|
||||
)
|
||||
}
|
||||
|
||||
/** DELETE /api/desarrollos/:id — 409 si tiene inventario u operación */
|
||||
export async function eliminarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const id = idDe(req)
|
||||
await service.eliminarDesarrollo(id)
|
||||
res.status(204).end()
|
||||
auditoria.registrar(usuarioDe(req), 'ELIMINAR_DESARROLLO', 'desarrollo', id)
|
||||
}
|
||||
|
||||
// ── Modelos ───────────────────────────────────────────────────────────
|
||||
|
||||
/** POST /api/desarrollos/:id/modelos */
|
||||
export async function agregarModeloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const desarrolloId = idDe(req)
|
||||
const body = service.crearModeloSchema.parse(req.body)
|
||||
const modelo = await service.agregarModelo(desarrolloId, body)
|
||||
res.status(201).json(modelo)
|
||||
auditoria.registrar(usuarioDe(req), 'AGREGAR_MODELO', 'desarrollo', desarrolloId, modelo.nombre)
|
||||
}
|
||||
|
||||
/** PATCH /api/desarrollos/modelos/:modeloId */
|
||||
export async function actualizarModeloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const modeloId = String(req.params['modeloId'])
|
||||
const body = service.actualizarModeloSchema.parse(req.body)
|
||||
const modelo = await service.actualizarModelo(modeloId, body)
|
||||
res.json(modelo)
|
||||
auditoria.registrar(usuarioDe(req), 'ACTUALIZAR_MODELO', 'desarrollo', modelo.desarrolloId, modelo.nombre)
|
||||
}
|
||||
|
||||
/** DELETE /api/desarrollos/modelos/:modeloId — 409 si el modelo tiene unidades */
|
||||
export async function eliminarModeloEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const modeloId = String(req.params['modeloId'])
|
||||
await service.eliminarModelo(modeloId)
|
||||
res.status(204).end()
|
||||
auditoria.registrar(usuarioDe(req), 'ELIMINAR_MODELO', 'desarrollo', modeloId)
|
||||
}
|
||||
|
||||
// ── Reglas del cotizador ──────────────────────────────────────────────
|
||||
|
||||
/** PUT /api/desarrollos/:id/reglas */
|
||||
export async function guardarReglasEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const desarrolloId = idDe(req)
|
||||
const parametros = service.parametrosDesarrolloSchema.parse(req.body)
|
||||
const reglas = await service.guardarReglas(desarrolloId, parametros, req.usuarioToken?.sub ?? null)
|
||||
res.json(reglas)
|
||||
auditoria.registrar(usuarioDe(req), 'GUARDAR_REGLAS_COTIZADOR', 'desarrollo', desarrolloId)
|
||||
}
|
||||
|
||||
// ── Inventario ────────────────────────────────────────────────────────
|
||||
|
||||
/** POST /api/desarrollos/:id/unidades — alta manual o por generador */
|
||||
export async function crearUnidadesEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const desarrolloId = idDe(req)
|
||||
const body = service.crearUnidadesSchema.parse(req.body)
|
||||
const resultado = await service.crearUnidades(desarrolloId, body)
|
||||
res.status(201).json(resultado)
|
||||
auditoria.registrar(
|
||||
usuarioDe(req),
|
||||
'CREAR_UNIDADES',
|
||||
'unidad',
|
||||
desarrolloId,
|
||||
`${resultado.creadas} unidad(es): ${resultado.unidades.map((u) => u.codigo).join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** DELETE /api/desarrollos/:id/unidades/:codigo — 409 si la unidad tiene operación */
|
||||
export async function eliminarUnidadEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const desarrolloId = idDe(req)
|
||||
const codigo = String(req.params['codigo'])
|
||||
await service.eliminarUnidad(desarrolloId, codigo)
|
||||
res.status(204).end()
|
||||
auditoria.registrar(usuarioDe(req), 'ELIMINAR_UNIDAD', 'unidad', codigo)
|
||||
}
|
||||
|
||||
// ── Público (micrositio) ──────────────────────────────────────────────
|
||||
|
||||
/** GET /api/publico/desarrollos */
|
||||
export async function listarDesarrollosPublicosEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json(await service.listarDesarrollosPublicos())
|
||||
}
|
||||
|
||||
/** GET /api/publico/desarrollos/:clave */
|
||||
export async function obtenerDesarrolloPublicoEndpoint(req: Request, res: Response): Promise<void> {
|
||||
res.json(await service.obtenerDesarrolloPublico(String(req.params['clave'])))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
reglaCotizadorDesarrollo: { findMany: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { reglasCotizadorEndpoint } from './reglas-cotizador.controller.js'
|
||||
import { DESARROLLOS } from '../services/cotizacion.service.js'
|
||||
|
||||
function resFake() {
|
||||
const json = vi.fn()
|
||||
return { res: { json } as unknown as Response, json }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('GET /api/desarrollos/reglas-cotizador', () => {
|
||||
it('devuelve el mapa reglasPorDesarrollo con las reglas efectivas', async () => {
|
||||
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
|
||||
{
|
||||
desarrolloId: 'SENDA',
|
||||
parametrosJson: JSON.stringify(DESARROLLOS['SENDA']),
|
||||
activoEnCotizador: false,
|
||||
},
|
||||
])
|
||||
const { res, json } = resFake()
|
||||
|
||||
await reglasCotizadorEndpoint({} as Request, res)
|
||||
|
||||
const cuerpo = json.mock.calls[0]?.[0] as {
|
||||
reglasPorDesarrollo: Record<string, { activoEnCotizador?: boolean; propuestas: unknown[] }>
|
||||
}
|
||||
expect(Object.keys(cuerpo.reglasPorDesarrollo).sort()).toEqual(Object.keys(DESARROLLOS).sort())
|
||||
expect(cuerpo.reglasPorDesarrollo['SENDA']?.activoEnCotizador).toBe(false)
|
||||
expect(cuerpo.reglasPorDesarrollo['SENDA']?.propuestas).toHaveLength(4)
|
||||
// Los desarrollos sin reglas guardadas salen con sus constantes
|
||||
expect(cuerpo.reglasPorDesarrollo['DIAGONAL']).toEqual(DESARROLLOS['DIAGONAL'])
|
||||
})
|
||||
})
|
||||
14
api-gateway/src/controllers/reglas-cotizador.controller.ts
Normal file
14
api-gateway/src/controllers/reglas-cotizador.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { getReglasEfectivas } from '../services/reglas-desarrollo.service.js'
|
||||
|
||||
/**
|
||||
* GET /api/desarrollos/reglas-cotizador — reglas comerciales EFECTIVAS por
|
||||
* desarrollo (las configuradas por el admin en la BD, con respaldo en las
|
||||
* constantes históricas para los desarrollos sin reglas guardadas).
|
||||
*
|
||||
* Autenticado y disponible para cualquier rol que cotice (el middleware de auth
|
||||
* cubre todo /api salvo las rutas públicas). Devuelve el mapa `reglasPorDesarrollo`.
|
||||
*/
|
||||
export async function reglasCotizadorEndpoint(_req: Request, res: Response): Promise<void> {
|
||||
res.json({ reglasPorDesarrollo: await getReglasEfectivas() })
|
||||
}
|
||||
@@ -124,6 +124,34 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('deja pasar los endpoints públicos de desarrollos (micrositio sin sesión)', () => {
|
||||
const casos: Partial<Request>[] = [
|
||||
{ method: 'GET', path: '/publico/desarrollos' },
|
||||
{ method: 'GET', path: '/publico/desarrollos/SENDA' },
|
||||
]
|
||||
for (const c of casos) {
|
||||
vi.clearAllMocks()
|
||||
authMiddleware(reqFake(c), resFake().res, next)
|
||||
expect(next).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
|
||||
it('el patrón público de desarrollos no abre el CRUD de admin', () => {
|
||||
const casos: Partial<Request>[] = [
|
||||
{ method: 'POST', path: '/publico/desarrollos' },
|
||||
{ method: 'DELETE', path: '/publico/desarrollos/SENDA' },
|
||||
{ method: 'GET', path: '/publico/desarrollos/SENDA/unidades' },
|
||||
{ method: 'GET', path: '/desarrollos' },
|
||||
{ method: 'GET', path: '/desarrollos/d-1' },
|
||||
{ method: 'POST', path: '/desarrollos/d-1/unidades' },
|
||||
]
|
||||
for (const c of casos) {
|
||||
const { res, status } = resFake()
|
||||
authMiddleware(reqFake(c), res, next)
|
||||
expect(status).toHaveBeenCalledWith(401)
|
||||
}
|
||||
})
|
||||
|
||||
it('el token interno abre solo POST /cotizaciones/alertas-inventario', () => {
|
||||
authMiddleware(
|
||||
reqFake({
|
||||
|
||||
@@ -20,6 +20,10 @@ const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [
|
||||
{ metodo: 'GET', patron: /^\/clientes\/publico\/[^/]+$/ },
|
||||
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/firma$/ },
|
||||
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/documentos$/ },
|
||||
// Micrositio: fichas públicas de desarrollos y su inventario disponible
|
||||
// (solo información comercial, jamás datos de clientes ni cotizaciones).
|
||||
{ metodo: 'GET', patron: /^\/publico\/desarrollos$/ },
|
||||
{ metodo: 'GET', patron: /^\/publico\/desarrollos\/[^/]+$/ },
|
||||
]
|
||||
|
||||
declare module 'express-serve-static-core' {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppError } from '../utils/errors.js'
|
||||
import { Router } from 'express'
|
||||
import { health } from '../controllers/health.controller.js'
|
||||
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
|
||||
import { reglasCotizadorEndpoint } from '../controllers/reglas-cotizador.controller.js'
|
||||
import {
|
||||
alertasInventarioEndpoint,
|
||||
crearCotizacion,
|
||||
@@ -163,6 +164,21 @@ import {
|
||||
requireAdminOManager,
|
||||
uploadArchivosSoporte,
|
||||
} from '../controllers/soporte.controller.js'
|
||||
import {
|
||||
actualizarDesarrolloEndpoint,
|
||||
actualizarModeloEndpoint,
|
||||
agregarModeloEndpoint,
|
||||
crearDesarrolloEndpoint,
|
||||
crearUnidadesEndpoint,
|
||||
eliminarDesarrolloEndpoint,
|
||||
eliminarModeloEndpoint,
|
||||
eliminarUnidadEndpoint,
|
||||
guardarReglasEndpoint,
|
||||
listarDesarrollosEndpoint,
|
||||
listarDesarrollosPublicosEndpoint,
|
||||
obtenerDesarrolloEndpoint,
|
||||
obtenerDesarrolloPublicoEndpoint,
|
||||
} from '../controllers/desarrollos.controller.js'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
@@ -176,6 +192,11 @@ apiRouter.post('/auth/cambiar-password', cambiarPasswordEndpoint)
|
||||
apiRouter.get('/unidades', listarUnidades)
|
||||
apiRouter.get('/unidades/:codigo', obtenerUnidad)
|
||||
|
||||
// Reglas comerciales del cotizador (BD del admin con respaldo en constantes).
|
||||
// Ruta fija: debe ir ANTES de cualquier '/desarrollos/:id' que agregue el
|
||||
// módulo de administración para no quedar tapada por el parámetro.
|
||||
apiRouter.get('/desarrollos/reglas-cotizador', reglasCotizadorEndpoint)
|
||||
|
||||
apiRouter.post('/cotizaciones', crearCotizacion)
|
||||
apiRouter.get('/cotizaciones', listarCotizaciones)
|
||||
apiRouter.get('/cotizaciones/mias', listarMisCotizaciones)
|
||||
@@ -365,3 +386,22 @@ apiRouter.post('/soporte', uploadArchivosSoporte, crearSolicitudSoporteEndpoint)
|
||||
apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint)
|
||||
apiRouter.get('/soporte/:id/archivos/:indice', descargarArchivoSoporteEndpoint)
|
||||
apiRouter.patch('/soporte/:id', requireAdminOManager, cambiarEstadoSolicitudEndpoint)
|
||||
|
||||
// ── Módulo Desarrollos: catálogo en BD (solo admin) ─────────────────
|
||||
// Público primero (sin auth, lo consume el micrositio): ver RUTAS_PUBLICAS/
|
||||
// PATRONES_PUBLICOS en middleware/auth.middleware.ts
|
||||
apiRouter.get('/publico/desarrollos', listarDesarrollosPublicosEndpoint)
|
||||
apiRouter.get('/publico/desarrollos/:clave', obtenerDesarrolloPublicoEndpoint)
|
||||
|
||||
apiRouter.get('/desarrollos', requireAdminEstricto, listarDesarrollosEndpoint)
|
||||
apiRouter.post('/desarrollos', requireAdminEstricto, crearDesarrolloEndpoint)
|
||||
// Rutas fijas de modelos ANTES de /desarrollos/:id para no colisionar con el parámetro
|
||||
apiRouter.patch('/desarrollos/modelos/:modeloId', requireAdminEstricto, actualizarModeloEndpoint)
|
||||
apiRouter.delete('/desarrollos/modelos/:modeloId', requireAdminEstricto, eliminarModeloEndpoint)
|
||||
apiRouter.get('/desarrollos/:id', requireAdminEstricto, obtenerDesarrolloEndpoint)
|
||||
apiRouter.patch('/desarrollos/:id', requireAdminEstricto, actualizarDesarrolloEndpoint)
|
||||
apiRouter.delete('/desarrollos/:id', requireAdminEstricto, eliminarDesarrolloEndpoint)
|
||||
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
|
||||
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
|
||||
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
|
||||
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)
|
||||
|
||||
201
api-gateway/src/services/cotizacion-reglas.test.ts
Normal file
201
api-gateway/src/services/cotizacion-reglas.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
APARTADO_DEFAULT,
|
||||
DESARROLLOS as DESARROLLOS_LOCALES,
|
||||
calcularPropuesta,
|
||||
calcularPropuestas,
|
||||
totalPagado,
|
||||
type DescuentosPropuestas,
|
||||
type ParametrosDesarrollo,
|
||||
type TipoPropuestaApi,
|
||||
type UnidadCotizable,
|
||||
} from './cotizacion.service.js'
|
||||
import { normalizarReglas } from './reglas-desarrollo.service.js'
|
||||
|
||||
/**
|
||||
* Compatibilidad de las reglas configurables: cotizar con las reglas que llegan
|
||||
* de la BD (mismo JSON que guarda `ReglaCotizadorDesarrollo`, validado por
|
||||
* `normalizarReglas`) debe dar EXACTAMENTE los mismos números que con las
|
||||
* constantes históricas. Unidades: 3 reales por desarrollo del inventario.
|
||||
*/
|
||||
|
||||
function unidad(parcial: Partial<UnidadCotizable> & Pick<UnidadCotizable, 'codigo' | 'desarrollo'>): UnidadCotizable {
|
||||
return {
|
||||
modelo: 'X',
|
||||
fechaEntrega: null,
|
||||
supEstacionamiento: 0,
|
||||
supConstruccion: 0,
|
||||
supExcedente: 0,
|
||||
precioSinAcabados: 0,
|
||||
precioConAcabados: 0,
|
||||
status: 'DISPONIBLE',
|
||||
...parcial,
|
||||
}
|
||||
}
|
||||
|
||||
const UNIDADES: UnidadCotizable[] = [
|
||||
// DIAGONAL (precio varía con acabados)
|
||||
unidad({ codigo: 'U-101', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 4415000, precioConAcabados: 4579000 }),
|
||||
unidad({ codigo: 'U-103', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 3107108.39, precioConAcabados: 3269000 }),
|
||||
unidad({ codigo: 'U-201', desarrollo: 'DIAGONAL', fechaEntrega: null, precioSinAcabados: 3990000, precioConAcabados: 4150000 }),
|
||||
// ATELIER (precio único, N fijo = 1)
|
||||
unidad({ codigo: 'ATELIER-10', desarrollo: 'ATELIER', precioSinAcabados: 3997500, precioConAcabados: 3997500 }),
|
||||
unidad({ codigo: 'ATELIER-17', desarrollo: 'ATELIER', precioSinAcabados: 3554291, precioConAcabados: 3554291 }),
|
||||
unidad({ codigo: 'ATELIER-27', desarrollo: 'ATELIER', precioSinAcabados: 2304339, precioConAcabados: 2304339 }),
|
||||
// ESTANZA (pago final sobre precio de LISTA: el "quirk" del Excel)
|
||||
unidad({ codigo: 'ESTANZA-17', desarrollo: 'ESTANZA', precioSinAcabados: 2617632, precioConAcabados: 2617632 }),
|
||||
unidad({ codigo: 'ESTANZA-19', desarrollo: 'ESTANZA', precioSinAcabados: 2340562, precioConAcabados: 2340562 }),
|
||||
unidad({ codigo: 'ESTANZA-25', desarrollo: 'ESTANZA', precioSinAcabados: 2501683, precioConAcabados: 2501683 }),
|
||||
// SENDA (N por fecha de entrega; hay piezas sin fecha → nDefault)
|
||||
unidad({ codigo: 'SENDA-409-1-2', desarrollo: 'SENDA', fechaEntrega: '2026-12-19', precioSinAcabados: 2700000, precioConAcabados: 2700000 }),
|
||||
unidad({ codigo: 'SENDA-411-1-7', desarrollo: 'SENDA', fechaEntrega: null, precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
|
||||
unidad({ codigo: 'SENDA-409-1-4', desarrollo: 'SENDA', fechaEntrega: '2026-11-30', precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
|
||||
// SOLANA (N por unidad: meses para firma; en el espejo viene null → nDefault = 1)
|
||||
unidad({ codigo: 'SOLANA-6', desarrollo: 'SOLANA', precioSinAcabados: 2560000, precioConAcabados: 2560000 }),
|
||||
unidad({ codigo: 'SOLANA-7', desarrollo: 'SOLANA', precioSinAcabados: 2730000, precioConAcabados: 2730000 }),
|
||||
unidad({ codigo: 'SOLANA-9', desarrollo: 'SOLANA', precioSinAcabados: 2990445, precioConAcabados: 2990445 }),
|
||||
]
|
||||
|
||||
const TIPOS: TipoPropuestaApi[] = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']
|
||||
|
||||
const DESCUENTOS: DescuentosPropuestas = {
|
||||
creditoBancario: 0.01,
|
||||
creditoInfonavit: 0.005,
|
||||
contado: 0.03,
|
||||
semiContado: 0.015,
|
||||
}
|
||||
|
||||
/**
|
||||
* Reglas "de BD": el JSON serializado tal como lo guarda el admin, con los
|
||||
* campos extra que agrega el módulo de administración (nombre, activo, flags).
|
||||
* Pasa por `normalizarReglas`, el mismo camino que usa el API al leerlas.
|
||||
*/
|
||||
function reglasDeBd(desarrollo: string): ParametrosDesarrollo {
|
||||
const constantes = DESARROLLOS_LOCALES[desarrollo]!
|
||||
const json = JSON.parse(
|
||||
JSON.stringify({
|
||||
...constantes,
|
||||
nombre: desarrollo,
|
||||
activoEnCotizador: true,
|
||||
propuestas: constantes.propuestas.map((p) => ({ ...p, activa: true })),
|
||||
}),
|
||||
) as unknown
|
||||
const reglas = normalizarReglas(desarrollo, json)
|
||||
if (!reglas) throw new Error(`Las reglas de BD de ${desarrollo} deberían ser válidas`)
|
||||
return reglas
|
||||
}
|
||||
|
||||
describe('reglas de BD vs constantes: mismos números', () => {
|
||||
it('las 4 propuestas de cada unidad dan resultados idénticos', () => {
|
||||
for (const u of UNIDADES) {
|
||||
const reglas = reglasDeBd(u.desarrollo)
|
||||
for (const acabados of [false, true]) {
|
||||
const conConstantes = calcularPropuestas({
|
||||
unidad: u,
|
||||
acabados,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: DESCUENTOS,
|
||||
})
|
||||
const conBd = calcularPropuestas({
|
||||
unidad: u,
|
||||
acabados,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: DESCUENTOS,
|
||||
reglas,
|
||||
})
|
||||
expect(conBd).toHaveLength(conConstantes.length)
|
||||
expect(conBd).toEqual(conConstantes)
|
||||
for (const p of conBd) {
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).not.toBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('la cotización de una propuesta (con descuento, enganche y N del vendedor) es idéntica', () => {
|
||||
for (const u of UNIDADES) {
|
||||
const reglas = reglasDeBd(u.desarrollo)
|
||||
for (const tipo of TIPOS) {
|
||||
for (const apartado of [0, APARTADO_DEFAULT, 150_000]) {
|
||||
const conConstantes = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12)
|
||||
const conBd = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12, reglas)
|
||||
expect(conBd).toEqual(conConstantes)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('sin reglas en el parámetro se comporta como hoy (respaldo en constantes)', () => {
|
||||
const u = UNIDADES[0]!
|
||||
expect(calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, null)).toEqual(
|
||||
calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reglas del admin que cambian el comportamiento', () => {
|
||||
const u101 = UNIDADES[0]!
|
||||
|
||||
it('un descuento distinto en la BD se usa en el cálculo', () => {
|
||||
const base = DESARROLLOS_LOCALES['DIAGONAL']!
|
||||
const reglas = normalizarReglas('DIAGONAL', {
|
||||
...base,
|
||||
propuestas: base.propuestas.map((p) =>
|
||||
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.05 } : p,
|
||||
),
|
||||
})!
|
||||
const [bancario, , contado] = calcularPropuestas({
|
||||
unidad: u101,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.05, semiContado: 0 },
|
||||
reglas,
|
||||
})
|
||||
expect(bancario?.precioFinal).toBeCloseTo(4_415_000, 2)
|
||||
expect(contado?.descuento).toBe(0.05)
|
||||
expect(contado?.precioFinal).toBeCloseTo(4_415_000 * 0.95, 2)
|
||||
})
|
||||
|
||||
it('una propuesta desactivada no se calcula ni se puede elegir', () => {
|
||||
const base = DESARROLLOS_LOCALES['DIAGONAL']!
|
||||
const reglas = normalizarReglas('DIAGONAL', {
|
||||
...base,
|
||||
propuestas: base.propuestas.map((p) => ({ ...p, activa: p.tipo !== 'CONTADO' })),
|
||||
})!
|
||||
|
||||
const propuestas = calcularPropuestas({
|
||||
unidad: u101,
|
||||
acabados: false,
|
||||
apartado: APARTADO_DEFAULT,
|
||||
descuentos: DESCUENTOS,
|
||||
reglas,
|
||||
})
|
||||
|
||||
expect(propuestas.map((p) => p.tipo)).toEqual([
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'SEMI_CONTADO',
|
||||
])
|
||||
expect(() =>
|
||||
calcularPropuesta(u101, 'CONTADO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas),
|
||||
).toThrow(/desactivada/)
|
||||
})
|
||||
|
||||
it('un cambio de parámetros del Excel se refleja (pctEnganche / base del pago final)', () => {
|
||||
const base = DESARROLLOS_LOCALES['ESTANZA']!
|
||||
const reglas = normalizarReglas('ESTANZA', {
|
||||
...base,
|
||||
propuestas: base.propuestas.map((p) =>
|
||||
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.1, pagoFinalBase: 'final' } : p,
|
||||
),
|
||||
})!
|
||||
const casa = UNIDADES.find((u) => u.codigo === 'ESTANZA-17')!
|
||||
|
||||
const p = calcularPropuesta(casa, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas)
|
||||
|
||||
// 10% de enganche sobre precio final, pago final 90% sobre final
|
||||
expect(p.enganche).toBeCloseTo(0.1 * 2617632 - APARTADO_DEFAULT, 2)
|
||||
expect(p.pagoFinal).toBeCloseTo(0.9 * 2617632, 2)
|
||||
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2617632, 2)
|
||||
})
|
||||
})
|
||||
@@ -48,10 +48,10 @@ export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
|
||||
|
||||
// ---------- Parametrización por desarrollo ----------
|
||||
|
||||
type BasePrecio = 'lista' | 'final'
|
||||
type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
|
||||
export type BasePrecio = 'lista' | 'final'
|
||||
export type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
|
||||
|
||||
interface PropuestaConfig {
|
||||
export interface PropuestaConfig {
|
||||
tipo: TipoPropuesta
|
||||
claveDescuento: keyof DescuentosPropuestas
|
||||
titulo: string
|
||||
@@ -62,10 +62,17 @@ interface PropuestaConfig {
|
||||
pagoFinalBase: BasePrecio
|
||||
modoMensualidades: ModoMensualidades
|
||||
engancheCeroSinApartado?: boolean
|
||||
/**
|
||||
* Regla del admin: false => la propuesta no se ofrece ni se puede elegir en
|
||||
* el cotizador. Ausente/true => vigente (comportamiento histórico).
|
||||
*/
|
||||
activa?: boolean
|
||||
}
|
||||
|
||||
interface ParametrosDesarrollo {
|
||||
export interface ParametrosDesarrollo {
|
||||
id: string
|
||||
/** Nombre para mostrar (solo lo traen las reglas guardadas por el admin) */
|
||||
nombre?: string
|
||||
precioUnico: boolean
|
||||
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
|
||||
nFijo?: number
|
||||
@@ -75,6 +82,15 @@ interface ParametrosDesarrollo {
|
||||
* siempre se calculen (espejo de apps/cotizador/src/data/desarrollos.ts).
|
||||
*/
|
||||
nDefault?: number
|
||||
/** Cuota mensual de mantenimiento (MXN); null => pendiente */
|
||||
cuotaMantenimiento?: number | null
|
||||
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
|
||||
prefijoUnidadPdf?: string
|
||||
/**
|
||||
* Regla del admin: false => el desarrollo no se ofrece en el cotizador.
|
||||
* Ausente/true => disponible (comportamiento histórico).
|
||||
*/
|
||||
activoEnCotizador?: boolean
|
||||
propuestas: PropuestaConfig[]
|
||||
}
|
||||
|
||||
@@ -153,10 +169,23 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
|
||||
|
||||
const DESARROLLO_DEFAULT = 'DIAGONAL'
|
||||
|
||||
function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo {
|
||||
/**
|
||||
* Reglas comerciales del desarrollo: las configuradas por el admin (BD) ganan
|
||||
* cuando corresponden al id pedido; si no, se usan las constantes del módulo.
|
||||
*/
|
||||
function obtenerDesarrollo(
|
||||
id?: string | null,
|
||||
reglas?: ParametrosDesarrollo | null,
|
||||
): ParametrosDesarrollo {
|
||||
if (reglas && (id === undefined || id === null || id === '' || reglas.id === id)) return reglas
|
||||
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]!
|
||||
}
|
||||
|
||||
/** Propuestas vigentes: el admin puede desactivar una propuesta por desarrollo */
|
||||
export function propuestasVigentes(params: ParametrosDesarrollo): PropuestaConfig[] {
|
||||
return params.propuestas.filter((p) => p.activa !== false)
|
||||
}
|
||||
|
||||
export interface PropuestaResultado {
|
||||
tipo: TipoPropuesta
|
||||
titulo: string
|
||||
@@ -177,6 +206,11 @@ export interface CotizacionInput {
|
||||
apartado: number
|
||||
descuentos: DescuentosPropuestas
|
||||
desarrollo?: string
|
||||
/**
|
||||
* Reglas comerciales efectivas del desarrollo (las configuradas por el admin
|
||||
* o su respaldo en constantes). Si no vienen, se usan las constantes.
|
||||
*/
|
||||
reglas?: ParametrosDesarrollo
|
||||
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
|
||||
numMeses?: number
|
||||
}
|
||||
@@ -356,13 +390,16 @@ export function indiceMenorTotal(totales: number[]): number {
|
||||
return indice
|
||||
}
|
||||
|
||||
/** Calcula las propuestas comerciales del desarrollo de la unidad */
|
||||
/** Calcula las propuestas comerciales vigentes del desarrollo de la unidad */
|
||||
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
|
||||
const { unidad, acabados, apartado, descuentos } = input
|
||||
const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
|
||||
const params = obtenerDesarrollo(
|
||||
input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT,
|
||||
input.reglas ?? null,
|
||||
)
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
|
||||
return params.propuestas.map((cfg) =>
|
||||
return propuestasVigentes(params).map((cfg) =>
|
||||
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n),
|
||||
)
|
||||
}
|
||||
@@ -386,6 +423,8 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
|
||||
* crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo.
|
||||
* `numMeses` opcional: número de mensualidades elegido por el vendedor en el
|
||||
* cotizador; si no viene se resuelve con la fuente del desarrollo.
|
||||
* `reglas` opcional: reglas comerciales efectivas del desarrollo de la unidad
|
||||
* (BD del admin con respaldo en constantes); sin ellas se usan las constantes.
|
||||
*/
|
||||
export function calcularPropuesta(
|
||||
unidad: UnidadCotizable,
|
||||
@@ -395,12 +434,18 @@ export function calcularPropuesta(
|
||||
descuento?: number,
|
||||
enganchePct?: number,
|
||||
numMeses?: number,
|
||||
reglas?: ParametrosDesarrollo | null,
|
||||
): PropuestaResultado {
|
||||
const params = obtenerDesarrollo(unidad.desarrollo)
|
||||
const params = obtenerDesarrollo(unidad.desarrollo, reglas ?? null)
|
||||
const tipoInterno = TIPO_PROPUESTA_API[tipo]
|
||||
const cfg = params.propuestas.find((p) => p.tipo === tipoInterno)
|
||||
const cfg = propuestasVigentes(params).find((p) => p.tipo === tipoInterno)
|
||||
if (!cfg) {
|
||||
throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`)
|
||||
const desactivada = params.propuestas.some((p) => p.tipo === tipoInterno)
|
||||
throw new Error(
|
||||
desactivada
|
||||
? `La propuesta ${tipo} está desactivada para el desarrollo ${params.id}`
|
||||
: `La propuesta ${tipo} no aplica para el desarrollo ${params.id}`,
|
||||
)
|
||||
}
|
||||
const precioLista = obtenerPrecioLista(unidad, acabados)
|
||||
const n = numMeses ?? resolverNumMensualidades(unidad, params)
|
||||
|
||||
496
api-gateway/src/services/desarrollos.service.test.ts
Normal file
496
api-gateway/src/services/desarrollos.service.test.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado ----------
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
desarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
modeloDesarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
reglaCotizadorDesarrollo: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
unidad: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
cotizacion: { count: vi.fn() },
|
||||
leadCaptura: { count: vi.fn() },
|
||||
cliente: { count: vi.fn() },
|
||||
planPago: { count: vi.fn() },
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import {
|
||||
actualizarDesarrollo,
|
||||
agregarModelo,
|
||||
crearDesarrollo,
|
||||
crearUnidades,
|
||||
crearUnidadesSchema,
|
||||
eliminarDesarrollo,
|
||||
eliminarModelo,
|
||||
eliminarUnidad,
|
||||
guardarReglas,
|
||||
listarDesarrollos,
|
||||
listarDesarrollosPublicos,
|
||||
obtenerDesarrolloPublico,
|
||||
parametrosDesarrolloSchema,
|
||||
} from './desarrollos.service.js'
|
||||
|
||||
/** Fila de Desarrollo tal como la devuelve Prisma (con relaciones) */
|
||||
function filaDesarrollo(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'd-1',
|
||||
clave: 'SENDA',
|
||||
nombre: 'Senda',
|
||||
estado: 'ACTIVO',
|
||||
tipo: 'VERTICAL',
|
||||
constructora: null,
|
||||
direccion: null,
|
||||
zona: 'Zapopan',
|
||||
descripcion: null,
|
||||
amenidades: 'alberca, gym',
|
||||
fechaInicioObra: null,
|
||||
fechaEntregaProgramada: new Date('2026-12-01T00:00:00.000Z'),
|
||||
fechaEntregaReprogramada: null,
|
||||
activoEnCotizador: true,
|
||||
activoEnAlta: true,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
imagenUrl: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
modelos: [],
|
||||
reglas: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function filaUnidad(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'u-1',
|
||||
codigo: 'SENDA-1',
|
||||
desarrollo: 'SENDA',
|
||||
modelo: 'HIMALAYA',
|
||||
manzana: '',
|
||||
lote: '',
|
||||
nivel: '',
|
||||
supConstruccion: 120,
|
||||
supExcedente: 0,
|
||||
supEstacionamiento: 1,
|
||||
precioLista: 3000000,
|
||||
precioAcabados: 3200000,
|
||||
status: 'DISPONIBLE',
|
||||
etapa: '',
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const REGLAS_VALIDAS = {
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'fechaEntrega' as const,
|
||||
nDefault: 6,
|
||||
cuotaMantenimiento: null,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
propuestas: [
|
||||
{
|
||||
tipo: 'CONTADO' as const,
|
||||
claveDescuento: 'contado' as const,
|
||||
titulo: 'Contado',
|
||||
descuentoDefault: 0.02,
|
||||
pctEnganche: 0.9,
|
||||
engancheBase: 'final' as const,
|
||||
pctPagoFinal: 0.1,
|
||||
pagoFinalBase: 'final' as const,
|
||||
modoMensualidades: 'ninguna' as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.unidad.findMany.mockResolvedValue([])
|
||||
prismaMock.unidad.count.mockResolvedValue(0)
|
||||
prismaMock.cotizacion.count.mockResolvedValue(0)
|
||||
prismaMock.leadCaptura.count.mockResolvedValue(0)
|
||||
prismaMock.cliente.count.mockResolvedValue(0)
|
||||
prismaMock.planPago.count.mockResolvedValue(0)
|
||||
// create devuelve lo que recibe (más id/updatedAt), como Prisma
|
||||
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
id: `id-${String(data['codigo'])}`,
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
...data,
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Fichas ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('crearDesarrollo', () => {
|
||||
it('rechaza una clave duplicada con 409', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
|
||||
await expect(crearDesarrollo({ clave: 'SENDA', nombre: 'Senda' })).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: 'Ya existe un desarrollo con la clave SENDA',
|
||||
})
|
||||
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normaliza la clave a mayúsculas y aplica los defaults', async () => {
|
||||
prismaMock.desarrollo.findUnique
|
||||
.mockResolvedValueOnce(null) // no existe la clave
|
||||
.mockResolvedValueOnce(filaDesarrollo()) // lectura de la ficha creada
|
||||
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
|
||||
|
||||
const ficha = await crearDesarrollo({ clave: ' senda ', nombre: 'Senda' })
|
||||
|
||||
expect(prismaMock.desarrollo.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
clave: 'SENDA',
|
||||
nombre: 'Senda',
|
||||
estado: 'ACTIVO',
|
||||
tipo: 'VERTICAL',
|
||||
activoEnCotizador: true,
|
||||
activoEnAlta: true,
|
||||
}),
|
||||
})
|
||||
expect(ficha.clave).toBe('SENDA')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listarDesarrollos', () => {
|
||||
it('cuenta unidades totales/disponibles y expone tieneReglas', async () => {
|
||||
prismaMock.desarrollo.findMany.mockResolvedValue([
|
||||
filaDesarrollo({ reglas: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedAt: new Date(), updatedPorId: null } }),
|
||||
filaDesarrollo({ id: 'd-2', clave: 'SOLE', nombre: 'Sole' }),
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
|
||||
{ desarrollo: 'SENDA', status: 'VENDIDO' },
|
||||
{ desarrollo: 'SOLE', status: 'disponible' },
|
||||
])
|
||||
|
||||
const { desarrollos } = await listarDesarrollos()
|
||||
|
||||
expect(desarrollos[0]).toMatchObject({ clave: 'SENDA', unidadesTotales: 2, unidadesDisponibles: 1, tieneReglas: true })
|
||||
expect(desarrollos[1]).toMatchObject({ clave: 'SOLE', unidadesTotales: 1, unidadesDisponibles: 1, tieneReglas: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('actualizarDesarrollo', () => {
|
||||
it('404 si el desarrollo no existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
|
||||
await expect(actualizarDesarrollo('nope', { nombre: 'X' })).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
|
||||
it('no deja cambiar la clave si tiene unidades', async () => {
|
||||
prismaMock.desarrollo.findUnique
|
||||
.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' })
|
||||
.mockResolvedValueOnce(null) // clave destino libre
|
||||
prismaMock.unidad.count.mockResolvedValue(4)
|
||||
|
||||
await expect(actualizarDesarrollo('d-1', { clave: 'SENDA2' })).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.desarrollo.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── Borrado bloqueado ─────────────────────────────────────────────────
|
||||
|
||||
describe('eliminarDesarrollo', () => {
|
||||
it('409 con el mensaje acordado si tiene inventario y operación', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
|
||||
prismaMock.cotizacion.count.mockResolvedValue(7)
|
||||
|
||||
await expect(eliminarDesarrollo('d-1')).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: 'El desarrollo tiene 2 unidades y 7 registros de operación; desactívalo en vez de borrarlo',
|
||||
})
|
||||
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('borra si no hay inventario ni operación', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
await eliminarDesarrollo('d-1')
|
||||
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Inventario ────────────────────────────────────────────────────────
|
||||
|
||||
describe('crearUnidades — generador', () => {
|
||||
it('genera una unidad por número del rango con {clave} y {n}', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
|
||||
const resultado = await crearUnidades('d-1', {
|
||||
generar: { patron: '{clave}-401-{n}', desde: 1, hasta: 3, precioLista: 2500000, supConstruccion: 88 },
|
||||
})
|
||||
|
||||
expect(resultado.creadas).toBe(3)
|
||||
expect(resultado.unidades.map((u) => u.codigo)).toEqual(['SENDA-401-1', 'SENDA-401-2', 'SENDA-401-3'])
|
||||
expect(prismaMock.unidad.create).toHaveBeenCalledTimes(3)
|
||||
expect(prismaMock.unidad.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
codigo: 'SENDA-401-1',
|
||||
desarrollo: 'SENDA',
|
||||
status: 'DISPONIBLE',
|
||||
precioLista: 2500000,
|
||||
supConstruccion: 88,
|
||||
precioAcabados: 0,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('el esquema rechaza un patrón sin {n} y un body con ambos modos', () => {
|
||||
expect(() => crearUnidadesSchema.parse({ generar: { patron: 'SENDA-401', desde: 1, hasta: 3, precioLista: 1 } })).toThrow()
|
||||
expect(() =>
|
||||
crearUnidadesSchema.parse({ unidades: [{ precioLista: 1 }], generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }),
|
||||
).toThrow()
|
||||
expect(() => crearUnidadesSchema.parse({})).toThrow()
|
||||
})
|
||||
|
||||
it('rechaza un rango invertido', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
await expect(
|
||||
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 5, hasta: 1, precioLista: 1 } }),
|
||||
).rejects.toMatchObject({ status: 400 })
|
||||
})
|
||||
|
||||
it('resuelve el modelo por id validando que sea del desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA' })
|
||||
|
||||
const resultado = await crearUnidades('d-1', {
|
||||
generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-1', precioLista: 1 },
|
||||
})
|
||||
expect(resultado.unidades[0]?.modelo).toBe('HIMALAYA')
|
||||
})
|
||||
|
||||
it('rechaza un modelo de otro desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-9', desarrolloId: 'd-9', nombre: 'AJENO' })
|
||||
|
||||
await expect(
|
||||
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-9', precioLista: 1 } }),
|
||||
).rejects.toMatchObject({ status: 400 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('crearUnidades — alta manual y duplicados', () => {
|
||||
it('arma el código con manzana y lote cuando no se envía', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
|
||||
const resultado = await crearUnidades('d-1', {
|
||||
unidades: [{ manzana: '4', lote: '12', precioLista: 100, precioAcabados: 120 }],
|
||||
})
|
||||
expect(resultado.unidades[0]?.codigo).toBe('SENDA-4-12')
|
||||
})
|
||||
|
||||
it('409 y no crea nada si un código ya existe en la BD', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany
|
||||
.mockResolvedValueOnce([]) // codigos del desarrollo (correlativo)
|
||||
.mockResolvedValueOnce([{ codigo: 'SENDA-9' }]) // ya existe
|
||||
|
||||
await expect(
|
||||
crearUnidades('d-1', { unidades: [{ codigo: 'SENDA-9', precioLista: 1 }] }),
|
||||
).rejects.toMatchObject({ status: 409, message: 'Códigos de unidad duplicados: SENDA-9' })
|
||||
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('409 si el lote repetido aparece dos veces en la misma alta', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([])
|
||||
|
||||
await expect(
|
||||
crearUnidades('d-1', {
|
||||
unidades: [
|
||||
{ codigo: 'SENDA-7', precioLista: 1 },
|
||||
{ codigo: 'SENDA-7', precioLista: 2 },
|
||||
],
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('eliminarUnidad', () => {
|
||||
it('409 si la unidad tiene cotizaciones o ventas', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
|
||||
prismaMock.cotizacion.count.mockResolvedValue(2)
|
||||
|
||||
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('404 si la unidad no pertenece al desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad({ desarrollo: 'SOLANA' }))
|
||||
|
||||
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
|
||||
it('borra una unidad libre', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
|
||||
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
|
||||
|
||||
await eliminarUnidad('d-1', 'SENDA-1')
|
||||
expect(prismaMock.unidad.delete).toHaveBeenCalledWith({ where: { codigo: 'SENDA-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Modelos ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('eliminarModelo', () => {
|
||||
it('409 si el modelo tiene unidades', async () => {
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
|
||||
id: 'm-1',
|
||||
nombre: 'HIMALAYA',
|
||||
desarrollo: { clave: 'SENDA' },
|
||||
})
|
||||
prismaMock.unidad.count.mockResolvedValue(79)
|
||||
|
||||
await expect(eliminarModelo('m-1')).rejects.toMatchObject({ status: 409 })
|
||||
expect(prismaMock.modeloDesarrollo.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('borra el modelo sin unidades', async () => {
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
|
||||
id: 'm-1',
|
||||
nombre: 'NUEVO',
|
||||
desarrollo: { clave: 'SENDA' },
|
||||
})
|
||||
await eliminarModelo('m-1')
|
||||
expect(prismaMock.modeloDesarrollo.delete).toHaveBeenCalledWith({ where: { id: 'm-1' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('agregarModelo', () => {
|
||||
it('409 si el nombre ya existe en el desarrollo', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
|
||||
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1' })
|
||||
|
||||
await expect(agregarModelo('d-1', { nombre: 'HIMALAYA' })).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Reglas del cotizador ──────────────────────────────────────────────
|
||||
|
||||
describe('parametrosDesarrolloSchema', () => {
|
||||
it('acepta la forma real del cotizador', () => {
|
||||
expect(parametrosDesarrolloSchema.parse(REGLAS_VALIDAS)).toMatchObject({ precioUnico: true })
|
||||
})
|
||||
|
||||
it('rechaza porcentajes fuera de 0..1', () => {
|
||||
const invalido = {
|
||||
...REGLAS_VALIDAS,
|
||||
propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], pctEnganche: 1.5 }],
|
||||
}
|
||||
expect(() => parametrosDesarrolloSchema.parse(invalido)).toThrow()
|
||||
})
|
||||
|
||||
it('rechaza enums desconocidos y claves de más', () => {
|
||||
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, fuenteDeN: 'otra' })).toThrow()
|
||||
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], modoMensualidades: 'raro' }] })).toThrow()
|
||||
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, extra: 1 })).toThrow()
|
||||
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], descuentoDefault: -1 }] })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('guardarReglas', () => {
|
||||
it('hace upsert del JSON validado y devuelve el objeto parseado', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
|
||||
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
|
||||
desarrolloId: 'd-1',
|
||||
parametrosJson: JSON.stringify(REGLAS_VALIDAS),
|
||||
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
|
||||
updatedPorId: 'u-admin',
|
||||
})
|
||||
|
||||
const guardadas = await guardarReglas('d-1', REGLAS_VALIDAS, 'u-admin')
|
||||
|
||||
expect(prismaMock.reglaCotizadorDesarrollo.upsert).toHaveBeenCalledWith({
|
||||
where: { desarrolloId: 'd-1' },
|
||||
update: { parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
|
||||
create: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
|
||||
})
|
||||
expect(guardadas.parametros.precioUnico).toBe(true)
|
||||
expect(guardadas.updatedPorId).toBe('u-admin')
|
||||
})
|
||||
|
||||
it('404 si el desarrollo no existe', async () => {
|
||||
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
|
||||
await expect(guardarReglas('nope', REGLAS_VALIDAS, null)).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
// ── Público ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('listarDesarrollosPublicos', () => {
|
||||
it('solo publica activos no cerrados, con inventario disponible y sin datos personales', async () => {
|
||||
prismaMock.desarrollo.findMany.mockResolvedValue([
|
||||
filaDesarrollo({
|
||||
modelos: [
|
||||
{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
|
||||
],
|
||||
}),
|
||||
])
|
||||
prismaMock.unidad.findMany.mockResolvedValue([
|
||||
filaUnidad({ codigo: 'SENDA-1', nivel: '4' }),
|
||||
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 999 }),
|
||||
])
|
||||
|
||||
const { desarrollos } = await listarDesarrollosPublicos()
|
||||
const d = desarrollos[0]
|
||||
|
||||
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith({
|
||||
where: { activoEnCotizador: true, estado: { not: 'CERRADO' } },
|
||||
include: expect.anything(),
|
||||
orderBy: { clave: 'asc' },
|
||||
})
|
||||
expect(d?.unidadesDisponibles).toEqual([
|
||||
{
|
||||
codigo: 'SENDA-1',
|
||||
modelo: 'HIMALAYA',
|
||||
nivel: '4',
|
||||
precioLista: 3000000,
|
||||
precioAcabados: 3200000,
|
||||
supConstruccion: 120,
|
||||
},
|
||||
])
|
||||
expect(d?.modelos[0]).toMatchObject({ nombre: 'HIMALAYA', disponibles: 1, precioDesde: 3000000 })
|
||||
|
||||
// Nunca consulta tablas con datos de clientes y la ficha no trae esos campos
|
||||
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
|
||||
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
|
||||
const json = JSON.stringify(desarrollos)
|
||||
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado']) {
|
||||
expect(json.toLowerCase()).not.toContain(prohibido)
|
||||
}
|
||||
})
|
||||
|
||||
it('404 si la clave no existe o no es pública', async () => {
|
||||
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
|
||||
await expect(obtenerDesarrolloPublico('NOEXISTE')).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
})
|
||||
754
api-gateway/src/services/desarrollos.service.ts
Normal file
754
api-gateway/src/services/desarrollos.service.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
import { z } from 'zod'
|
||||
import { prisma } from './prisma.js'
|
||||
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
* Módulo Desarrollos: la BD es la fuente de verdad del catálogo de
|
||||
* desarrollos (ficha comercial, modelos de unidad y reglas del cotizador).
|
||||
*
|
||||
* Sustituye a las listas fijas del código (DESARROLLOS de
|
||||
* config/campos-departamento.ts y las constantes de
|
||||
* apps/cotizador/src/data/desarrollos.ts), que hoy siguen funcionando como
|
||||
* espejo hasta que el resto de los frentes migre a esta tabla.
|
||||
*
|
||||
* El inventario sigue viviendo en `Unidad`, ligado por `desarrollo` = `clave`
|
||||
* del desarrollo (el `codigo` de la unidad se forma con esa clave).
|
||||
*/
|
||||
|
||||
/** Estados comerciales del desarrollo */
|
||||
export const ESTADOS_DESARROLLO = ['ACTIVO', 'PREVENTA', 'CERRADO'] as const
|
||||
/** VERTICAL = edificio (deptos); HORIZONTAL = casas en lote */
|
||||
export const TIPOS_DESARROLLO = ['VERTICAL', 'HORIZONTAL'] as const
|
||||
/** Formatos de propuesta del cotizador */
|
||||
export const TIPOS_PROPUESTA = [
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'CONTADO',
|
||||
'SEMI_CONTADO',
|
||||
] as const
|
||||
/** Claves de descuento editables del cotizador */
|
||||
export const CLAVES_DESCUENTO = ['creditoBancario', 'creditoInfonavit', 'contado', 'semiContado'] as const
|
||||
|
||||
const CLAVE_DESARROLLO = /^[A-Z0-9-]{2,20}$/
|
||||
|
||||
// ── Validación de las reglas del cotizador ────────────────────────────
|
||||
|
||||
const fraccion = (campo: string) =>
|
||||
z.number().min(0, `${campo} debe estar entre 0 y 1`).max(1, `${campo} debe estar entre 0 y 1`)
|
||||
|
||||
const propuestaSchema = z
|
||||
.object({
|
||||
tipo: z.enum(TIPOS_PROPUESTA),
|
||||
claveDescuento: z.enum(CLAVES_DESCUENTO),
|
||||
titulo: z.string().trim().min(1),
|
||||
descuentoDefault: fraccion('descuentoDefault'),
|
||||
pctEnganche: fraccion('pctEnganche'),
|
||||
engancheBase: z.enum(['lista', 'final']),
|
||||
pctPagoFinal: fraccion('pctPagoFinal'),
|
||||
pagoFinalBase: z.enum(['lista', 'final']),
|
||||
// 'porcentaje' es el modo de la mayoría de los desarrollos; sin él la
|
||||
// config real del cotizador no valida (el brief lo omitía por error).
|
||||
modoMensualidades: z.enum(['remanente', 'remanenteLista', 'porcentaje', 'ninguna']),
|
||||
// SOLANA Infonavit: si el enganche es 0 y no hay apartado, no se resta
|
||||
engancheCeroSinApartado: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/**
|
||||
* Forma del JSON de reglas: la config que antes vivía en
|
||||
* apps/cotizador/src/data/desarrollos.ts. `id`/`nombre` se aceptan porque los
|
||||
* objetos del cotizador los traen, pero la fuente de verdad del nombre es la
|
||||
* ficha del desarrollo.
|
||||
*/
|
||||
export const parametrosDesarrolloSchema = z
|
||||
.object({
|
||||
id: z.string().trim().min(1).optional(),
|
||||
nombre: z.string().trim().min(1).optional(),
|
||||
precioUnico: z.boolean(),
|
||||
fuenteDeN: z.enum(['fechaEntrega', 'fijo', 'porUnidad']),
|
||||
// N fijo cuando fuenteDeN === 'fijo' (entrega inmediata)
|
||||
nFijo: z.number().int().min(0).optional(),
|
||||
nDefault: z.number().int().min(0),
|
||||
// null => el cotizador muestra "PENDIENTE" (desarrollos ya entregados)
|
||||
cuotaMantenimiento: z.number().min(0).nullable(),
|
||||
prefijoUnidadPdf: z.string().trim().min(1),
|
||||
propuestas: z.array(propuestaSchema).min(1, 'Se requiere al menos una propuesta'),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ParametrosDesarrolloJson = z.infer<typeof parametrosDesarrolloSchema>
|
||||
|
||||
// ── Validación de la ficha ────────────────────────────────────────────
|
||||
|
||||
export const crearDesarrolloSchema = z
|
||||
.object({
|
||||
clave: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(CLAVE_DESARROLLO, 'La clave debe ser mayúsculas sin espacios (2 a 20 caracteres: A-Z, 0-9, -)'),
|
||||
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
|
||||
estado: z.enum(ESTADOS_DESARROLLO).optional(),
|
||||
tipo: z.enum(TIPOS_DESARROLLO).optional(),
|
||||
constructora: z.string().trim().min(1).nullable().optional(),
|
||||
direccion: z.string().trim().min(1).nullable().optional(),
|
||||
zona: z.string().trim().min(1).nullable().optional(),
|
||||
descripcion: z.string().trim().min(1).nullable().optional(),
|
||||
amenidades: z.string().trim().min(1).nullable().optional(),
|
||||
fechaInicioObra: z.coerce.date().nullable().optional(),
|
||||
fechaEntregaProgramada: z.coerce.date().nullable().optional(),
|
||||
fechaEntregaReprogramada: z.coerce.date().nullable().optional(),
|
||||
activoEnCotizador: z.boolean().optional(),
|
||||
activoEnAlta: z.boolean().optional(),
|
||||
prefijoUnidadPdf: z.string().trim().min(1).nullable().optional(),
|
||||
imagenUrl: z.string().trim().min(1).nullable().optional(),
|
||||
logoUrl: z.string().trim().min(1).nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const actualizarDesarrolloSchema = crearDesarrolloSchema.partial()
|
||||
|
||||
export const crearModeloSchema = z
|
||||
.object({
|
||||
nombre: z.string().trim().min(1, 'El nombre del modelo es obligatorio'),
|
||||
recamaras: z.number().int().min(0).nullable().optional(),
|
||||
banos: z.number().min(0).nullable().optional(),
|
||||
estacionamientos: z.number().int().min(0).nullable().optional(),
|
||||
supConstruccion: z.number().min(0).nullable().optional(),
|
||||
activo: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const actualizarModeloSchema = crearModeloSchema.partial()
|
||||
|
||||
const unidadManualSchema = z
|
||||
.object({
|
||||
codigo: z.string().trim().min(1).optional(),
|
||||
manzana: z.string().trim().optional(),
|
||||
lote: z.string().trim().optional(),
|
||||
nivel: z.string().trim().optional(),
|
||||
modeloId: z.string().trim().min(1).optional(),
|
||||
precioLista: z.number().min(0, 'precioLista es obligatorio'),
|
||||
precioAcabados: z.number().min(0).optional(),
|
||||
supConstruccion: z.number().min(0).optional(),
|
||||
supExcedente: z.number().min(0).optional(),
|
||||
supEstacionamiento: z.number().min(0).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/** Tope del generador: un rango más grande huele a error de captura */
|
||||
export const MAX_UNIDADES_POR_ALTA = 1000
|
||||
|
||||
const generarUnidadesSchema = z
|
||||
.object({
|
||||
patron: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((p) => p.includes('{n}'), 'El patrón debe incluir {n} (el número de la unidad)')
|
||||
// El código final sigue el formato de la clave: mayúsculas sin espacios
|
||||
.refine((p) => !/[^A-Za-z0-9{}_-]/.test(p), 'El patrón solo admite letras, números, guiones, {clave} y {n}'),
|
||||
desde: z.number().int(),
|
||||
hasta: z.number().int(),
|
||||
modeloId: z.string().trim().min(1).optional(),
|
||||
precioLista: z.number().min(0),
|
||||
precioAcabados: z.number().min(0).optional(),
|
||||
supConstruccion: z.number().min(0).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const crearUnidadesSchema = z
|
||||
.object({
|
||||
unidades: z.array(unidadManualSchema).min(1).max(MAX_UNIDADES_POR_ALTA).optional(),
|
||||
generar: generarUnidadesSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((b) => (b.unidades === undefined) !== (b.generar === undefined), {
|
||||
message: 'Envía `unidades` (alta manual) o `generar` (rango), pero no ambos',
|
||||
})
|
||||
|
||||
export type CrearUnidadesBody = z.infer<typeof crearUnidadesSchema>
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
interface ModeloFila {
|
||||
id: string
|
||||
nombre: string
|
||||
recamaras: number | null
|
||||
banos: number | null
|
||||
estacionamientos: number | null
|
||||
supConstruccion: number | null
|
||||
activo: boolean
|
||||
}
|
||||
|
||||
function modeloDto(m: ModeloFila) {
|
||||
return {
|
||||
id: m.id,
|
||||
nombre: m.nombre,
|
||||
recamaras: m.recamaras,
|
||||
banos: m.banos,
|
||||
estacionamientos: m.estacionamientos,
|
||||
supConstruccion: m.supConstruccion,
|
||||
activo: m.activo,
|
||||
}
|
||||
}
|
||||
|
||||
function esDisponible(status: string): boolean {
|
||||
return status.trim().toUpperCase() === 'DISPONIBLE'
|
||||
}
|
||||
|
||||
/** Clave de desarrollo normalizada (la tabla Unidad la guarda en mayúsculas) */
|
||||
export function normalizarClave(clave: string): string {
|
||||
return clave.trim().toUpperCase()
|
||||
}
|
||||
|
||||
/** Conteos por desarrollo a partir de una lectura del inventario */
|
||||
function conteosPorDesarrollo(unidades: { desarrollo: string; status: string }[]): Map<string, { total: number; disponibles: number }> {
|
||||
const mapa = new Map<string, { total: number; disponibles: number }>()
|
||||
for (const u of unidades) {
|
||||
const clave = normalizarClave(u.desarrollo)
|
||||
const actual = mapa.get(clave) ?? { total: 0, disponibles: 0 }
|
||||
actual.total++
|
||||
if (esDisponible(u.status)) actual.disponibles++
|
||||
mapa.set(clave, actual)
|
||||
}
|
||||
return mapa
|
||||
}
|
||||
|
||||
function parsearReglas(regla: { parametrosJson: string; updatedAt: Date; updatedPorId: string | null } | null) {
|
||||
if (regla === null) return null
|
||||
let parametros: unknown
|
||||
try {
|
||||
parametros = JSON.parse(regla.parametrosJson)
|
||||
} catch {
|
||||
parametros = null
|
||||
}
|
||||
return {
|
||||
parametros,
|
||||
updatedAt: regla.updatedAt,
|
||||
updatedPorId: regla.updatedPorId,
|
||||
}
|
||||
}
|
||||
|
||||
/** Desarrollo + relaciones tal como lo devuelve Prisma en las lecturas de ficha */
|
||||
const INCLUDE_FICHA = { modelos: { orderBy: { nombre: 'asc' } }, reglas: true } as const
|
||||
|
||||
async function fichaPorId(id: string) {
|
||||
const fila = await prisma.desarrollo.findUnique({ where: { id }, include: INCLUDE_FICHA })
|
||||
if (fila === null) return null
|
||||
const unidades = await prisma.unidad.findMany({
|
||||
where: { desarrollo: fila.clave },
|
||||
select: { status: true },
|
||||
})
|
||||
const porEstatus: Record<string, number> = {}
|
||||
for (const u of unidades) {
|
||||
const estatus = u.status.trim().toUpperCase() || 'SIN_ESTATUS'
|
||||
porEstatus[estatus] = (porEstatus[estatus] ?? 0) + 1
|
||||
}
|
||||
return {
|
||||
id: fila.id,
|
||||
clave: fila.clave,
|
||||
nombre: fila.nombre,
|
||||
estado: fila.estado,
|
||||
tipo: fila.tipo,
|
||||
constructora: fila.constructora,
|
||||
direccion: fila.direccion,
|
||||
zona: fila.zona,
|
||||
descripcion: fila.descripcion,
|
||||
amenidades: fila.amenidades,
|
||||
fechaInicioObra: fila.fechaInicioObra,
|
||||
fechaEntregaProgramada: fila.fechaEntregaProgramada,
|
||||
fechaEntregaReprogramada: fila.fechaEntregaReprogramada,
|
||||
activoEnCotizador: fila.activoEnCotizador,
|
||||
activoEnAlta: fila.activoEnAlta,
|
||||
prefijoUnidadPdf: fila.prefijoUnidadPdf,
|
||||
imagenUrl: fila.imagenUrl,
|
||||
logoUrl: fila.logoUrl,
|
||||
createdAt: fila.createdAt,
|
||||
updatedAt: fila.updatedAt,
|
||||
modelos: fila.modelos.map(modeloDto),
|
||||
reglas: parsearReglas(fila.reglas),
|
||||
unidadesTotales: unidades.length,
|
||||
unidadesDisponibles: unidades.filter((u) => esDisponible(u.status)).length,
|
||||
unidadesPorEstatus: porEstatus,
|
||||
}
|
||||
}
|
||||
|
||||
export type FichaDesarrollo = NonNullable<Awaited<ReturnType<typeof fichaPorId>>>
|
||||
|
||||
/** La ficha completa o 404 */
|
||||
export async function obtenerDesarrollo(id: string): Promise<FichaDesarrollo> {
|
||||
const ficha = await fichaPorId(id)
|
||||
if (ficha === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
return ficha
|
||||
}
|
||||
|
||||
// ── Listado ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function listarDesarrollos() {
|
||||
const [desarrollos, unidades] = await Promise.all([
|
||||
prisma.desarrollo.findMany({ include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
|
||||
prisma.unidad.findMany({ select: { desarrollo: true, status: true } }),
|
||||
])
|
||||
const conteos = conteosPorDesarrollo(unidades)
|
||||
return {
|
||||
desarrollos: desarrollos.map((d) => {
|
||||
const c = conteos.get(normalizarClave(d.clave)) ?? { total: 0, disponibles: 0 }
|
||||
return {
|
||||
id: d.id,
|
||||
clave: d.clave,
|
||||
nombre: d.nombre,
|
||||
estado: d.estado,
|
||||
tipo: d.tipo,
|
||||
activoEnCotizador: d.activoEnCotizador,
|
||||
activoEnAlta: d.activoEnAlta,
|
||||
modelos: d.modelos.map(modeloDto),
|
||||
unidadesTotales: c.total,
|
||||
unidadesDisponibles: c.disponibles,
|
||||
tieneReglas: d.reglas !== null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alta / cambio / borrado ───────────────────────────────────────────
|
||||
|
||||
export async function crearDesarrollo(data: z.infer<typeof crearDesarrolloSchema>) {
|
||||
const clave = normalizarClave(data.clave)
|
||||
const existente = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
|
||||
if (existente !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
|
||||
|
||||
const fila = await prisma.desarrollo.create({
|
||||
data: {
|
||||
clave,
|
||||
nombre: data.nombre,
|
||||
estado: data.estado ?? 'ACTIVO',
|
||||
tipo: data.tipo ?? 'VERTICAL',
|
||||
constructora: data.constructora ?? null,
|
||||
direccion: data.direccion ?? null,
|
||||
zona: data.zona ?? null,
|
||||
descripcion: data.descripcion ?? null,
|
||||
amenidades: data.amenidades ?? null,
|
||||
fechaInicioObra: data.fechaInicioObra ?? null,
|
||||
fechaEntregaProgramada: data.fechaEntregaProgramada ?? null,
|
||||
fechaEntregaReprogramada: data.fechaEntregaReprogramada ?? null,
|
||||
activoEnCotizador: data.activoEnCotizador ?? true,
|
||||
activoEnAlta: data.activoEnAlta ?? true,
|
||||
prefijoUnidadPdf: data.prefijoUnidadPdf ?? null,
|
||||
imagenUrl: data.imagenUrl ?? null,
|
||||
logoUrl: data.logoUrl ?? null,
|
||||
},
|
||||
})
|
||||
return obtenerDesarrollo(fila.id)
|
||||
}
|
||||
|
||||
export async function actualizarDesarrollo(id: string, data: z.infer<typeof actualizarDesarrolloSchema>) {
|
||||
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
|
||||
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
// Solo se actualizan los campos enviados (los undefined se descartan)
|
||||
const cambios: Record<string, unknown> = {}
|
||||
for (const [campo, valor] of Object.entries(data)) {
|
||||
if (valor !== undefined) cambios[campo] = valor
|
||||
}
|
||||
if (data.clave !== undefined) {
|
||||
const clave = normalizarClave(data.clave)
|
||||
if (clave !== actual.clave) {
|
||||
const ocupada = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
|
||||
if (ocupada !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
|
||||
// El inventario se liga por clave: renombrar la clave sin arrastrar las
|
||||
// unidades las dejaría huérfanas, así que solo se permite sin unidades.
|
||||
const unidades = await prisma.unidad.count({ where: { desarrollo: actual.clave } })
|
||||
if (unidades > 0) {
|
||||
throw new AppError(409, `El desarrollo tiene ${unidades} unidades; no se puede cambiar la clave`)
|
||||
}
|
||||
}
|
||||
cambios['clave'] = clave
|
||||
}
|
||||
|
||||
await prisma.desarrollo.update({ where: { id }, data: cambios as never })
|
||||
return obtenerDesarrollo(id)
|
||||
}
|
||||
|
||||
/** Registros de operación ligados al desarrollo: impiden borrarlo */
|
||||
async function contarRegistrosDeOperacion(clave: string, codigos: string[]): Promise<number> {
|
||||
const porUnidad = codigos.length > 0 ? { unidadCodigo: { in: codigos } } : null
|
||||
const [cotizaciones, leads, clientes, planes] = await Promise.all([
|
||||
prisma.cotizacion.count({ where: porUnidad === null ? { desarrollo: clave } : { OR: [{ desarrollo: clave }, porUnidad] } }),
|
||||
prisma.leadCaptura.count({ where: { desarrollo: clave } }),
|
||||
prisma.cliente.count({ where: { proyecto: clave } }),
|
||||
prisma.planPago.count({ where: { proyecto: clave } }),
|
||||
])
|
||||
return cotizaciones + leads + clientes + planes
|
||||
}
|
||||
|
||||
export async function eliminarDesarrollo(id: string): Promise<void> {
|
||||
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
|
||||
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
const unidades = await prisma.unidad.findMany({ where: { desarrollo: actual.clave }, select: { codigo: true } })
|
||||
const operacion = await contarRegistrosDeOperacion(
|
||||
actual.clave,
|
||||
unidades.map((u) => u.codigo),
|
||||
)
|
||||
if (unidades.length > 0 || operacion > 0) {
|
||||
throw new AppError(
|
||||
409,
|
||||
`El desarrollo tiene ${unidades.length} unidades y ${operacion} registros de operación; desactívalo en vez de borrarlo`,
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.desarrollo.delete({ where: { id } })
|
||||
}
|
||||
|
||||
// ── Modelos ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function agregarModelo(desarrolloId: string, data: z.infer<typeof crearModeloSchema>) {
|
||||
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
|
||||
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
const repetido = await prisma.modeloDesarrollo.findUnique({
|
||||
where: { desarrolloId_nombre: { desarrolloId, nombre: data.nombre } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
|
||||
|
||||
return prisma.modeloDesarrollo.create({
|
||||
data: {
|
||||
desarrolloId,
|
||||
nombre: data.nombre,
|
||||
recamaras: data.recamaras ?? null,
|
||||
banos: data.banos ?? null,
|
||||
estacionamientos: data.estacionamientos ?? null,
|
||||
supConstruccion: data.supConstruccion ?? null,
|
||||
activo: data.activo ?? true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function actualizarModelo(modeloId: string, data: z.infer<typeof actualizarModeloSchema>) {
|
||||
const actual = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
|
||||
if (actual === null) throw new NotFoundError('Modelo no encontrado')
|
||||
|
||||
if (data.nombre !== undefined && data.nombre !== actual.nombre) {
|
||||
const repetido = await prisma.modeloDesarrollo.findUnique({
|
||||
where: { desarrolloId_nombre: { desarrolloId: actual.desarrolloId, nombre: data.nombre } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
|
||||
}
|
||||
|
||||
const cambios: Record<string, unknown> = {}
|
||||
for (const [campo, valor] of Object.entries(data)) {
|
||||
if (valor !== undefined) cambios[campo] = valor
|
||||
}
|
||||
return prisma.modeloDesarrollo.update({ where: { id: modeloId }, data: cambios as never })
|
||||
}
|
||||
|
||||
export async function eliminarModelo(modeloId: string): Promise<void> {
|
||||
const actual = await prisma.modeloDesarrollo.findUnique({
|
||||
where: { id: modeloId },
|
||||
include: { desarrollo: { select: { clave: true } } },
|
||||
})
|
||||
if (actual === null) throw new NotFoundError('Modelo no encontrado')
|
||||
|
||||
const unidades = await prisma.unidad.count({
|
||||
where: { desarrollo: actual.desarrollo.clave, modelo: actual.nombre },
|
||||
})
|
||||
if (unidades > 0) {
|
||||
throw new AppError(409, `El modelo ${actual.nombre} tiene ${unidades} unidades; no se puede eliminar`)
|
||||
}
|
||||
|
||||
await prisma.modeloDesarrollo.delete({ where: { id: modeloId } })
|
||||
}
|
||||
|
||||
// ── Reglas del cotizador ──────────────────────────────────────────────
|
||||
|
||||
export async function guardarReglas(desarrolloId: string, parametros: ParametrosDesarrolloJson, usuarioId: string | null) {
|
||||
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
|
||||
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
const parametrosJson = JSON.stringify(parametros)
|
||||
const regla = await prisma.reglaCotizadorDesarrollo.upsert({
|
||||
where: { desarrolloId },
|
||||
update: { parametrosJson, updatedPorId: usuarioId },
|
||||
create: { desarrolloId, parametrosJson, updatedPorId: usuarioId },
|
||||
})
|
||||
|
||||
return {
|
||||
desarrolloId: regla.desarrolloId,
|
||||
parametros: JSON.parse(regla.parametrosJson) as ParametrosDesarrolloJson,
|
||||
updatedAt: regla.updatedAt,
|
||||
updatedPorId: regla.updatedPorId,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inventario ────────────────────────────────────────────────────────
|
||||
|
||||
interface UnidadNueva {
|
||||
codigo: string
|
||||
modelo: string
|
||||
manzana: string
|
||||
lote: string
|
||||
nivel: string
|
||||
precioLista: number
|
||||
precioAcabados: number
|
||||
supConstruccion: number
|
||||
supExcedente: number
|
||||
supEstacionamiento: number
|
||||
status: string
|
||||
}
|
||||
|
||||
/** Modelo del desarrollo por id (valida que pertenezca al desarrollo) */
|
||||
async function nombreModelo(desarrolloId: string, modeloId: string): Promise<string> {
|
||||
const modelo = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
|
||||
if (modelo === null || modelo.desarrolloId !== desarrolloId) {
|
||||
throw new BadRequestError('El modelo no pertenece a este desarrollo')
|
||||
}
|
||||
return modelo.nombre
|
||||
}
|
||||
|
||||
/** Correlativo siguiente de `${clave}-n` en el inventario del desarrollo */
|
||||
function correlativoDesdeCodigos(clave: string, codigos: string[]): number {
|
||||
const patron = new RegExp(`^${clave}-(\\d+)$`)
|
||||
let max = 0
|
||||
for (const codigo of codigos) {
|
||||
const m = patron.exec(codigo)
|
||||
if (m === null) continue
|
||||
const n = Number(m[1])
|
||||
if (n > max) max = n
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
export async function crearUnidades(
|
||||
desarrolloId: string,
|
||||
body: CrearUnidadesBody,
|
||||
): Promise<{ creadas: number; unidades: UnidadNueva[] }> {
|
||||
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true, clave: true } })
|
||||
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
const clave = desarrollo.clave
|
||||
|
||||
let filas: UnidadNueva[]
|
||||
|
||||
if (body.generar !== undefined) {
|
||||
const g = body.generar
|
||||
if (g.hasta < g.desde) throw new BadRequestError('El rango es inválido: `hasta` debe ser mayor o igual a `desde`')
|
||||
if (g.hasta - g.desde + 1 > MAX_UNIDADES_POR_ALTA) {
|
||||
throw new BadRequestError(`El rango genera más de ${MAX_UNIDADES_POR_ALTA} unidades`)
|
||||
}
|
||||
const modelo = g.modeloId !== undefined ? await nombreModelo(desarrolloId, g.modeloId) : ''
|
||||
filas = []
|
||||
for (let n = g.desde; n <= g.hasta; n++) {
|
||||
const codigo = g.patron.replaceAll('{clave}', clave).replaceAll('{n}', String(n))
|
||||
filas.push({
|
||||
codigo,
|
||||
modelo,
|
||||
manzana: '',
|
||||
lote: '',
|
||||
nivel: '',
|
||||
precioLista: g.precioLista,
|
||||
precioAcabados: g.precioAcabados ?? 0,
|
||||
supConstruccion: g.supConstruccion ?? 0,
|
||||
supExcedente: 0,
|
||||
supEstacionamiento: 0,
|
||||
status: 'DISPONIBLE',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const manuales = body.unidades ?? []
|
||||
const existentes = await prisma.unidad.findMany({ where: { desarrollo: clave }, select: { codigo: true } })
|
||||
let correlativo = correlativoDesdeCodigos(clave, existentes.map((u) => u.codigo))
|
||||
filas = []
|
||||
for (const u of manuales) {
|
||||
const modelo = u.modeloId !== undefined ? await nombreModelo(desarrolloId, u.modeloId) : ''
|
||||
// Sin código explícito se arma con manzana/lote o con un correlativo
|
||||
const codigo =
|
||||
u.codigo ??
|
||||
(u.manzana !== undefined && u.manzana !== '' && u.lote !== undefined && u.lote !== ''
|
||||
? `${clave}-${u.manzana}-${u.lote}`
|
||||
: `${clave}-${++correlativo}`)
|
||||
filas.push({
|
||||
codigo,
|
||||
modelo,
|
||||
manzana: u.manzana ?? '',
|
||||
lote: u.lote ?? '',
|
||||
nivel: u.nivel ?? '',
|
||||
precioLista: u.precioLista,
|
||||
precioAcabados: u.precioAcabados ?? 0,
|
||||
supConstruccion: u.supConstruccion ?? 0,
|
||||
supExcedente: u.supExcedente ?? 0,
|
||||
supEstacionamiento: u.supEstacionamiento ?? 0,
|
||||
status: 'DISPONIBLE',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const codigos = filas.map((f) => f.codigo)
|
||||
const repetidosEnLote = [...new Set(codigos.filter((c, i) => codigos.indexOf(c) !== i))]
|
||||
const yaEnBd =
|
||||
codigos.length > 0
|
||||
? (await prisma.unidad.findMany({ where: { codigo: { in: codigos } }, select: { codigo: true } })).map((u) => u.codigo)
|
||||
: []
|
||||
const duplicados = [...new Set([...repetidosEnLote, ...yaEnBd])]
|
||||
if (duplicados.length > 0) {
|
||||
throw new AppError(409, `Códigos de unidad duplicados: ${duplicados.join(', ')}`)
|
||||
}
|
||||
|
||||
const creadas = await prisma.$transaction(filas.map((f) => prisma.unidad.create({ data: { ...f, desarrollo: clave } })))
|
||||
return {
|
||||
creadas: creadas.length,
|
||||
unidades: creadas.map((u) => ({
|
||||
codigo: u.codigo,
|
||||
modelo: u.modelo,
|
||||
manzana: u.manzana,
|
||||
lote: u.lote,
|
||||
nivel: u.nivel,
|
||||
precioLista: u.precioLista,
|
||||
precioAcabados: u.precioAcabados,
|
||||
supConstruccion: u.supConstruccion,
|
||||
supExcedente: u.supExcedente,
|
||||
supEstacionamiento: u.supEstacionamiento,
|
||||
status: u.status,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function eliminarUnidad(desarrolloId: string, codigo: string): Promise<void> {
|
||||
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { clave: true } })
|
||||
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
|
||||
const unidad = await prisma.unidad.findUnique({ where: { codigo } })
|
||||
if (unidad === null || normalizarClave(unidad.desarrollo) !== normalizarClave(desarrollo.clave)) {
|
||||
throw new NotFoundError(`Unidad ${codigo} no encontrada en el desarrollo`)
|
||||
}
|
||||
|
||||
const [cotizaciones, planes, clientes] = await Promise.all([
|
||||
prisma.cotizacion.count({ where: { unidadCodigo: codigo } }),
|
||||
prisma.planPago.count({ where: { unidadCodigo: codigo } }),
|
||||
prisma.cliente.count({ where: { unidadCodigo: codigo } }),
|
||||
])
|
||||
const operacion = cotizaciones + planes + clientes
|
||||
if (operacion > 0) {
|
||||
throw new AppError(409, `La unidad ${codigo} tiene ${operacion} cotizaciones o ventas; no se puede eliminar`)
|
||||
}
|
||||
|
||||
await prisma.unidad.delete({ where: { codigo } })
|
||||
}
|
||||
|
||||
// ── Público (micrositio, sin auth) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ficha pública: SOLO información comercial del desarrollo y su inventario
|
||||
* disponible. Nunca clientes, cotizaciones ni datos personales.
|
||||
*/
|
||||
function fichaPublica(
|
||||
desarrollo: {
|
||||
clave: string
|
||||
nombre: string
|
||||
estado: string
|
||||
tipo: string
|
||||
zona: string | null
|
||||
direccion: string | null
|
||||
descripcion: string | null
|
||||
amenidades: string | null
|
||||
imagenUrl: string | null
|
||||
logoUrl: string | null
|
||||
fechaEntregaProgramada: Date | null
|
||||
modelos: { nombre: string; recamaras: number | null; banos: number | null; estacionamientos: number | null; supConstruccion: number | null; activo: boolean }[]
|
||||
},
|
||||
unidades: {
|
||||
codigo: string
|
||||
modelo: string
|
||||
nivel: string
|
||||
precioLista: number
|
||||
precioAcabados: number
|
||||
supConstruccion: number
|
||||
}[],
|
||||
) {
|
||||
const disponiblesPorModelo = new Map<string, { n: number; precioDesde: number | null }>()
|
||||
for (const u of unidades) {
|
||||
const actual = disponiblesPorModelo.get(u.modelo) ?? { n: 0, precioDesde: null }
|
||||
actual.n++
|
||||
if (u.precioLista > 0 && (actual.precioDesde === null || u.precioLista < actual.precioDesde)) {
|
||||
actual.precioDesde = u.precioLista
|
||||
}
|
||||
disponiblesPorModelo.set(u.modelo, actual)
|
||||
}
|
||||
|
||||
return {
|
||||
clave: desarrollo.clave,
|
||||
nombre: desarrollo.nombre,
|
||||
estado: desarrollo.estado,
|
||||
tipo: desarrollo.tipo,
|
||||
zona: desarrollo.zona,
|
||||
direccion: desarrollo.direccion,
|
||||
descripcion: desarrollo.descripcion,
|
||||
amenidades: desarrollo.amenidades,
|
||||
imagenUrl: desarrollo.imagenUrl,
|
||||
logoUrl: desarrollo.logoUrl,
|
||||
fechaEntregaProgramada: desarrollo.fechaEntregaProgramada,
|
||||
modelos: desarrollo.modelos
|
||||
.filter((m) => m.activo)
|
||||
.map((m) => {
|
||||
const disp = disponiblesPorModelo.get(m.nombre) ?? { n: 0, precioDesde: null }
|
||||
return {
|
||||
nombre: m.nombre,
|
||||
recamaras: m.recamaras,
|
||||
banos: m.banos,
|
||||
estacionamientos: m.estacionamientos,
|
||||
supConstruccion: m.supConstruccion,
|
||||
disponibles: disp.n,
|
||||
precioDesde: disp.precioDesde,
|
||||
}
|
||||
}),
|
||||
unidadesDisponibles: unidades.map((u) => ({
|
||||
codigo: u.codigo,
|
||||
modelo: u.modelo,
|
||||
precioLista: u.precioLista,
|
||||
precioAcabados: u.precioAcabados,
|
||||
supConstruccion: u.supConstruccion,
|
||||
...(u.nivel !== '' ? { nivel: u.nivel } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** Filtro de publicación: activo en cotizador y no cerrado */
|
||||
const DONDE_PUBLICO = { activoEnCotizador: true, estado: { not: 'CERRADO' } } as const
|
||||
|
||||
/** Inventario publicado de un desarrollo (solo unidades DISPONIBLE) */
|
||||
async function unidadesPublicas(clave: string) {
|
||||
const filas = await prisma.unidad.findMany({
|
||||
where: { desarrollo: clave },
|
||||
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
|
||||
orderBy: { codigo: 'asc' },
|
||||
})
|
||||
return filas.filter((u) => esDisponible(u.status))
|
||||
}
|
||||
|
||||
export async function listarDesarrollosPublicos() {
|
||||
const [desarrollos, unidades] = await Promise.all([
|
||||
prisma.desarrollo.findMany({ where: DONDE_PUBLICO, include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
|
||||
prisma.unidad.findMany({
|
||||
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
|
||||
orderBy: { codigo: 'asc' },
|
||||
}),
|
||||
])
|
||||
return {
|
||||
desarrollos: desarrollos.map((d) =>
|
||||
fichaPublica(
|
||||
d,
|
||||
unidades
|
||||
.filter((u) => normalizarClave(u.desarrollo) === normalizarClave(d.clave) && esDisponible(u.status))
|
||||
.map((u) => ({ codigo: u.codigo, modelo: u.modelo, nivel: u.nivel, precioLista: u.precioLista, precioAcabados: u.precioAcabados, supConstruccion: u.supConstruccion })),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export async function obtenerDesarrolloPublico(claveCruda: string) {
|
||||
const clave = normalizarClave(claveCruda)
|
||||
const fila = await prisma.desarrollo.findFirst({ where: { clave, ...DONDE_PUBLICO }, include: INCLUDE_FICHA })
|
||||
if (fila === null) throw new NotFoundError('Desarrollo no encontrado')
|
||||
const unidades = await unidadesPublicas(fila.clave)
|
||||
return fichaPublica(fila, unidades)
|
||||
}
|
||||
253
api-gateway/src/services/reglas-desarrollo.service.test.ts
Normal file
253
api-gateway/src/services/reglas-desarrollo.service.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ---------- Prisma mockeado (los tests no tocan BD) ----------
|
||||
// `reglaCotizadorDesarrollo` y `desarrollo` se activan/desactivan por test:
|
||||
// simulan el cliente Prisma con los modelos generados o sin ellos (lectura
|
||||
// cruda de las tablas).
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$queryRawUnsafe: vi.fn(),
|
||||
reglaCotizadorDesarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
|
||||
desarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
|
||||
}))
|
||||
|
||||
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||
|
||||
import { DESARROLLOS, type ParametrosDesarrollo } from './cotizacion.service.js'
|
||||
import {
|
||||
getReglasDesarrollo,
|
||||
getReglasEfectivas,
|
||||
getReglasGuardadas,
|
||||
normalizarReglas,
|
||||
} from './reglas-desarrollo.service.js'
|
||||
|
||||
/** Reglas con la forma exacta que guarda la BD (JSON serializado) */
|
||||
function reglasJson(params: ParametrosDesarrollo, extras: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
...params,
|
||||
...extras,
|
||||
propuestas: params.propuestas.map((p) => ({ ...p, activa: true })),
|
||||
})
|
||||
}
|
||||
|
||||
function usarModeloPrisma(): ReturnType<typeof vi.fn> {
|
||||
const findMany = vi.fn()
|
||||
prismaMock.reglaCotizadorDesarrollo = { findMany }
|
||||
return findMany
|
||||
}
|
||||
|
||||
/** Ficha del desarrollo (tabla Desarrollo) para el catálogo del servicio */
|
||||
function ficha(id: string, clave: string, extras: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
clave,
|
||||
nombre: clave.charAt(0) + clave.slice(1).toLowerCase(),
|
||||
activoEnCotizador: true,
|
||||
prefijoUnidadPdf: 'CASA',
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
function usarCatalogo(filas: unknown[]): void {
|
||||
prismaMock.desarrollo = { findMany: vi.fn().mockResolvedValue(filas) }
|
||||
}
|
||||
|
||||
/** Propuesta del fixture (JSON crudo) por tipo */
|
||||
function brinda(propuestas: Record<string, unknown>[], tipo: string): Record<string, unknown> {
|
||||
const p = propuestas.find((x) => x['tipo'] === tipo)
|
||||
if (!p) throw new Error(`La propuesta ${tipo} no está en el fixture`)
|
||||
return p
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.reglaCotizadorDesarrollo = undefined
|
||||
prismaMock.desarrollo = undefined
|
||||
prismaMock.$queryRawUnsafe.mockReset()
|
||||
prismaMock.$queryRawUnsafe.mockResolvedValue([])
|
||||
})
|
||||
|
||||
describe('normalizarReglas (JSON de la BD)', () => {
|
||||
it('acepta el JSON con la forma de las constantes y conserva los valores', () => {
|
||||
const reglas = normalizarReglas('DIAGONAL', JSON.parse(reglasJson(DESARROLLOS['DIAGONAL']!)))
|
||||
expect(reglas).not.toBeNull()
|
||||
expect(reglas?.id).toBe('DIAGONAL')
|
||||
expect(reglas?.propuestas).toHaveLength(4)
|
||||
expect(reglas?.propuestas[2]?.descuentoDefault).toBe(0.02)
|
||||
})
|
||||
|
||||
it('marca la propuesta desactivada por el admin (activa/activo)', () => {
|
||||
const bruto = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as {
|
||||
propuestas: Record<string, unknown>[]
|
||||
}
|
||||
brinda(bruto.propuestas, 'CONTADO').activa = false
|
||||
const reglas = normalizarReglas('SENDA', bruto)
|
||||
expect(reglas?.propuestas.find((p) => p.tipo === 'CONTADO')?.activa).toBe(false)
|
||||
})
|
||||
|
||||
it('rechaza JSON sin la forma esperada (precioUnico, propuestas, claves)', () => {
|
||||
expect(normalizarReglas('DIAGONAL', null)).toBeNull()
|
||||
expect(normalizarReglas('DIAGONAL', 'DIAGONAL')).toBeNull()
|
||||
expect(normalizarReglas('DIAGONAL', { precioUnico: true })).toBeNull()
|
||||
expect(
|
||||
normalizarReglas('DIAGONAL', {
|
||||
precioUnico: true,
|
||||
fuenteDeN: 'fijo',
|
||||
propuestas: [{ tipo: 'CONTADO' }],
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getReglasGuardadas / getReglasEfectivas', () => {
|
||||
it('sin filas guardadas devuelve las constantes actuales (compatibilidad)', async () => {
|
||||
usarModeloPrisma().mockResolvedValue([])
|
||||
|
||||
const efectivas = await getReglasEfectivas()
|
||||
|
||||
expect(Object.keys(efectivas).sort()).toEqual(Object.keys(DESARROLLOS).sort())
|
||||
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
|
||||
expect(efectivas[id]).toEqual(constantes)
|
||||
}
|
||||
expect(await getReglasDesarrollo('SENDA')).toEqual(DESARROLLOS['SENDA'])
|
||||
})
|
||||
|
||||
it('las reglas de la BD ganan a las constantes del desarrollo', async () => {
|
||||
const conDescuento = {
|
||||
...DESARROLLOS['DIAGONAL']!,
|
||||
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
|
||||
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.07 } : p,
|
||||
),
|
||||
}
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: reglasJson(conDescuento) },
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
const contado = reglas.propuestas.find((p) => p.tipo === 'CONTADO')
|
||||
expect(contado?.descuentoDefault).toBe(0.07)
|
||||
expect(DESARROLLOS['DIAGONAL']!.propuestas[2]?.descuentoDefault).toBe(0.02)
|
||||
|
||||
// Los demás desarrollos siguen en constantes
|
||||
expect((await getReglasDesarrollo('SOLANA')).propuestas[1]?.pctEnganche).toBe(0)
|
||||
})
|
||||
|
||||
it('usa la columna activoEnCotizador y también el campo dentro del JSON', async () => {
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{
|
||||
desarrolloId: 'SOLANA',
|
||||
parametrosJson: reglasJson(DESARROLLOS['SOLANA']!),
|
||||
activoEnCotizador: false,
|
||||
},
|
||||
{
|
||||
desarrolloId: 'ESTANZA',
|
||||
parametrosJson: reglasJson(DESARROLLOS['ESTANZA']!, { activoEnCotizador: false }),
|
||||
},
|
||||
])
|
||||
|
||||
expect((await getReglasDesarrollo('SOLANA')).activoEnCotizador).toBe(false)
|
||||
expect((await getReglasDesarrollo('ESTANZA')).activoEnCotizador).toBe(false)
|
||||
expect((await getReglasDesarrollo('DIAGONAL')).activoEnCotizador).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resuelve la clave del desarrollo por su id (las reglas apuntan al uuid) y lee su ficha', async () => {
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{
|
||||
desarrolloId: 'uuid-diagonal',
|
||||
parametrosJson: reglasJson({ ...DESARROLLOS['DIAGONAL']!, nombre: undefined as never }),
|
||||
},
|
||||
])
|
||||
usarCatalogo([
|
||||
ficha('uuid-diagonal', 'DIAGONAL', { activoEnCotizador: false, nombre: 'Diagonal Residencial' }),
|
||||
ficha('uuid-senda', 'SENDA'),
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
|
||||
// La clave sale del catálogo (id → clave), no del uuid
|
||||
expect(reglas.propuestas).toHaveLength(4)
|
||||
// El nombre y la bandera salen de la ficha del desarrollo
|
||||
expect(reglas.nombre).toBe('Diagonal Residencial')
|
||||
expect(reglas.activoEnCotizador).toBe(false)
|
||||
|
||||
// Las reglas efectivas y el desarrollo activo siguen saliendo de constantes
|
||||
const efectivas = await getReglasEfectivas()
|
||||
expect(efectivas['SENDA']).toEqual(DESARROLLOS['SENDA'])
|
||||
expect(efectivas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
|
||||
})
|
||||
|
||||
it('ignora filas sin desarrollo identificable o sin parámetros', async () => {
|
||||
const sinClave = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as Record<string, unknown>
|
||||
delete sinClave['id']
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: '', parametrosJson: JSON.stringify(sinClave) },
|
||||
{ desarrolloId: 'SENDA', parametrosJson: null },
|
||||
null,
|
||||
])
|
||||
|
||||
expect((await getReglasGuardadas()).size).toBe(0)
|
||||
})
|
||||
|
||||
it('lee la tabla por SQL crudo cuando el cliente Prisma no tiene el modelo', async () => {
|
||||
prismaMock.$queryRawUnsafe.mockImplementation(async (sql: string) => {
|
||||
if (sql.includes('"ReglaCotizadorDesarrollo"')) {
|
||||
return [
|
||||
{ desarrolloId: 'uuid-senda', parametrosJson: reglasJson(DESARROLLOS['SENDA']!) },
|
||||
{ desarrollo: 'SOLANA', parametros: reglasJson(DESARROLLOS['SOLANA']!) },
|
||||
]
|
||||
}
|
||||
return [ficha('uuid-senda', 'SENDA'), ficha('uuid-solana', 'SOLANA')]
|
||||
})
|
||||
|
||||
const reglas = await getReglasEfectivas()
|
||||
|
||||
expect(prismaMock.$queryRawUnsafe).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"ReglaCotizadorDesarrollo"'),
|
||||
)
|
||||
expect(reglas['SENDA']?.propuestas).toHaveLength(4)
|
||||
expect(reglas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('respaldo en constantes (nunca falla la cotización)', () => {
|
||||
it('JSON corrupto: respaldo en las constantes con warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: '{"propuestas": [' },
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
|
||||
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('JSON válido pero con forma inesperada: respaldo en las constantes con warning', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
usarModeloPrisma().mockResolvedValue([
|
||||
{ desarrolloId: 'DIAGONAL', parametrosJson: JSON.stringify({ precioUnico: true }) },
|
||||
])
|
||||
|
||||
const reglas = await getReglasDesarrollo('DIAGONAL')
|
||||
|
||||
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('forma inesperada'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('tabla inexistente: respaldo en las constantes, sin lanzar y con un solo warning', async () => {
|
||||
vi.resetModules()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
prismaMock.$queryRawUnsafe.mockRejectedValue(new Error('no such table: ReglaCotizadorDesarrollo'))
|
||||
const servicio = await import('./reglas-desarrollo.service.js')
|
||||
|
||||
const reglas = await servicio.getReglasDesarrollo('SENDA')
|
||||
const efectivas = await servicio.getReglasEfectivas()
|
||||
|
||||
expect(reglas).toEqual(DESARROLLOS['SENDA'])
|
||||
expect(Object.keys(efectivas)).toHaveLength(Object.keys(DESARROLLOS).length)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('constantes'), expect.any(Error))
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
417
api-gateway/src/services/reglas-desarrollo.service.ts
Normal file
417
api-gateway/src/services/reglas-desarrollo.service.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* 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]!
|
||||
}
|
||||
Reference in New Issue
Block a user