Files
FRASA-CAS/api-gateway/src/services/desarrollos.service.ts
Consult AS Technology d6548999bf 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).
2026-09-21 09:52:47 +00:00

755 lines
29 KiB
TypeScript

import { z } from 'zod'
import { prisma } from './prisma.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
/**
* Módulo Desarrollos: la BD es la fuente de verdad del catálogo de
* desarrollos (ficha comercial, modelos de unidad y reglas del cotizador).
*
* Sustituye a las listas fijas del código (DESARROLLOS de
* config/campos-departamento.ts y las constantes de
* apps/cotizador/src/data/desarrollos.ts), que hoy siguen funcionando como
* espejo hasta que el resto de los frentes migre a esta tabla.
*
* El inventario sigue viviendo en `Unidad`, ligado por `desarrollo` = `clave`
* del desarrollo (el `codigo` de la unidad se forma con esa clave).
*/
/** Estados comerciales del desarrollo */
export const ESTADOS_DESARROLLO = ['ACTIVO', 'PREVENTA', 'CERRADO'] as const
/** VERTICAL = edificio (deptos); HORIZONTAL = casas en lote */
export const TIPOS_DESARROLLO = ['VERTICAL', 'HORIZONTAL'] as const
/** Formatos de propuesta del cotizador */
export const TIPOS_PROPUESTA = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
] as const
/** Claves de descuento editables del cotizador */
export const CLAVES_DESCUENTO = ['creditoBancario', 'creditoInfonavit', 'contado', 'semiContado'] as const
const CLAVE_DESARROLLO = /^[A-Z0-9-]{2,20}$/
// ── Validación de las reglas del cotizador ────────────────────────────
const fraccion = (campo: string) =>
z.number().min(0, `${campo} debe estar entre 0 y 1`).max(1, `${campo} debe estar entre 0 y 1`)
const propuestaSchema = z
.object({
tipo: z.enum(TIPOS_PROPUESTA),
claveDescuento: z.enum(CLAVES_DESCUENTO),
titulo: z.string().trim().min(1),
descuentoDefault: fraccion('descuentoDefault'),
pctEnganche: fraccion('pctEnganche'),
engancheBase: z.enum(['lista', 'final']),
pctPagoFinal: fraccion('pctPagoFinal'),
pagoFinalBase: z.enum(['lista', 'final']),
// 'porcentaje' es el modo de la mayoría de los desarrollos; sin él la
// config real del cotizador no valida (el brief lo omitía por error).
modoMensualidades: z.enum(['remanente', 'remanenteLista', 'porcentaje', 'ninguna']),
// SOLANA Infonavit: si el enganche es 0 y no hay apartado, no se resta
engancheCeroSinApartado: z.boolean().optional(),
})
.strict()
/**
* Forma del JSON de reglas: la config que antes vivía en
* apps/cotizador/src/data/desarrollos.ts. `id`/`nombre` se aceptan porque los
* objetos del cotizador los traen, pero la fuente de verdad del nombre es la
* ficha del desarrollo.
*/
export const parametrosDesarrolloSchema = z
.object({
id: z.string().trim().min(1).optional(),
nombre: z.string().trim().min(1).optional(),
precioUnico: z.boolean(),
fuenteDeN: z.enum(['fechaEntrega', 'fijo', 'porUnidad']),
// N fijo cuando fuenteDeN === 'fijo' (entrega inmediata)
nFijo: z.number().int().min(0).optional(),
nDefault: z.number().int().min(0),
// null => el cotizador muestra "PENDIENTE" (desarrollos ya entregados)
cuotaMantenimiento: z.number().min(0).nullable(),
prefijoUnidadPdf: z.string().trim().min(1),
propuestas: z.array(propuestaSchema).min(1, 'Se requiere al menos una propuesta'),
})
.strict()
export type ParametrosDesarrolloJson = z.infer<typeof parametrosDesarrolloSchema>
// ── Validación de la ficha ────────────────────────────────────────────
export const crearDesarrolloSchema = z
.object({
clave: z
.string()
.trim()
.regex(CLAVE_DESARROLLO, 'La clave debe ser mayúsculas sin espacios (2 a 20 caracteres: A-Z, 0-9, -)'),
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
estado: z.enum(ESTADOS_DESARROLLO).optional(),
tipo: z.enum(TIPOS_DESARROLLO).optional(),
constructora: z.string().trim().min(1).nullable().optional(),
direccion: z.string().trim().min(1).nullable().optional(),
zona: z.string().trim().min(1).nullable().optional(),
descripcion: z.string().trim().min(1).nullable().optional(),
amenidades: z.string().trim().min(1).nullable().optional(),
fechaInicioObra: z.coerce.date().nullable().optional(),
fechaEntregaProgramada: z.coerce.date().nullable().optional(),
fechaEntregaReprogramada: z.coerce.date().nullable().optional(),
activoEnCotizador: z.boolean().optional(),
activoEnAlta: z.boolean().optional(),
prefijoUnidadPdf: z.string().trim().min(1).nullable().optional(),
imagenUrl: z.string().trim().min(1).nullable().optional(),
logoUrl: z.string().trim().min(1).nullable().optional(),
})
.strict()
export const actualizarDesarrolloSchema = crearDesarrolloSchema.partial()
export const crearModeloSchema = z
.object({
nombre: z.string().trim().min(1, 'El nombre del modelo es obligatorio'),
recamaras: z.number().int().min(0).nullable().optional(),
banos: z.number().min(0).nullable().optional(),
estacionamientos: z.number().int().min(0).nullable().optional(),
supConstruccion: z.number().min(0).nullable().optional(),
activo: z.boolean().optional(),
})
.strict()
export const actualizarModeloSchema = crearModeloSchema.partial()
const unidadManualSchema = z
.object({
codigo: z.string().trim().min(1).optional(),
manzana: z.string().trim().optional(),
lote: z.string().trim().optional(),
nivel: z.string().trim().optional(),
modeloId: z.string().trim().min(1).optional(),
precioLista: z.number().min(0, 'precioLista es obligatorio'),
precioAcabados: z.number().min(0).optional(),
supConstruccion: z.number().min(0).optional(),
supExcedente: z.number().min(0).optional(),
supEstacionamiento: z.number().min(0).optional(),
})
.strict()
/** Tope del generador: un rango más grande huele a error de captura */
export const MAX_UNIDADES_POR_ALTA = 1000
const generarUnidadesSchema = z
.object({
patron: z
.string()
.trim()
.min(1)
.refine((p) => p.includes('{n}'), 'El patrón debe incluir {n} (el número de la unidad)')
// El código final sigue el formato de la clave: mayúsculas sin espacios
.refine((p) => !/[^A-Za-z0-9{}_-]/.test(p), 'El patrón solo admite letras, números, guiones, {clave} y {n}'),
desde: z.number().int(),
hasta: z.number().int(),
modeloId: z.string().trim().min(1).optional(),
precioLista: z.number().min(0),
precioAcabados: z.number().min(0).optional(),
supConstruccion: z.number().min(0).optional(),
})
.strict()
export const crearUnidadesSchema = z
.object({
unidades: z.array(unidadManualSchema).min(1).max(MAX_UNIDADES_POR_ALTA).optional(),
generar: generarUnidadesSchema.optional(),
})
.strict()
.refine((b) => (b.unidades === undefined) !== (b.generar === undefined), {
message: 'Envía `unidades` (alta manual) o `generar` (rango), pero no ambos',
})
export type CrearUnidadesBody = z.infer<typeof crearUnidadesSchema>
// ── Helpers ───────────────────────────────────────────────────────────
interface ModeloFila {
id: string
nombre: string
recamaras: number | null
banos: number | null
estacionamientos: number | null
supConstruccion: number | null
activo: boolean
}
function modeloDto(m: ModeloFila) {
return {
id: m.id,
nombre: m.nombre,
recamaras: m.recamaras,
banos: m.banos,
estacionamientos: m.estacionamientos,
supConstruccion: m.supConstruccion,
activo: m.activo,
}
}
function esDisponible(status: string): boolean {
return status.trim().toUpperCase() === 'DISPONIBLE'
}
/** Clave de desarrollo normalizada (la tabla Unidad la guarda en mayúsculas) */
export function normalizarClave(clave: string): string {
return clave.trim().toUpperCase()
}
/** Conteos por desarrollo a partir de una lectura del inventario */
function conteosPorDesarrollo(unidades: { desarrollo: string; status: string }[]): Map<string, { total: number; disponibles: number }> {
const mapa = new Map<string, { total: number; disponibles: number }>()
for (const u of unidades) {
const clave = normalizarClave(u.desarrollo)
const actual = mapa.get(clave) ?? { total: 0, disponibles: 0 }
actual.total++
if (esDisponible(u.status)) actual.disponibles++
mapa.set(clave, actual)
}
return mapa
}
function parsearReglas(regla: { parametrosJson: string; updatedAt: Date; updatedPorId: string | null } | null) {
if (regla === null) return null
let parametros: unknown
try {
parametros = JSON.parse(regla.parametrosJson)
} catch {
parametros = null
}
return {
parametros,
updatedAt: regla.updatedAt,
updatedPorId: regla.updatedPorId,
}
}
/** Desarrollo + relaciones tal como lo devuelve Prisma en las lecturas de ficha */
const INCLUDE_FICHA = { modelos: { orderBy: { nombre: 'asc' } }, reglas: true } as const
async function fichaPorId(id: string) {
const fila = await prisma.desarrollo.findUnique({ where: { id }, include: INCLUDE_FICHA })
if (fila === null) return null
const unidades = await prisma.unidad.findMany({
where: { desarrollo: fila.clave },
select: { status: true },
})
const porEstatus: Record<string, number> = {}
for (const u of unidades) {
const estatus = u.status.trim().toUpperCase() || 'SIN_ESTATUS'
porEstatus[estatus] = (porEstatus[estatus] ?? 0) + 1
}
return {
id: fila.id,
clave: fila.clave,
nombre: fila.nombre,
estado: fila.estado,
tipo: fila.tipo,
constructora: fila.constructora,
direccion: fila.direccion,
zona: fila.zona,
descripcion: fila.descripcion,
amenidades: fila.amenidades,
fechaInicioObra: fila.fechaInicioObra,
fechaEntregaProgramada: fila.fechaEntregaProgramada,
fechaEntregaReprogramada: fila.fechaEntregaReprogramada,
activoEnCotizador: fila.activoEnCotizador,
activoEnAlta: fila.activoEnAlta,
prefijoUnidadPdf: fila.prefijoUnidadPdf,
imagenUrl: fila.imagenUrl,
logoUrl: fila.logoUrl,
createdAt: fila.createdAt,
updatedAt: fila.updatedAt,
modelos: fila.modelos.map(modeloDto),
reglas: parsearReglas(fila.reglas),
unidadesTotales: unidades.length,
unidadesDisponibles: unidades.filter((u) => esDisponible(u.status)).length,
unidadesPorEstatus: porEstatus,
}
}
export type FichaDesarrollo = NonNullable<Awaited<ReturnType<typeof fichaPorId>>>
/** La ficha completa o 404 */
export async function obtenerDesarrollo(id: string): Promise<FichaDesarrollo> {
const ficha = await fichaPorId(id)
if (ficha === null) throw new NotFoundError('Desarrollo no encontrado')
return ficha
}
// ── Listado ───────────────────────────────────────────────────────────
export async function listarDesarrollos() {
const [desarrollos, unidades] = await Promise.all([
prisma.desarrollo.findMany({ include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
prisma.unidad.findMany({ select: { desarrollo: true, status: true } }),
])
const conteos = conteosPorDesarrollo(unidades)
return {
desarrollos: desarrollos.map((d) => {
const c = conteos.get(normalizarClave(d.clave)) ?? { total: 0, disponibles: 0 }
return {
id: d.id,
clave: d.clave,
nombre: d.nombre,
estado: d.estado,
tipo: d.tipo,
activoEnCotizador: d.activoEnCotizador,
activoEnAlta: d.activoEnAlta,
modelos: d.modelos.map(modeloDto),
unidadesTotales: c.total,
unidadesDisponibles: c.disponibles,
tieneReglas: d.reglas !== null,
}
}),
}
}
// ── Alta / cambio / borrado ───────────────────────────────────────────
export async function crearDesarrollo(data: z.infer<typeof crearDesarrolloSchema>) {
const clave = normalizarClave(data.clave)
const existente = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
if (existente !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
const fila = await prisma.desarrollo.create({
data: {
clave,
nombre: data.nombre,
estado: data.estado ?? 'ACTIVO',
tipo: data.tipo ?? 'VERTICAL',
constructora: data.constructora ?? null,
direccion: data.direccion ?? null,
zona: data.zona ?? null,
descripcion: data.descripcion ?? null,
amenidades: data.amenidades ?? null,
fechaInicioObra: data.fechaInicioObra ?? null,
fechaEntregaProgramada: data.fechaEntregaProgramada ?? null,
fechaEntregaReprogramada: data.fechaEntregaReprogramada ?? null,
activoEnCotizador: data.activoEnCotizador ?? true,
activoEnAlta: data.activoEnAlta ?? true,
prefijoUnidadPdf: data.prefijoUnidadPdf ?? null,
imagenUrl: data.imagenUrl ?? null,
logoUrl: data.logoUrl ?? null,
},
})
return obtenerDesarrollo(fila.id)
}
export async function actualizarDesarrollo(id: string, data: z.infer<typeof actualizarDesarrolloSchema>) {
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
// Solo se actualizan los campos enviados (los undefined se descartan)
const cambios: Record<string, unknown> = {}
for (const [campo, valor] of Object.entries(data)) {
if (valor !== undefined) cambios[campo] = valor
}
if (data.clave !== undefined) {
const clave = normalizarClave(data.clave)
if (clave !== actual.clave) {
const ocupada = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
if (ocupada !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
// El inventario se liga por clave: renombrar la clave sin arrastrar las
// unidades las dejaría huérfanas, así que solo se permite sin unidades.
const unidades = await prisma.unidad.count({ where: { desarrollo: actual.clave } })
if (unidades > 0) {
throw new AppError(409, `El desarrollo tiene ${unidades} unidades; no se puede cambiar la clave`)
}
}
cambios['clave'] = clave
}
await prisma.desarrollo.update({ where: { id }, data: cambios as never })
return obtenerDesarrollo(id)
}
/** Registros de operación ligados al desarrollo: impiden borrarlo */
async function contarRegistrosDeOperacion(clave: string, codigos: string[]): Promise<number> {
const porUnidad = codigos.length > 0 ? { unidadCodigo: { in: codigos } } : null
const [cotizaciones, leads, clientes, planes] = await Promise.all([
prisma.cotizacion.count({ where: porUnidad === null ? { desarrollo: clave } : { OR: [{ desarrollo: clave }, porUnidad] } }),
prisma.leadCaptura.count({ where: { desarrollo: clave } }),
prisma.cliente.count({ where: { proyecto: clave } }),
prisma.planPago.count({ where: { proyecto: clave } }),
])
return cotizaciones + leads + clientes + planes
}
export async function eliminarDesarrollo(id: string): Promise<void> {
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
const unidades = await prisma.unidad.findMany({ where: { desarrollo: actual.clave }, select: { codigo: true } })
const operacion = await contarRegistrosDeOperacion(
actual.clave,
unidades.map((u) => u.codigo),
)
if (unidades.length > 0 || operacion > 0) {
throw new AppError(
409,
`El desarrollo tiene ${unidades.length} unidades y ${operacion} registros de operación; desactívalo en vez de borrarlo`,
)
}
await prisma.desarrollo.delete({ where: { id } })
}
// ── Modelos ───────────────────────────────────────────────────────────
export async function agregarModelo(desarrolloId: string, data: z.infer<typeof crearModeloSchema>) {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const repetido = await prisma.modeloDesarrollo.findUnique({
where: { desarrolloId_nombre: { desarrolloId, nombre: data.nombre } },
select: { id: true },
})
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
return prisma.modeloDesarrollo.create({
data: {
desarrolloId,
nombre: data.nombre,
recamaras: data.recamaras ?? null,
banos: data.banos ?? null,
estacionamientos: data.estacionamientos ?? null,
supConstruccion: data.supConstruccion ?? null,
activo: data.activo ?? true,
},
})
}
export async function actualizarModelo(modeloId: string, data: z.infer<typeof actualizarModeloSchema>) {
const actual = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
if (actual === null) throw new NotFoundError('Modelo no encontrado')
if (data.nombre !== undefined && data.nombre !== actual.nombre) {
const repetido = await prisma.modeloDesarrollo.findUnique({
where: { desarrolloId_nombre: { desarrolloId: actual.desarrolloId, nombre: data.nombre } },
select: { id: true },
})
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
}
const cambios: Record<string, unknown> = {}
for (const [campo, valor] of Object.entries(data)) {
if (valor !== undefined) cambios[campo] = valor
}
return prisma.modeloDesarrollo.update({ where: { id: modeloId }, data: cambios as never })
}
export async function eliminarModelo(modeloId: string): Promise<void> {
const actual = await prisma.modeloDesarrollo.findUnique({
where: { id: modeloId },
include: { desarrollo: { select: { clave: true } } },
})
if (actual === null) throw new NotFoundError('Modelo no encontrado')
const unidades = await prisma.unidad.count({
where: { desarrollo: actual.desarrollo.clave, modelo: actual.nombre },
})
if (unidades > 0) {
throw new AppError(409, `El modelo ${actual.nombre} tiene ${unidades} unidades; no se puede eliminar`)
}
await prisma.modeloDesarrollo.delete({ where: { id: modeloId } })
}
// ── Reglas del cotizador ──────────────────────────────────────────────
export async function guardarReglas(desarrolloId: string, parametros: ParametrosDesarrolloJson, usuarioId: string | null) {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const parametrosJson = JSON.stringify(parametros)
const regla = await prisma.reglaCotizadorDesarrollo.upsert({
where: { desarrolloId },
update: { parametrosJson, updatedPorId: usuarioId },
create: { desarrolloId, parametrosJson, updatedPorId: usuarioId },
})
return {
desarrolloId: regla.desarrolloId,
parametros: JSON.parse(regla.parametrosJson) as ParametrosDesarrolloJson,
updatedAt: regla.updatedAt,
updatedPorId: regla.updatedPorId,
}
}
// ── Inventario ────────────────────────────────────────────────────────
interface UnidadNueva {
codigo: string
modelo: string
manzana: string
lote: string
nivel: string
precioLista: number
precioAcabados: number
supConstruccion: number
supExcedente: number
supEstacionamiento: number
status: string
}
/** Modelo del desarrollo por id (valida que pertenezca al desarrollo) */
async function nombreModelo(desarrolloId: string, modeloId: string): Promise<string> {
const modelo = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
if (modelo === null || modelo.desarrolloId !== desarrolloId) {
throw new BadRequestError('El modelo no pertenece a este desarrollo')
}
return modelo.nombre
}
/** Correlativo siguiente de `${clave}-n` en el inventario del desarrollo */
function correlativoDesdeCodigos(clave: string, codigos: string[]): number {
const patron = new RegExp(`^${clave}-(\\d+)$`)
let max = 0
for (const codigo of codigos) {
const m = patron.exec(codigo)
if (m === null) continue
const n = Number(m[1])
if (n > max) max = n
}
return max
}
export async function crearUnidades(
desarrolloId: string,
body: CrearUnidadesBody,
): Promise<{ creadas: number; unidades: UnidadNueva[] }> {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true, clave: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const clave = desarrollo.clave
let filas: UnidadNueva[]
if (body.generar !== undefined) {
const g = body.generar
if (g.hasta < g.desde) throw new BadRequestError('El rango es inválido: `hasta` debe ser mayor o igual a `desde`')
if (g.hasta - g.desde + 1 > MAX_UNIDADES_POR_ALTA) {
throw new BadRequestError(`El rango genera más de ${MAX_UNIDADES_POR_ALTA} unidades`)
}
const modelo = g.modeloId !== undefined ? await nombreModelo(desarrolloId, g.modeloId) : ''
filas = []
for (let n = g.desde; n <= g.hasta; n++) {
const codigo = g.patron.replaceAll('{clave}', clave).replaceAll('{n}', String(n))
filas.push({
codigo,
modelo,
manzana: '',
lote: '',
nivel: '',
precioLista: g.precioLista,
precioAcabados: g.precioAcabados ?? 0,
supConstruccion: g.supConstruccion ?? 0,
supExcedente: 0,
supEstacionamiento: 0,
status: 'DISPONIBLE',
})
}
} else {
const manuales = body.unidades ?? []
const existentes = await prisma.unidad.findMany({ where: { desarrollo: clave }, select: { codigo: true } })
let correlativo = correlativoDesdeCodigos(clave, existentes.map((u) => u.codigo))
filas = []
for (const u of manuales) {
const modelo = u.modeloId !== undefined ? await nombreModelo(desarrolloId, u.modeloId) : ''
// Sin código explícito se arma con manzana/lote o con un correlativo
const codigo =
u.codigo ??
(u.manzana !== undefined && u.manzana !== '' && u.lote !== undefined && u.lote !== ''
? `${clave}-${u.manzana}-${u.lote}`
: `${clave}-${++correlativo}`)
filas.push({
codigo,
modelo,
manzana: u.manzana ?? '',
lote: u.lote ?? '',
nivel: u.nivel ?? '',
precioLista: u.precioLista,
precioAcabados: u.precioAcabados ?? 0,
supConstruccion: u.supConstruccion ?? 0,
supExcedente: u.supExcedente ?? 0,
supEstacionamiento: u.supEstacionamiento ?? 0,
status: 'DISPONIBLE',
})
}
}
const codigos = filas.map((f) => f.codigo)
const repetidosEnLote = [...new Set(codigos.filter((c, i) => codigos.indexOf(c) !== i))]
const yaEnBd =
codigos.length > 0
? (await prisma.unidad.findMany({ where: { codigo: { in: codigos } }, select: { codigo: true } })).map((u) => u.codigo)
: []
const duplicados = [...new Set([...repetidosEnLote, ...yaEnBd])]
if (duplicados.length > 0) {
throw new AppError(409, `Códigos de unidad duplicados: ${duplicados.join(', ')}`)
}
const creadas = await prisma.$transaction(filas.map((f) => prisma.unidad.create({ data: { ...f, desarrollo: clave } })))
return {
creadas: creadas.length,
unidades: creadas.map((u) => ({
codigo: u.codigo,
modelo: u.modelo,
manzana: u.manzana,
lote: u.lote,
nivel: u.nivel,
precioLista: u.precioLista,
precioAcabados: u.precioAcabados,
supConstruccion: u.supConstruccion,
supExcedente: u.supExcedente,
supEstacionamiento: u.supEstacionamiento,
status: u.status,
})),
}
}
export async function eliminarUnidad(desarrolloId: string, codigo: string): Promise<void> {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { clave: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const unidad = await prisma.unidad.findUnique({ where: { codigo } })
if (unidad === null || normalizarClave(unidad.desarrollo) !== normalizarClave(desarrollo.clave)) {
throw new NotFoundError(`Unidad ${codigo} no encontrada en el desarrollo`)
}
const [cotizaciones, planes, clientes] = await Promise.all([
prisma.cotizacion.count({ where: { unidadCodigo: codigo } }),
prisma.planPago.count({ where: { unidadCodigo: codigo } }),
prisma.cliente.count({ where: { unidadCodigo: codigo } }),
])
const operacion = cotizaciones + planes + clientes
if (operacion > 0) {
throw new AppError(409, `La unidad ${codigo} tiene ${operacion} cotizaciones o ventas; no se puede eliminar`)
}
await prisma.unidad.delete({ where: { codigo } })
}
// ── Público (micrositio, sin auth) ────────────────────────────────────
/**
* Ficha pública: SOLO información comercial del desarrollo y su inventario
* disponible. Nunca clientes, cotizaciones ni datos personales.
*/
function fichaPublica(
desarrollo: {
clave: string
nombre: string
estado: string
tipo: string
zona: string | null
direccion: string | null
descripcion: string | null
amenidades: string | null
imagenUrl: string | null
logoUrl: string | null
fechaEntregaProgramada: Date | null
modelos: { nombre: string; recamaras: number | null; banos: number | null; estacionamientos: number | null; supConstruccion: number | null; activo: boolean }[]
},
unidades: {
codigo: string
modelo: string
nivel: string
precioLista: number
precioAcabados: number
supConstruccion: number
}[],
) {
const disponiblesPorModelo = new Map<string, { n: number; precioDesde: number | null }>()
for (const u of unidades) {
const actual = disponiblesPorModelo.get(u.modelo) ?? { n: 0, precioDesde: null }
actual.n++
if (u.precioLista > 0 && (actual.precioDesde === null || u.precioLista < actual.precioDesde)) {
actual.precioDesde = u.precioLista
}
disponiblesPorModelo.set(u.modelo, actual)
}
return {
clave: desarrollo.clave,
nombre: desarrollo.nombre,
estado: desarrollo.estado,
tipo: desarrollo.tipo,
zona: desarrollo.zona,
direccion: desarrollo.direccion,
descripcion: desarrollo.descripcion,
amenidades: desarrollo.amenidades,
imagenUrl: desarrollo.imagenUrl,
logoUrl: desarrollo.logoUrl,
fechaEntregaProgramada: desarrollo.fechaEntregaProgramada,
modelos: desarrollo.modelos
.filter((m) => m.activo)
.map((m) => {
const disp = disponiblesPorModelo.get(m.nombre) ?? { n: 0, precioDesde: null }
return {
nombre: m.nombre,
recamaras: m.recamaras,
banos: m.banos,
estacionamientos: m.estacionamientos,
supConstruccion: m.supConstruccion,
disponibles: disp.n,
precioDesde: disp.precioDesde,
}
}),
unidadesDisponibles: unidades.map((u) => ({
codigo: u.codigo,
modelo: u.modelo,
precioLista: u.precioLista,
precioAcabados: u.precioAcabados,
supConstruccion: u.supConstruccion,
...(u.nivel !== '' ? { nivel: u.nivel } : {}),
})),
}
}
/** Filtro de publicación: activo en cotizador y no cerrado */
const DONDE_PUBLICO = { activoEnCotizador: true, estado: { not: 'CERRADO' } } as const
/** Inventario publicado de un desarrollo (solo unidades DISPONIBLE) */
async function unidadesPublicas(clave: string) {
const filas = await prisma.unidad.findMany({
where: { desarrollo: clave },
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
orderBy: { codigo: 'asc' },
})
return filas.filter((u) => esDisponible(u.status))
}
export async function listarDesarrollosPublicos() {
const [desarrollos, unidades] = await Promise.all([
prisma.desarrollo.findMany({ where: DONDE_PUBLICO, include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
prisma.unidad.findMany({
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
orderBy: { codigo: 'asc' },
}),
])
return {
desarrollos: desarrollos.map((d) =>
fichaPublica(
d,
unidades
.filter((u) => normalizarClave(u.desarrollo) === normalizarClave(d.clave) && esDisponible(u.status))
.map((u) => ({ codigo: u.codigo, modelo: u.modelo, nivel: u.nivel, precioLista: u.precioLista, precioAcabados: u.precioAcabados, supConstruccion: u.supConstruccion })),
),
),
}
}
export async function obtenerDesarrolloPublico(claveCruda: string) {
const clave = normalizarClave(claveCruda)
const fila = await prisma.desarrollo.findFirst({ where: { clave, ...DONDE_PUBLICO }, include: INCLUDE_FICHA })
if (fila === null) throw new NotFoundError('Desarrollo no encontrado')
const unidades = await unidadesPublicas(fila.clave)
return fichaPublica(fila, unidades)
}