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

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

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

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

View File

@@ -33,12 +33,17 @@ const prismaMock = vi.hoisted(() => ({
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
// Reglas comerciales configurables por el admin (vacías = constantes)
reglaCotizadorDesarrollo: {
findMany: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
import { DESARROLLOS } from '../services/cotizacion.service.js'
/**
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
@@ -91,6 +96,7 @@ beforeEach(() => {
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
@@ -233,6 +239,107 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
})
})
describe('crearCotizacion — reglas comerciales del admin', () => {
/** Reglas guardadas del desarrollo, con los ajustes del test */
function reglasGuardadas(
desarrollo: string,
ajustes: { propuestaInactiva?: string; activoEnCotizador?: boolean; descuentoContado?: number } = {},
) {
const base = DESARROLLOS[desarrollo]!
return [
{
desarrolloId: desarrollo,
parametrosJson: JSON.stringify({
...base,
...(ajustes.activoEnCotizador !== undefined
? { activoEnCotizador: ajustes.activoEnCotizador }
: {}),
propuestas: base.propuestas.map((p) => ({
...p,
activa: p.tipo !== ajustes.propuestaInactiva,
...(p.tipo === 'CONTADO' && ajustes.descuentoContado !== undefined
? { descuentoDefault: ajustes.descuentoContado }
: {}),
})),
}),
},
]
}
it('sin reglas guardadas cotiza igual que siempre (respaldo en constantes)', async () => {
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0 }),
})
})
it('usa el descuento configurado por el admin', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
)
const { res, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0.1, precioFinal: 900_000 }),
})
})
it('propuesta desactivada → 409 con el mensaje del servidor y sin persistir', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
)
const { res } = resFake()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
status: 409,
message: expect.stringContaining('La propuesta BANCARIO no está disponible'),
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
// Las otras propuestas siguen cotizándose
const { res: res2, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res2)
expect(status).toHaveBeenCalledWith(201)
})
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { activoEnCotizador: false }),
)
const { res } = resFake()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
status: 409,
message: expect.stringContaining('no está disponible en el cotizador'),
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('JSON corrupto en la BD: cotiza con las constantes (un warning, sin fallar)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: '{corrupto' },
])
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0, precioLista: 1_000_000 }),
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
warn.mockRestore()
})
})
describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => {
it('crea una ActividadLocal al vendedor de cada cotización reciente, con dedup', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])

View File

