feat(desarrollos): modulo de alta de desarrollos con inventario, reglas del cotizador por desarrollo y micrositio publico

Modulo nuevo (solo admin) para dar de alta desarrollos sin tocar codigo:
- CRUD de la ficha, modelos e inventario (alta manual y generador por rango)
- Reglas del cotizador por desarrollo, editables y persistidas en la BD
- El cotizador consume las reglas desde /desarrollos/reglas-cotizador y cae a
  las constantes con aviso cuando un desarrollo aun no tiene reglas guardadas
- Micrositio publico en /d/:clave (sin sesion) con ficha, modelos y
  disponibilidad; jamas expone datos de clientes ni cotizaciones

Integracion:
- La landing monta el modulo en el grid (clave 'desarrollos', solo admin) y el
  micrositio fuera del login; el rol manager NO accede (matriz y API con 403)
- La tabla de roles de Configuracion ahora lista todos los modulos
- Se alinea la FK de ReglaCotizadorDesarrollo con la BD viva (Desarrollo.id)

Verificado: 708 pruebas del API, 115 del cotizador y 89 de la landing en verde;
12/12 cotizaciones identicas antes y despues del cambio de reglas; prueba en
vivo del API con alta, modelos, inventario, reglas, micrositio y baja (30/30).
This commit is contained in:
2026-09-21 09:52:47 +00:00
parent 2fc80e7f9e
commit d6548999bf
68 changed files with 10016 additions and 66 deletions

View File

@@ -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"

View File

@@ -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 '';

View File

@@ -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?
}

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

View File

@@ -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,
}

View File

@@ -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' }])

View File

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

View 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' }))
})
})

View 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'])))
}

View File

@@ -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'])
})
})

View 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() })
}

View File

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

View File

@@ -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' {

View File

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

View 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)
})
})

View File

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

View 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 })
})
})

View 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)
}

View 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()
})
})

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