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).
755 lines
29 KiB
TypeScript
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)
|
|
}
|