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