@@ -1,7 +1,13 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { getUnidadLocal } from '../services/unidades-local.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
import {
calcularPropuesta,
obtenerPrecioLista,
propuestasVigentes,
TIPO_PROPUESTA_API,
} from '../services/cotizacion.service.js'
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
import {
procesarAlertasInventario,
type ResultadoAlertas,
@@ -76,6 +82,24 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
// el mismo inmueble ya apartado/vendido
validarUnidadDisponible(unidad)
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
// respaldo en las constantes históricas. Un desarrollo o una propuesta
// desactivados se rechazan con el mensaje que muestra el cotizador.
const reglas = await getReglasDesarrollo(unidad.desarrollo)
if (reglas.activoEnCotizador === false) {
throw new AppError(
409,
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
)
}
const tipoInterno = TIPO_PROPUESTA_API[body.tipoPropuesta]
if (!propuestasVigentes(reglas).some((p) => p.tipo === tipoInterno)) {
throw new AppError(
409,
`La propuesta ${body.tipoPropuesta} no está disponible para el desarrollo ${reglas.id}. Elige otra propuesta.`,
)
}
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
const propuesta = calcularPropuesta(
unidad,
@@ -85,6 +109,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
body.descuento,
body.enganchePct,
body.numMensualidades,
reglas,
)
const precioLista = obtenerPrecioLista(unidad, body.acabados)

View File

@@ -0,0 +1,524 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Prisma mockeado (el servicio corre de verdad) ----------
const prismaMock = vi.hoisted(() => ({
desarrollo: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
modeloDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
reglaCotizadorDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
upsert: vi.fn(),
count: vi.fn(),
},
unidad: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
cotizacion: { count: vi.fn(), findMany: vi.fn() },
leadCaptura: { count: vi.fn(), findMany: vi.fn() },
cliente: { count: vi.fn(), findMany: vi.fn() },
planPago: { count: vi.fn(), findMany: vi.fn() },
auditoriaLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { requireAdminEstricto } from './config.controller.js'
import {
actualizarDesarrolloEndpoint,
agregarModeloEndpoint,
crearDesarrolloEndpoint,
crearUnidadesEndpoint,
eliminarDesarrolloEndpoint,
eliminarUnidadEndpoint,
guardarReglasEndpoint,
listarDesarrollosEndpoint,
listarDesarrollosPublicosEndpoint,
obtenerDesarrolloPublicoEndpoint,
} from './desarrollos.controller.js'
function reqFake(opts: { body?: unknown; params?: Record<string, string>; query?: Record<string, string>; token?: { sub: string; rol: string } }): Request {
return {
body: opts.body ?? {},
params: opts.params ?? {},
query: opts.query ?? {},
usuarioToken: opts.token,
} as unknown as Request
}
function resFake() {
const json = vi.fn()
const end = vi.fn()
const statusFn = vi.fn().mockReturnValue({ json, end })
return { res: { json, end, status: statusFn } as unknown as Response, status: statusFn, json, end }
}
const ADMIN = { sub: 'u-admin', rol: 'admin' }
function filaDesarrollo(over: Record<string, unknown> = {}) {
return {
id: 'd-1',
clave: 'SENDA',
nombre: 'Senda',
estado: 'ACTIVO',
tipo: 'VERTICAL',
constructora: null,
direccion: null,
zona: 'Zapopan',
descripcion: null,
amenidades: null,
fechaInicioObra: null,
fechaEntregaProgramada: null,
fechaEntregaReprogramada: null,
activoEnCotizador: true,
activoEnAlta: true,
prefijoUnidadPdf: 'CASA',
imagenUrl: null,
logoUrl: null,
createdAt: new Date('2026-09-21T00:00:00.000Z'),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
modelos: [],
reglas: null,
...over,
}
}
function filaUnidad(over: Record<string, unknown> = {}) {
return {
id: 'u-1',
codigo: 'SENDA-1',
desarrollo: 'SENDA',
modelo: 'HIMALAYA',
manzana: '',
lote: '',
nivel: '',
supConstruccion: 120,
supExcedente: 0,
supEstacionamiento: 1,
precioLista: 3000000,
precioAcabados: 3200000,
status: 'DISPONIBLE',
etapa: '',
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...over,
}
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.count.mockResolvedValue(0)
prismaMock.cotizacion.count.mockResolvedValue(0)
prismaMock.leadCaptura.count.mockResolvedValue(0)
prismaMock.cliente.count.mockResolvedValue(0)
prismaMock.planPago.count.mockResolvedValue(0)
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: `id-${String(data['codigo'])}`,
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...data,
}))
})
// ── Permisos ──────────────────────────────────────────────────────────
describe('requireAdminEstricto', () => {
it('deja pasar al admin', () => {
const next = vi.fn()
requireAdminEstricto(reqFake({ token: ADMIN }), {} as Response, next)
expect(next).toHaveBeenCalledOnce()
})
it('403 para manager y para vendedor', () => {
for (const rol of ['manager', 'vendedor', 'coordinador', 'corporativo']) {
expect(() => requireAdminEstricto(reqFake({ token: { sub: 'u-1', rol } }), {} as Response, vi.fn())).toThrow(
'Solo un administrador puede hacer esta operación',
)
}
})
it('403 sin token', () => {
expect(() => requireAdminEstricto(reqFake({}), {} as Response, vi.fn())).toThrow()
})
it('los endpoints de escritura quedan detrás del guard (403 con rol manager)', async () => {
// El guard se monta en las rutas; aquí se comprueba que corta antes del servicio
const req = reqFake({ token: { sub: 'u-1', rol: 'manager' }, body: { clave: 'NUEVO', nombre: 'Nuevo' } })
expect(() => requireAdminEstricto(req, {} as Response, vi.fn())).toThrow()
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
})
})
// ── Fichas ────────────────────────────────────────────────────────────
describe('POST /api/desarrollos', () => {
it('201 con la ficha cuando la clave es nueva', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(filaDesarrollo())
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
const { res, status, json } = resFake()
await crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ accion: 'CREAR_DESARROLLO', entidad: 'desarrollo', usuarioId: 'u-admin' }),
})
})
it('409 con clave duplicada y sin crear', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
const { res } = resFake()
await expect(crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)).rejects.toMatchObject({
status: 409,
})
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
})
it('400 si la clave no es mayúsculas sin espacios', async () => {
const { res } = resFake()
await expect(
crearDesarrolloEndpoint(reqFake({ body: { clave: 'senda nueva', nombre: 'Senda' }, token: ADMIN }), res),
).rejects.toThrow()
})
})
describe('GET /api/desarrollos y detalle', () => {
it('devuelve el contrato del listado', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
reglas: { desarrolloId: 'd-1', parametrosJson: '{}', updatedAt: new Date(), updatedPorId: null },
}),
])
prismaMock.unidad.findMany.mockResolvedValue([
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
{ desarrollo: 'SENDA', status: 'APARTADO' },
])
const { res, json } = resFake()
await listarDesarrollosEndpoint(reqFake({ token: ADMIN }), res)
expect(json).toHaveBeenCalledWith({
desarrollos: [
expect.objectContaining({
clave: 'SENDA',
unidadesTotales: 2,
unidadesDisponibles: 1,
tieneReglas: true,
modelos: [
{ id: 'm-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
],
}),
],
})
})
})
describe('PATCH /api/desarrollos/:id', () => {
it('404 si no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(actualizarDesarrolloEndpoint(reqFake({ params: { id: 'nope' }, body: { zona: 'X' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
status: 404,
})
})
it('actualiza solo los campos enviados', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' }).mockResolvedValueOnce(filaDesarrollo({ zona: 'Tlajomulco' }))
prismaMock.desarrollo.update.mockResolvedValue(filaDesarrollo())
const { res, json } = resFake()
await actualizarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, body: { zona: 'Tlajomulco' }, token: ADMIN }), res)
expect(prismaMock.desarrollo.update).toHaveBeenCalledWith({ where: { id: 'd-1' }, data: { zona: 'Tlajomulco' } })
expect(json).toHaveBeenCalledWith(expect.objectContaining({ zona: 'Tlajomulco' }))
})
})
describe('DELETE /api/desarrollos/:id', () => {
it('409 con inventario y operación; 204 sin dependencias', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
prismaMock.cotizacion.count.mockResolvedValue(3)
await expect(eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
status: 409,
message: 'El desarrollo tiene 2 unidades y 3 registros de operación; desactívalo en vez de borrarlo',
})
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
})
it('204 y auditoría cuando el desarrollo está limpio', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const { res, status } = resFake()
await eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(204)
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ accion: 'ELIMINAR_DESARROLLO', entidadId: 'd-1' }),
})
})
})
// ── Modelos y reglas ──────────────────────────────────────────────────
describe('POST /api/desarrollos/:id/modelos', () => {
it('201 con el modelo creado', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue(null)
prismaMock.modeloDesarrollo.create.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'PANORAMA' })
const { res, status } = resFake()
await agregarModeloEndpoint(reqFake({ params: { id: 'd-1' }, body: { nombre: 'PANORAMA', recamaras: 3 }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(201)
})
})
describe('PUT /api/desarrollos/:id/reglas', () => {
const reglas = {
precioUnico: false,
fuenteDeN: 'fechaEntrega',
nDefault: 13,
cuotaMantenimiento: 1500,
prefijoUnidadPdf: 'CASA',
propuestas: [
{
tipo: 'CREDITO_BANCARIO',
claveDescuento: 'creditoBancario',
titulo: 'Crédito Bancario',
descuentoDefault: 0,
pctEnganche: 0.025,
engancheBase: 'lista',
pctPagoFinal: 0.9,
pagoFinalBase: 'final',
modoMensualidades: 'remanente',
},
],
}
it('guarda y devuelve las reglas parseadas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
desarrolloId: 'd-1',
parametrosJson: JSON.stringify(reglas),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
updatedPorId: 'u-admin',
})
const { res, json } = resFake()
await guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: reglas, token: ADMIN }), res)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ desarrolloId: 'd-1', parametros: expect.objectContaining({ nDefault: 13 }) }))
})
it('400 si un porcentaje se sale de 0..1', async () => {
const malas = { ...reglas, propuestas: [{ ...reglas.propuestas[0], pctPagoFinal: 1.2 }] }
await expect(guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: malas, token: ADMIN }), resFake().res)).rejects.toThrow()
expect(prismaMock.reglaCotizadorDesarrollo.upsert).not.toHaveBeenCalled()
})
})
// ── Inventario ────────────────────────────────────────────────────────
describe('POST /api/desarrollos/:id/unidades', () => {
it('genera el rango con {clave} y {n}: 201 con las creadas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const { res, status, json } = resFake()
await crearUnidadesEndpoint(
reqFake({
params: { id: 'd-1' },
body: { generar: { patron: 'SENDA-401-{n}', desde: 1, hasta: 3, precioLista: 2000000 } },
token: ADMIN,
}),
res,
)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({
creadas: 3,
unidades: expect.arrayContaining([expect.objectContaining({ codigo: 'SENDA-401-1', status: 'DISPONIBLE' })]),
})
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ accion: 'CREAR_UNIDADES', entidad: 'unidad' }),
})
})
it('{clave} se sustituye por la clave del desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const { json } = resFake()
await crearUnidadesEndpoint(
reqFake({ params: { id: 'd-1' }, body: { generar: { patron: '{clave}-{n}', desde: 7, hasta: 8, precioLista: 1 } }, token: ADMIN }),
resFake().res,
)
void json
const creadas = prismaMock.unidad.create.mock.calls.map((c) => (c[0] as { data: { codigo: string } }).data.codigo)
expect(creadas).toEqual(['SENDA-7', 'SENDA-8'])
})
it('409 sin crear nada si algún código ya existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([{ codigo: 'SENDA-5' }])
const { res } = resFake()
await expect(
crearUnidadesEndpoint(
reqFake({
params: { id: 'd-1' },
body: {
unidades: [
{ codigo: 'SENDA-5', precioLista: 1 },
{ codigo: 'SENDA-6', precioLista: 1 },
],
},
token: ADMIN,
}),
res,
),
).rejects.toMatchObject({ status: 409 })
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
})
it('409 si el mismo código se repite en la alta', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValue([])
await expect(
crearUnidadesEndpoint(
reqFake({
params: { id: 'd-1' },
body: { unidades: [{ codigo: 'SENDA-1', precioLista: 1 }, { codigo: 'SENDA-1', precioLista: 2 }] },
token: ADMIN,
}),
resFake().res,
),
).rejects.toMatchObject({ status: 409 })
})
it('404 si el desarrollo no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(
crearUnidadesEndpoint(reqFake({ params: { id: 'nope' }, body: { generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }, token: ADMIN }), resFake().res),
).rejects.toMatchObject({ status: 404 })
})
})
describe('DELETE /api/desarrollos/:id/unidades/:codigo', () => {
it('409 si la unidad tiene cotizaciones o ventas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
prismaMock.planPago.count.mockResolvedValue(1)
await expect(
eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), resFake().res),
).rejects.toMatchObject({ status: 409 })
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
})
it('204 si la unidad está libre', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
const { res, status } = resFake()
await eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(204)
})
})
// ── Público (sin auth) ────────────────────────────────────────────────
describe('GET /api/publico/desarrollos', () => {
it('publica solo el desarrollo activo con inventario disponible y sin datos personales', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
}),
])
prismaMock.unidad.findMany.mockResolvedValue([
filaUnidad({ codigo: 'SENDA-1', nivel: '3' }),
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 5000000 }),
])
const { res, json } = resFake()
await listarDesarrollosPublicosEndpoint(reqFake({}), res)
const payload = (json.mock.calls[0] as [ { desarrollos: Record<string, unknown>[] } ])[0]
const desarrollo = payload.desarrollos[0] as Record<string, unknown>
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
)
expect(Object.keys(desarrollo).sort()).toEqual(
[
'amenidades',
'clave',
'descripcion',
'direccion',
'estado',
'fechaEntregaProgramada',
'imagenUrl',
'logoUrl',
'modelos',
'nombre',
'tipo',
'unidadesDisponibles',
'zona',
].sort(),
)
expect(desarrollo['unidadesDisponibles']).toEqual([
{ codigo: 'SENDA-1', modelo: 'HIMALAYA', nivel: '3', precioLista: 3000000, precioAcabados: 3200000, supConstruccion: 120 },
])
// El camino público nunca consulta clientes, cotizaciones ni planes
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
expect(prismaMock.cliente.findMany).not.toHaveBeenCalled()
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
expect(prismaMock.planPago.count).not.toHaveBeenCalled()
expect(prismaMock.leadCaptura.count).not.toHaveBeenCalled()
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado', 'rfc']) {
expect(JSON.stringify(payload).toLowerCase()).not.toContain(prohibido)
}
})
})
describe('GET /api/publico/desarrollos/:clave', () => {
it('404 si no existe o no es público', async () => {
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
await expect(obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'NOEXISTE' } }), resFake().res)).rejects.toMatchObject({
status: 404,
})
})
it('resuelve la clave sin importar mayúsculas', async () => {
prismaMock.desarrollo.findFirst.mockResolvedValue(filaDesarrollo())
const { res, json } = resFake()
await obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'senda' } }), res)
expect(prismaMock.desarrollo.findFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'SENDA', activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
})
})

