From d6548999bf0573ac577ac82f8608a286bc220904 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Mon, 21 Sep 2026 09:52:47 +0000 Subject: [PATCH] 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). --- api-gateway/package.json | 1 + .../20260921120000_desarrollos/migration.sql | 58 ++ api-gateway/prisma/schema.prisma | 59 ++ api-gateway/scripts/seed-desarrollos.js | 157 ++++ api-gateway/src/config/roles.ts | 4 +- .../cotizaciones.controller.test.ts | 107 +++ .../controllers/cotizaciones.controller.ts | 27 +- .../desarrollos.controller.test.ts | 524 ++++++++++++ .../src/controllers/desarrollos.controller.ts | 141 ++++ .../reglas-cotizador.controller.test.ts | 45 ++ .../reglas-cotizador.controller.ts | 14 + .../src/middleware/auth.middleware.test.ts | 28 + api-gateway/src/middleware/auth.middleware.ts | 4 + api-gateway/src/routes/index.ts | 40 + .../src/services/cotizacion-reglas.test.ts | 201 +++++ .../src/services/cotizacion.service.ts | 67 +- .../src/services/desarrollos.service.test.ts | 496 ++++++++++++ .../src/services/desarrollos.service.ts | 754 +++++++++++++++++ .../reglas-desarrollo.service.test.ts | 253 ++++++ .../src/services/reglas-desarrollo.service.ts | 417 ++++++++++ .../src/components/ComparadorUnidades.tsx | 33 +- apps/cotizador/src/data/desarrollos.ts | 22 +- .../src/hooks/useCotizacion.test.tsx | 108 +++ apps/cotizador/src/hooks/useCotizacion.ts | 92 ++- apps/cotizador/src/hooks/useUnidades.ts | 19 +- apps/cotizador/src/lib/calculations.ts | 12 +- apps/cotizador/src/lib/comparadorUnidades.ts | 13 +- apps/cotizador/src/lib/reglas.test.ts | 164 ++++ apps/cotizador/src/lib/reglas.ts | 227 ++++++ .../src/pages/CotizadorPage.reglas.test.tsx | 89 ++ apps/cotizador/src/pages/CotizadorPage.tsx | 56 +- apps/cotizador/src/services/api.test.ts | 42 +- apps/cotizador/src/services/api.ts | 34 +- apps/cotizador/src/services/offline-db.ts | 20 + apps/cotizador/src/types/index.ts | 10 + apps/landing/src/App.tsx | 34 +- .../src/components/config/RolesPermisos.tsx | 8 + .../desarrollos/FichaDesarrollo.tsx | 345 ++++++++ .../desarrollos/FormularioDesarrollo.tsx | 458 +++++++++++ .../desarrollos/ListaDesarrollos.tsx | 137 ++++ .../desarrollos/ModalDesarrollo.tsx | 64 ++ .../components/desarrollos/TabInventario.tsx | 761 ++++++++++++++++++ .../src/components/desarrollos/TabModelos.tsx | 360 +++++++++ .../src/components/desarrollos/TabReglas.tsx | 558 +++++++++++++ .../desarrollos/TarjetaDesarrollo.tsx | 86 ++ .../src/components/desarrollos/comun.tsx | 189 +++++ .../src/components/desarrollos/generador.ts | 46 ++ .../src/components/desarrollos/reglas.ts | 164 ++++ .../publico/EncabezadoDesarrollo.tsx | 140 ++++ .../publico/EsqueletoDesarrollo.tsx | 45 ++ .../components/publico/EstadosDesarrollo.tsx | 70 ++ .../src/components/publico/FranjaDatos.tsx | 36 + .../src/components/publico/PieContacto.tsx | 81 ++ .../components/publico/SeccionDescripcion.tsx | 69 ++ .../src/components/publico/SeccionModelos.tsx | 39 + .../publico/TablaDisponibilidad.tsx | 318 ++++++++ .../src/components/publico/TarjetaModelo.tsx | 83 ++ .../src/components/publico/contacto.ts | 75 ++ .../landing/src/components/publico/formato.ts | 91 +++ apps/landing/src/data/modulos.test.ts | 5 +- apps/landing/src/data/modulos.ts | 7 + .../src/pages/DesarrolloPublicoPage.test.tsx | 225 ++++++ .../src/pages/DesarrolloPublicoPage.tsx | 166 ++++ .../src/pages/DesarrollosPage.test.tsx | 357 ++++++++ apps/landing/src/pages/DesarrollosPage.tsx | 135 ++++ apps/landing/src/services/desarrollos.ts | 384 +++++++++ apps/landing/src/services/publico.test.ts | 64 ++ apps/landing/src/services/publico.ts | 144 ++++ 68 files changed, 10016 insertions(+), 66 deletions(-) create mode 100644 api-gateway/prisma/migrations/20260921120000_desarrollos/migration.sql create mode 100644 api-gateway/scripts/seed-desarrollos.js create mode 100644 api-gateway/src/controllers/desarrollos.controller.test.ts create mode 100644 api-gateway/src/controllers/desarrollos.controller.ts create mode 100644 api-gateway/src/controllers/reglas-cotizador.controller.test.ts create mode 100644 api-gateway/src/controllers/reglas-cotizador.controller.ts create mode 100644 api-gateway/src/services/cotizacion-reglas.test.ts create mode 100644 api-gateway/src/services/desarrollos.service.test.ts create mode 100644 api-gateway/src/services/desarrollos.service.ts create mode 100644 api-gateway/src/services/reglas-desarrollo.service.test.ts create mode 100644 api-gateway/src/services/reglas-desarrollo.service.ts create mode 100644 apps/cotizador/src/hooks/useCotizacion.test.tsx create mode 100644 apps/cotizador/src/lib/reglas.test.ts create mode 100644 apps/cotizador/src/lib/reglas.ts create mode 100644 apps/cotizador/src/pages/CotizadorPage.reglas.test.tsx create mode 100644 apps/landing/src/components/desarrollos/FichaDesarrollo.tsx create mode 100644 apps/landing/src/components/desarrollos/FormularioDesarrollo.tsx create mode 100644 apps/landing/src/components/desarrollos/ListaDesarrollos.tsx create mode 100644 apps/landing/src/components/desarrollos/ModalDesarrollo.tsx create mode 100644 apps/landing/src/components/desarrollos/TabInventario.tsx create mode 100644 apps/landing/src/components/desarrollos/TabModelos.tsx create mode 100644 apps/landing/src/components/desarrollos/TabReglas.tsx create mode 100644 apps/landing/src/components/desarrollos/TarjetaDesarrollo.tsx create mode 100644 apps/landing/src/components/desarrollos/comun.tsx create mode 100644 apps/landing/src/components/desarrollos/generador.ts create mode 100644 apps/landing/src/components/desarrollos/reglas.ts create mode 100644 apps/landing/src/components/publico/EncabezadoDesarrollo.tsx create mode 100644 apps/landing/src/components/publico/EsqueletoDesarrollo.tsx create mode 100644 apps/landing/src/components/publico/EstadosDesarrollo.tsx create mode 100644 apps/landing/src/components/publico/FranjaDatos.tsx create mode 100644 apps/landing/src/components/publico/PieContacto.tsx create mode 100644 apps/landing/src/components/publico/SeccionDescripcion.tsx create mode 100644 apps/landing/src/components/publico/SeccionModelos.tsx create mode 100644 apps/landing/src/components/publico/TablaDisponibilidad.tsx create mode 100644 apps/landing/src/components/publico/TarjetaModelo.tsx create mode 100644 apps/landing/src/components/publico/contacto.ts create mode 100644 apps/landing/src/components/publico/formato.ts create mode 100644 apps/landing/src/pages/DesarrolloPublicoPage.test.tsx create mode 100644 apps/landing/src/pages/DesarrolloPublicoPage.tsx create mode 100644 apps/landing/src/pages/DesarrollosPage.test.tsx create mode 100644 apps/landing/src/pages/DesarrollosPage.tsx create mode 100644 apps/landing/src/services/desarrollos.ts create mode 100644 apps/landing/src/services/publico.test.ts create mode 100644 apps/landing/src/services/publico.ts diff --git a/api-gateway/package.json b/api-gateway/package.json index 7fcddee..e7fd6f0 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -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" diff --git a/api-gateway/prisma/migrations/20260921120000_desarrollos/migration.sql b/api-gateway/prisma/migrations/20260921120000_desarrollos/migration.sql new file mode 100644 index 0000000..a6c8820 --- /dev/null +++ b/api-gateway/prisma/migrations/20260921120000_desarrollos/migration.sql @@ -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 ''; diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index 52e1ea7..06f7d0e 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -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? +} diff --git a/api-gateway/scripts/seed-desarrollos.js b/api-gateway/scripts/seed-desarrollos.js new file mode 100644 index 0000000..277aa33 --- /dev/null +++ b/api-gateway/scripts/seed-desarrollos.js @@ -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()) diff --git a/api-gateway/src/config/roles.ts b/api-gateway/src/config/roles.ts index 5bf078e..b2617ce 100644 --- a/api-gateway/src/config/roles.ts +++ b/api-gateway/src/config/roles.ts @@ -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 = { 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, } diff --git a/api-gateway/src/controllers/cotizaciones.controller.test.ts b/api-gateway/src/controllers/cotizaciones.controller.test.ts index 8a5a78f..e6f4277 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.test.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.test.ts @@ -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[]) => 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' }]) diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts index 9508c38..f980511 100644 --- a/api-gateway/src/controllers/cotizaciones.controller.ts +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -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 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 ({ + 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[]) => 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; query?: Record; 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 = {}) { + 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 = {}) { + 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 }) => ({ + 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[] } ])[0] + const desarrollo = payload.desarrollos[0] as Record + + 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' })) + }) +}) diff --git a/api-gateway/src/controllers/desarrollos.controller.ts b/api-gateway/src/controllers/desarrollos.controller.ts new file mode 100644 index 0000000..66f25ca --- /dev/null +++ b/api-gateway/src/controllers/desarrollos.controller.ts @@ -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 { + res.json(await service.listarDesarrollos()) +} + +/** GET /api/desarrollos/:id */ +export async function obtenerDesarrolloEndpoint(req: Request, res: Response): Promise { + res.json(await service.obtenerDesarrollo(idDe(req))) +} + +/** POST /api/desarrollos */ +export async function crearDesarrolloEndpoint(req: Request, res: Response): Promise { + 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 { + 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).join(', '), + ) +} + +/** DELETE /api/desarrollos/:id — 409 si tiene inventario u operación */ +export async function eliminarDesarrolloEndpoint(req: Request, res: Response): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + res.json(await service.listarDesarrollosPublicos()) +} + +/** GET /api/publico/desarrollos/:clave */ +export async function obtenerDesarrolloPublicoEndpoint(req: Request, res: Response): Promise { + res.json(await service.obtenerDesarrolloPublico(String(req.params['clave']))) +} diff --git a/api-gateway/src/controllers/reglas-cotizador.controller.test.ts b/api-gateway/src/controllers/reglas-cotizador.controller.test.ts new file mode 100644 index 0000000..ee99263 --- /dev/null +++ b/api-gateway/src/controllers/reglas-cotizador.controller.test.ts @@ -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 + } + 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']) + }) +}) diff --git a/api-gateway/src/controllers/reglas-cotizador.controller.ts b/api-gateway/src/controllers/reglas-cotizador.controller.ts new file mode 100644 index 0000000..f63528e --- /dev/null +++ b/api-gateway/src/controllers/reglas-cotizador.controller.ts @@ -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 { + res.json({ reglasPorDesarrollo: await getReglasEfectivas() }) +} diff --git a/api-gateway/src/middleware/auth.middleware.test.ts b/api-gateway/src/middleware/auth.middleware.test.ts index b38c017..4358d57 100644 --- a/api-gateway/src/middleware/auth.middleware.test.ts +++ b/api-gateway/src/middleware/auth.middleware.test.ts @@ -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[] = [ + { 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[] = [ + { 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({ diff --git a/api-gateway/src/middleware/auth.middleware.ts b/api-gateway/src/middleware/auth.middleware.ts index 2cbff2c..e5b823a 100644 --- a/api-gateway/src/middleware/auth.middleware.ts +++ b/api-gateway/src/middleware/auth.middleware.ts @@ -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' { diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 62b9ed6..8c37d74 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -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) diff --git a/api-gateway/src/services/cotizacion-reglas.test.ts b/api-gateway/src/services/cotizacion-reglas.test.ts new file mode 100644 index 0000000..fd5fc2d --- /dev/null +++ b/api-gateway/src/services/cotizacion-reglas.test.ts @@ -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 & Pick): 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) + }) +}) diff --git a/api-gateway/src/services/cotizacion.service.ts b/api-gateway/src/services/cotizacion.service.ts index c422acc..50dd98c 100644 --- a/api-gateway/src/services/cotizacion.service.ts +++ b/api-gateway/src/services/cotizacion.service.ts @@ -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 = { 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) diff --git a/api-gateway/src/services/desarrollos.service.test.ts b/api-gateway/src/services/desarrollos.service.test.ts new file mode 100644 index 0000000..c408790 --- /dev/null +++ b/api-gateway/src/services/desarrollos.service.test.ts @@ -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[]) => 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 = {}) { + 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 = {}) { + 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 }) => ({ + 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 }) + }) +}) diff --git a/api-gateway/src/services/desarrollos.service.ts b/api-gateway/src/services/desarrollos.service.ts new file mode 100644 index 0000000..719e1dd --- /dev/null +++ b/api-gateway/src/services/desarrollos.service.ts @@ -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 + +// ── 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 + +// ── 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 { + const mapa = new Map() + 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 = {} + 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>> + +/** La ficha completa o 404 */ +export async function obtenerDesarrollo(id: string): Promise { + 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) { + 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) { + 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 = {} + 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 { + 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 { + 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) { + 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) { + 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 = {} + 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 { + 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 { + 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 { + 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() + 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) +} diff --git a/api-gateway/src/services/reglas-desarrollo.service.test.ts b/api-gateway/src/services/reglas-desarrollo.service.test.ts new file mode 100644 index 0000000..09c4e5c --- /dev/null +++ b/api-gateway/src/services/reglas-desarrollo.service.test.ts @@ -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 }, + desarrollo: undefined as undefined | { findMany: ReturnType }, +})) + +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 { + return JSON.stringify({ + ...params, + ...extras, + propuestas: params.propuestas.map((p) => ({ ...p, activa: true })), + }) +} + +function usarModeloPrisma(): ReturnType { + 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 = {}) { + 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[], tipo: string): Record { + 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[] + } + 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 + 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() + }) +}) diff --git a/api-gateway/src/services/reglas-desarrollo.service.ts b/api-gateway/src/services/reglas-desarrollo.service.ts new file mode 100644 index 0000000..6464f5c --- /dev/null +++ b/api-gateway/src/services/reglas-desarrollo.service.ts @@ -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 + +const DESARROLLO_DEFAULT = 'DIAGONAL' + +const TIPOS: ReadonlyArray = [ + 'CREDITO_BANCARIO', + 'CREDITO_INFONAVIT_COFINAVIT', + 'CONTADO', + 'SEMI_CONTADO', +] + +const CLAVES_DESCUENTO: ReadonlyArray = [ + 'creditoBancario', + 'creditoInfonavit', + 'contado', + 'semiContado', +] + +const BASES: ReadonlyArray = ['lista', 'final'] + +const MODOS: ReadonlyArray = [ + 'ninguna', + 'remanente', + 'remanenteLista', + 'porcentaje', +] + +function esTexto(valor: unknown): valor is string { + return typeof valor === 'string' && valor.trim() !== '' +} + +function esNumero(valor: unknown): valor is number { + return typeof valor === 'number' && Number.isFinite(valor) +} + +function esBase(valor: unknown): valor is BasePrecio { + return typeof valor === 'string' && (BASES as ReadonlyArray).includes(valor) +} + +function esModo(valor: unknown): valor is ModoMensualidades { + return typeof valor === 'string' && (MODOS as ReadonlyArray).includes(valor) +} + +/** Normaliza una propuesta del JSON de la BD; null si está corrupta */ +function normalizarPropuesta(bruto: unknown): PropuestaConfig | null { + if (typeof bruto !== 'object' || bruto === null) return null + const p = bruto as Record + const tipo = p['tipo'] + const clave = p['claveDescuento'] + if (typeof tipo !== 'string' || !(TIPOS as ReadonlyArray).includes(tipo)) return null + if (typeof clave !== 'string' || !(CLAVES_DESCUENTO as ReadonlyArray).includes(clave)) + return null + if (!esTexto(p['titulo'])) return null + if (!esNumero(p['descuentoDefault']) || !esNumero(p['pctEnganche']) || !esNumero(p['pctPagoFinal'])) + return null + if (!esBase(p['engancheBase']) || !esBase(p['pagoFinalBase'])) return null + if (!esModo(p['modoMensualidades'])) return null + + const propuesta: PropuestaConfig = { + tipo: tipo as TipoPropuesta, + claveDescuento: clave as keyof DescuentosPropuestas, + titulo: p['titulo'].trim(), + descuentoDefault: p['descuentoDefault'], + pctEnganche: p['pctEnganche'], + engancheBase: p['engancheBase'], + pctPagoFinal: p['pctPagoFinal'], + pagoFinalBase: p['pagoFinalBase'], + modoMensualidades: p['modoMensualidades'], + } + if (p['engancheCeroSinApartado'] === true) propuesta.engancheCeroSinApartado = true + const activa = p['activa'] ?? p['activo'] + if (typeof activa === 'boolean') propuesta.activa = activa + return propuesta +} + +/** + * Valida y normaliza el JSON de reglas de un desarrollo. Devuelve null (para + * que el llamador caiga a las constantes) si el JSON no tiene la forma esperada. + */ +export function normalizarReglas( + desarrolloId: string, + bruto: unknown, +): ParametrosDesarrollo | null { + if (typeof bruto !== 'object' || bruto === null || Array.isArray(bruto)) return null + const r = bruto as Record + + if (typeof r['precioUnico'] !== 'boolean') return null + const fuente = r['fuenteDeN'] + if (fuente !== 'fechaEntrega' && fuente !== 'fijo' && fuente !== 'porUnidad') return null + + const propuestasBrutas = r['propuestas'] + if (!Array.isArray(propuestasBrutas) || propuestasBrutas.length === 0) return null + const propuestas: PropuestaConfig[] = [] + for (const propuestaBruta of propuestasBrutas) { + const propuesta = normalizarPropuesta(propuestaBruta) + if (!propuesta) return null // una propuesta corrupta invalida el JSON completo + propuestas.push(propuesta) + } + + const id = desarrolloId.trim().toUpperCase() + const params: ParametrosDesarrollo = { + id, + precioUnico: r['precioUnico'], + fuenteDeN: fuente, + propuestas, + } + if (esTexto(r['nombre'])) params.nombre = r['nombre'].trim() + if (esNumero(r['nFijo'])) params.nFijo = r['nFijo'] + if (esNumero(r['nDefault'])) params.nDefault = r['nDefault'] + if (r['cuotaMantenimiento'] === null) params.cuotaMantenimiento = null + else if (esNumero(r['cuotaMantenimiento'])) params.cuotaMantenimiento = r['cuotaMantenimiento'] + if (esTexto(r['prefijoUnidadPdf'])) params.prefijoUnidadPdf = r['prefijoUnidadPdf'].trim() + const activo = r['activoEnCotizador'] ?? r['activo'] + if (typeof activo === 'boolean') params.activoEnCotizador = activo + return params +} + +/** Fila de reglas ya mapeada a nombres de campo conocidos */ +interface FilaReglas { + desarrolloId: string + parametros: unknown + activoEnCotizador: boolean | undefined +} + +/** Ficha mínima del desarrollo (tabla `Desarrollo`): la clave y sus banderas */ +interface FichaDesarrollo { + clave: string + activoEnCotizador: boolean | undefined + nombre: string | undefined + prefijoUnidadPdf: string | undefined +} + +interface CatalogoDesarrollos { + porClave: Map + /** Desarrollo.id → clave ('DIAGONAL', 'SENDA', …) */ + clavePorId: Map +} + +/** Primer valor presente de `claves` en la fila que cumpla el filtro */ +function primerValor( + fila: Record, + claves: ReadonlyArray, + acepta: (valor: unknown) => boolean, +): unknown { + for (const clave of claves) { + const valor = fila[clave] + if (valor !== undefined && valor !== null && acepta(valor)) return valor + } + return undefined +} + +/** Booleanos de SQLite crudo: 1/0 además de true/false */ +function esBandera(valor: unknown): valor is boolean | 0 | 1 { + return typeof valor === 'boolean' || valor === 0 || valor === 1 +} + +function aBandera(valor: unknown): boolean | undefined { + if (valor === true || valor === 1) return true + if (valor === false || valor === 0) return false + return undefined +} + +/** + * Mapea una fila cruda (Prisma o SQL) a `FilaReglas`. Los nombres de columna se + * resuelven por heurística para tolerar cambios del modelo mientras otro agente + * construye el módulo de administración: `desarrolloId`/`desarrollo` y + * `parametrosJson`/`parametros`/`reglas`. + */ +function mapearFila(fila: unknown): FilaReglas | null { + if (typeof fila !== 'object' || fila === null) return null + const f = fila as Record + const desarrollo = primerValor( + f, + ['desarrolloId', 'desarrollo', 'desarrollo_id', 'proyecto', 'id'], + esTexto, + ) + const parametros = primerValor( + f, + ['parametrosJson', 'parametros_json', 'parametros', 'reglas'], + (v) => typeof v === 'string' || typeof v === 'object', + ) + if (typeof desarrollo !== 'string') return null + const activo = primerValor(f, ['activoEnCotizador', 'activo'], esBandera) + return { + desarrolloId: desarrollo, + parametros, + activoEnCotizador: aBandera(activo), + } +} + +/** Cliente Prisma con el modelo de reglas, si ya está generado */ +function modeloReglas(): { findMany: (args?: unknown) => Promise } | null { + const cliente = prisma as unknown as Record + for (const nombre of ['reglaCotizadorDesarrollo', 'reglaCotizador', 'reglasCotizador']) { + const modelo = cliente[nombre] as { findMany?: unknown } | undefined + if (modelo && typeof modelo.findMany === 'function') { + return modelo as { findMany: (args?: unknown) => Promise } + } + } + return null +} + +/** + * Filas de reglas guardadas. Primero intenta el modelo de Prisma; si el cliente + * todavía no lo tiene generado, lee la tabla por SQL crudo. Cualquier fallo se + * traduce en "no hay reglas guardadas" (nunca en un error de cotización). + */ +async function leerFilasReglas(): Promise { + const modelo = modeloReglas() + if (modelo) { + const filas = await modelo.findMany() + return filas.map(mapearFila).filter((f): f is FilaReglas => f !== null) + } + const crudo = (prisma as unknown as { $queryRawUnsafe?: unknown }).$queryRawUnsafe + if (typeof crudo !== 'function') return [] + const filas = await prisma.$queryRawUnsafe( + 'SELECT * FROM "ReglaCotizadorDesarrollo"', + ) + return (Array.isArray(filas) ? filas : []).map(mapearFila).filter((f): f is FilaReglas => f !== null) +} + +let avisoCatalogoEmitido = false +let avisoLecturaEmitido = false + +/** + * Catálogo de desarrollos (tabla `Desarrollo`): de ahí sale la clave real de + * cada fila de reglas (las reglas apuntan al id del desarrollo) y la bandera + * `activoEnCotizador`. Tolerante: si la tabla no existe todavía, el catálogo + * queda vacío y las reglas se resuelven por la clave que traiga el JSON. + */ +async function leerCatalogo(): Promise { + const porClave = new Map() + const clavePorId = new Map() + let leidas: unknown[] + try { + const modelo = (prisma as unknown as Record)['desarrollo'] as + | { findMany?: unknown } + | undefined + if (modelo && typeof modelo.findMany === 'function') { + leidas = await (modelo.findMany as (args?: unknown) => Promise)({ + select: { id: true, clave: true, nombre: true, activoEnCotizador: true, prefijoUnidadPdf: true }, + }) + } else if (typeof prisma.$queryRawUnsafe === 'function') { + leidas = await prisma.$queryRawUnsafe('SELECT * FROM "Desarrollo"') + } else { + return { porClave, clavePorId } + } + } catch (err) { + if (!avisoCatalogoEmitido) { + avisoCatalogoEmitido = true + // eslint-disable-next-line no-console + console.warn( + '[reglas-cotizador] No se pudo leer el catálogo de desarrollos; se usan las constantes.', + err, + ) + } + return { porClave, clavePorId } + } + + for (const fila of Array.isArray(leidas) ? leidas : []) { + if (typeof fila !== 'object' || fila === null) continue + const f = fila as Record + const clave = primerValor(f, ['clave', 'desarrolloId', 'desarrollo'], esTexto) + if (typeof clave !== 'string') continue + const nombre = primerValor(f, ['nombre'], esTexto) + const prefijo = primerValor(f, ['prefijoUnidadPdf'], esTexto) + const ficha: FichaDesarrollo = { + clave: clave.trim().toUpperCase(), + activoEnCotizador: aBandera(primerValor(f, ['activoEnCotizador', 'activo'], esBandera)), + nombre: typeof nombre === 'string' ? nombre : undefined, + prefijoUnidadPdf: typeof prefijo === 'string' ? prefijo : undefined, + } + porClave.set(ficha.clave, ficha) + const id = primerValor(f, ['id'], esTexto) + if (typeof id === 'string') clavePorId.set(id, ficha.clave) + } + return { porClave, clavePorId } +} + +/** + * Clave del desarrollo de una fila de reglas: primero el id guardado (uuid) que + * se resuelve con el catálogo, luego el `id` del propio JSON y, si no, la clave + * que venga en la columna. + */ +function claveDeFila( + fila: FilaReglas, + bruto: unknown, + catalogo: CatalogoDesarrollos, +): string | null { + const crudo = fila.desarrolloId.trim() + const delCatalogo = catalogo.clavePorId.get(crudo) + if (delCatalogo !== undefined) return delCatalogo + if (typeof bruto === 'object' && bruto !== null) { + const idJson = (bruto as Record)['id'] + if (esTexto(idJson)) return idJson.trim().toUpperCase() + } + return crudo === '' ? null : crudo.toUpperCase() +} + +/** + * Reglas configuradas por el admin, indexadas por clave de desarrollo. Se + * descartan (con warning) las filas con JSON corrupto: esos desarrollos usan sus + * constantes. + */ +export async function getReglasGuardadas(): Promise> { + const guardadas = new Map() + let filas: FilaReglas[] + try { + filas = await leerFilasReglas() + } catch (err) { + if (!avisoLecturaEmitido) { + avisoLecturaEmitido = true + // eslint-disable-next-line no-console + console.warn( + '[reglas-cotizador] No se pudieron leer las reglas guardadas (¿tabla ReglaCotizadorDesarrollo sin crear?); se usan las constantes.', + err, + ) + } + return guardadas + } + if (filas.length === 0) return guardadas + + const catalogo = await leerCatalogo() + + for (const fila of filas) { + if (fila.parametros === undefined) continue + let bruto = fila.parametros + if (typeof bruto === 'string') { + try { + bruto = JSON.parse(bruto) + } catch { + // eslint-disable-next-line no-console + console.warn( + `[reglas-cotizador] JSON inválido para el desarrollo ${fila.desarrolloId}; se usan las constantes.`, + ) + continue + } + } + const clave = claveDeFila(fila, bruto, catalogo) + if (clave === null) { + // eslint-disable-next-line no-console + console.warn('[reglas-cotizador] Fila de reglas sin desarrollo; se ignora.') + continue + } + const reglas = normalizarReglas(clave, bruto) + if (!reglas) { + // eslint-disable-next-line no-console + console.warn( + `[reglas-cotizador] Reglas con forma inesperada para el desarrollo ${clave}; se usan las constantes.`, + ) + continue + } + + // La ficha del desarrollo completa lo que el JSON no traiga + const ficha = catalogo.porClave.get(clave) + if (reglas.nombre === undefined && ficha?.nombre !== undefined) reglas.nombre = ficha.nombre + if (reglas.prefijoUnidadPdf === undefined && ficha?.prefijoUnidadPdf !== undefined) { + reglas.prefijoUnidadPdf = ficha.prefijoUnidadPdf + } + // El desarrollo está activo solo si TODAS las fuentes que hablan de él lo + // dejan activo (la bandera vive en la ficha del desarrollo) + const banderas = [fila.activoEnCotizador, reglas.activoEnCotizador, ficha?.activoEnCotizador].filter( + (valor): valor is boolean => typeof valor === 'boolean', + ) + if (banderas.length > 0) reglas.activoEnCotizador = banderas.every((valor) => valor) + + guardadas.set(reglas.id, reglas) + } + return guardadas +} + +/** + * Reglas efectivas de TODOS los desarrollos: lo guardado por el admin gana; los + * desarrollos sin reglas (o con JSON corrupto) caen a las constantes actuales. + */ +export async function getReglasEfectivas(): Promise { + const guardadas = await getReglasGuardadas() + const efectivas: ReglasDesarrollo = {} + for (const [id, constantes] of Object.entries(DESARROLLOS)) { + efectivas[id] = guardadas.get(id) ?? constantes + } + for (const [id, reglas] of guardadas) { + if (efectivas[id] === undefined) efectivas[id] = reglas + } + return efectivas +} + +/** Reglas efectivas de un desarrollo; cae a las constantes (DIAGONAL si es desconocido) */ +export async function getReglasDesarrollo( + desarrolloId: string | null | undefined, +): Promise { + const id = (desarrolloId ?? DESARROLLO_DEFAULT).trim().toUpperCase() || DESARROLLO_DEFAULT + const guardadas = await getReglasGuardadas() + return guardadas.get(id) ?? DESARROLLOS[id] ?? DESARROLLOS[DESARROLLO_DEFAULT]! +} diff --git a/apps/cotizador/src/components/ComparadorUnidades.tsx b/apps/cotizador/src/components/ComparadorUnidades.tsx index 256b394..ef170ae 100644 --- a/apps/cotizador/src/components/ComparadorUnidades.tsx +++ b/apps/cotizador/src/components/ComparadorUnidades.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import type { Unidad, TipoPropuesta } from '../types' import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades' import { formatoMoneda } from '../lib/formatters' -import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos' +import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas' interface Props { unidades: Unidad[] @@ -10,6 +10,8 @@ interface Props { tipo: TipoPropuesta apartado: number acabados: boolean + /** Reglas comerciales por desarrollo (vacío => constantes) */ + reglas?: ReglasDesarrollo } const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`) @@ -23,17 +25,25 @@ interface Fila { /** * Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado, * con la propuesta activa calculada por el motor con los parámetros de cada - * desarrollo. Solo ofrece unidades DISPONIBLES: no se compara (ni se cotiza) - * inventario apartado, vendido o bloqueado. + * desarrollo (las reglas del admin con respaldo en las constantes). Solo ofrece + * unidades DISPONIBLES de desarrollos activos en el cotizador: no se compara + * (ni se cotiza) inventario apartado, vendido, bloqueado o desactivado. */ -export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props) { +export function ComparadorUnidades({ unidades, tipo, apartado, acabados, reglas = SIN_REGLAS }: Props) { const [abierto, setAbierto] = useState(false) const [codigoA, setCodigoA] = useState('') const [codigoB, setCodigoB] = useState('') + const desarrollos = useMemo(() => idsCotizables(reglas), [reglas]) + const disponibles = useMemo( - () => unidades.filter((u) => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'), - [unidades], + () => + unidades.filter( + (u) => + (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' && + desarrollos.includes(u.desarrollo ?? 'DIAGONAL'), + ), + [unidades, desarrollos], ) const porCodigo = useMemo(() => { @@ -46,8 +56,11 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props const unidadB = porCodigo.get(codigoB) ?? null const comparacion = useMemo( - () => (unidadA && unidadB ? compararUnidades(unidadA, unidadB, tipo, apartado, acabados) : null), - [unidadA, unidadB, tipo, apartado, acabados], + () => + unidadA && unidadB + ? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas) + : null, + [unidadA, unidadB, tipo, apartado, acabados, reglas], ) const filas: Fila[] = [ @@ -87,11 +100,11 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none" > - {LISTA_DESARROLLOS.map((dev) => { + {desarrollos.map((dev) => { const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev) if (delDesarrollo.length === 0) return null return ( - + {delDesarrollo.map((u) => (