View File

@@ -0,0 +1,141 @@
/**
* Endpoints del módulo Desarrollos.
*
* Todo el CRUD y el inventario son solo admin (`requireAdminEstricto`, montado
* en las rutas); el micrositio público lee /api/publico/desarrollos sin auth,
* con información exclusivamente comercial (nunca clientes ni cotizaciones).
*/
import type { Request, Response } from 'express'
import * as service from '../services/desarrollos.service.js'
import * as auditoria from '../services/auditoria.service.js'
import { AppError } from '../utils/errors.js'
/** Usuario del token para la bitácora (modo local sin auth: 'desconocido') */
function usuarioDe(req: Request): string {
return req.usuarioToken?.sub ?? 'desconocido'
}
function idDe(req: Request): string {
const id = String(req.params['id'] ?? '')
if (id === '') throw new AppError(400, 'Falta el parámetro :id')
return id
}
// ── Fichas ────────────────────────────────────────────────────────────
/** GET /api/desarrollos */
export async function listarDesarrollosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await service.listarDesarrollos())
}
/** GET /api/desarrollos/:id */
export async function obtenerDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
res.json(await service.obtenerDesarrollo(idDe(req)))
}
/** POST /api/desarrollos */
export async function crearDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
const body = service.crearDesarrolloSchema.parse(req.body)
const ficha = await service.crearDesarrollo(body)
res.status(201).json(ficha)
auditoria.registrar(usuarioDe(req), 'CREAR_DESARROLLO', 'desarrollo', ficha.id, ficha.clave)
}
/** PATCH /api/desarrollos/:id */
export async function actualizarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
const body = service.actualizarDesarrolloSchema.parse(req.body)
const ficha = await service.actualizarDesarrollo(idDe(req), body)
res.json(ficha)
auditoria.registrar(
usuarioDe(req),
'ACTUALIZAR_DESARROLLO',
'desarrollo',
ficha.id,
Object.keys(req.body as Record<string, unknown>).join(', '),
)
}
/** DELETE /api/desarrollos/:id — 409 si tiene inventario u operación */
export async function eliminarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
const id = idDe(req)
await service.eliminarDesarrollo(id)
res.status(204).end()
auditoria.registrar(usuarioDe(req), 'ELIMINAR_DESARROLLO', 'desarrollo', id)
}
// ── Modelos ───────────────────────────────────────────────────────────
/** POST /api/desarrollos/:id/modelos */
export async function agregarModeloEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const body = service.crearModeloSchema.parse(req.body)
const modelo = await service.agregarModelo(desarrolloId, body)
res.status(201).json(modelo)
auditoria.registrar(usuarioDe(req), 'AGREGAR_MODELO', 'desarrollo', desarrolloId, modelo.nombre)
}
/** PATCH /api/desarrollos/modelos/:modeloId */
export async function actualizarModeloEndpoint(req: Request, res: Response): Promise<void> {
const modeloId = String(req.params['modeloId'])
const body = service.actualizarModeloSchema.parse(req.body)
const modelo = await service.actualizarModelo(modeloId, body)
res.json(modelo)
auditoria.registrar(usuarioDe(req), 'ACTUALIZAR_MODELO', 'desarrollo', modelo.desarrolloId, modelo.nombre)
}
/** DELETE /api/desarrollos/modelos/:modeloId — 409 si el modelo tiene unidades */
export async function eliminarModeloEndpoint(req: Request, res: Response): Promise<void> {
const modeloId = String(req.params['modeloId'])
await service.eliminarModelo(modeloId)
res.status(204).end()
auditoria.registrar(usuarioDe(req), 'ELIMINAR_MODELO', 'desarrollo', modeloId)
}
// ── Reglas del cotizador ──────────────────────────────────────────────
/** PUT /api/desarrollos/:id/reglas */
export async function guardarReglasEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const parametros = service.parametrosDesarrolloSchema.parse(req.body)
const reglas = await service.guardarReglas(desarrolloId, parametros, req.usuarioToken?.sub ?? null)
res.json(reglas)
auditoria.registrar(usuarioDe(req), 'GUARDAR_REGLAS_COTIZADOR', 'desarrollo', desarrolloId)
}
// ── Inventario ────────────────────────────────────────────────────────
/** POST /api/desarrollos/:id/unidades — alta manual o por generador */
export async function crearUnidadesEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const body = service.crearUnidadesSchema.parse(req.body)
const resultado = await service.crearUnidades(desarrolloId, body)
res.status(201).json(resultado)
auditoria.registrar(
usuarioDe(req),
'CREAR_UNIDADES',
'unidad',
desarrolloId,
`${resultado.creadas} unidad(es): ${resultado.unidades.map((u) => u.codigo).join(', ')}`,
)
}
/** DELETE /api/desarrollos/:id/unidades/:codigo — 409 si la unidad tiene operación */
export async function eliminarUnidadEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const codigo = String(req.params['codigo'])
await service.eliminarUnidad(desarrolloId, codigo)
res.status(204).end()
auditoria.registrar(usuarioDe(req), 'ELIMINAR_UNIDAD', 'unidad', codigo)
}
// ── Público (micrositio) ──────────────────────────────────────────────
/** GET /api/publico/desarrollos */
export async function listarDesarrollosPublicosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await service.listarDesarrollosPublicos())
}
/** GET /api/publico/desarrollos/:clave */
export async function obtenerDesarrolloPublicoEndpoint(req: Request, res: Response): Promise<void> {
res.json(await service.obtenerDesarrolloPublico(String(req.params['clave'])))
}

View File

@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
const prismaMock = vi.hoisted(() => ({
reglaCotizadorDesarrollo: { findMany: vi.fn() },
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { reglasCotizadorEndpoint } from './reglas-cotizador.controller.js'
import { DESARROLLOS } from '../services/cotizacion.service.js'
function resFake() {
const json = vi.fn()
return { res: { json } as unknown as Response, json }
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
})
describe('GET /api/desarrollos/reglas-cotizador', () => {
it('devuelve el mapa reglasPorDesarrollo con las reglas efectivas', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
{
desarrolloId: 'SENDA',
parametrosJson: JSON.stringify(DESARROLLOS['SENDA']),
activoEnCotizador: false,
},
])
const { res, json } = resFake()
await reglasCotizadorEndpoint({} as Request, res)
const cuerpo = json.mock.calls[0]?.[0] as {
reglasPorDesarrollo: Record<string, { activoEnCotizador?: boolean; propuestas: unknown[] }>
}
expect(Object.keys(cuerpo.reglasPorDesarrollo).sort()).toEqual(Object.keys(DESARROLLOS).sort())
expect(cuerpo.reglasPorDesarrollo['SENDA']?.activoEnCotizador).toBe(false)
expect(cuerpo.reglasPorDesarrollo['SENDA']?.propuestas).toHaveLength(4)
// Los desarrollos sin reglas guardadas salen con sus constantes
expect(cuerpo.reglasPorDesarrollo['DIAGONAL']).toEqual(DESARROLLOS['DIAGONAL'])
})
})

View File

@@ -0,0 +1,14 @@
import type { Request, Response } from 'express'
import { getReglasEfectivas } from '../services/reglas-desarrollo.service.js'
/**
* GET /api/desarrollos/reglas-cotizador — reglas comerciales EFECTIVAS por
* desarrollo (las configuradas por el admin en la BD, con respaldo en las
* constantes históricas para los desarrollos sin reglas guardadas).
*
* Autenticado y disponible para cualquier rol que cotice (el middleware de auth
* cubre todo /api salvo las rutas públicas). Devuelve el mapa `reglasPorDesarrollo`.
*/
export async function reglasCotizadorEndpoint(_req: Request, res: Response): Promise<void> {
res.json({ reglasPorDesarrollo: await getReglasEfectivas() })
}