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:
754
api-gateway/src/services/desarrollos.service.ts
Normal file
754
api-gateway/src/services/desarrollos.service.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
import { z } from 'zod'
|
||||
import { prisma } from './prisma.js'
|
||||
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
|
||||
|
||||
/**
|
||||
* Módulo Desarrollos: la BD es la fuente de verdad del catálogo de
|
||||
* desarrollos (ficha comercial, modelos de unidad y reglas del cotizador).
|
||||
*
|
||||
* Sustituye a las listas fijas del código (DESARROLLOS de
|
||||
* config/campos-departamento.ts y las constantes de
|
||||
* apps/cotizador/src/data/desarrollos.ts), que hoy siguen funcionando como
|
||||
* espejo hasta que el resto de los frentes migre a esta tabla.
|
||||
*
|
||||
* El inventario sigue viviendo en `Unidad`, ligado por `desarrollo` = `clave`
|
||||
* del desarrollo (el `codigo` de la unidad se forma con esa clave).
|
||||
*/
|
||||
|
||||
/** Estados comerciales del desarrollo */
|
||||
export const ESTADOS_DESARROLLO = ['ACTIVO', 'PREVENTA', 'CERRADO'] as const
|
||||
/** VERTICAL = edificio (deptos); HORIZONTAL = casas en lote */
|
||||
export const TIPOS_DESARROLLO = ['VERTICAL', 'HORIZONTAL'] as const
|
||||
/** Formatos de propuesta del cotizador */
|
||||
export const TIPOS_PROPUESTA = [
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'CONTADO',
|
||||
'SEMI_CONTADO',
|
||||
] as const
|
||||
/** Claves de descuento editables del cotizador */
|
||||
export const CLAVES_DESCUENTO = ['creditoBancario', 'creditoInfonavit', 'contado', 'semiContado'] as const
|
||||
|
||||
const CLAVE_DESARROLLO = /^[A-Z0-9-]{2,20}$/
|
||||
|
||||
// ── Validación de las reglas del cotizador ────────────────────────────
|
||||
|
||||
const fraccion = (campo: string) =>
|
||||
z.number().min(0, `${campo} debe estar entre 0 y 1`).max(1, `${campo} debe estar entre 0 y 1`)
|
||||
|
||||
const propuestaSchema = z
|
||||
.object({
|
||||
tipo: z.enum(TIPOS_PROPUESTA),
|
||||
claveDescuento: z.enum(CLAVES_DESCUENTO),
|
||||
titulo: z.string().trim().min(1),
|
||||
descuentoDefault: fraccion('descuentoDefault'),
|
||||
pctEnganche: fraccion('pctEnganche'),
|
||||
engancheBase: z.enum(['lista', 'final']),
|
||||
pctPagoFinal: fraccion('pctPagoFinal'),
|
||||
pagoFinalBase: z.enum(['lista', 'final']),
|
||||
// 'porcentaje' es el modo de la mayoría de los desarrollos; sin él la
|
||||
// config real del cotizador no valida (el brief lo omitía por error).
|
||||
modoMensualidades: z.enum(['remanente', 'remanenteLista', 'porcentaje', 'ninguna']),
|
||||
// SOLANA Infonavit: si el enganche es 0 y no hay apartado, no se resta
|
||||
engancheCeroSinApartado: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
/**
|
||||
* Forma del JSON de reglas: la config que antes vivía en
|
||||
* apps/cotizador/src/data/desarrollos.ts. `id`/`nombre` se aceptan porque los
|
||||
* objetos del cotizador los traen, pero la fuente de verdad del nombre es la
|
||||
* ficha del desarrollo.
|
||||
*/
|
||||
export const parametrosDesarrolloSchema = z
|
||||
.object({
|
||||
id: z.string().trim().min(1).optional(),
|
||||
nombre: z.string().trim().min(1).optional(),
|
||||
precioUnico: z.boolean(),
|
||||
fuenteDeN: z.enum(['fechaEntrega', 'fijo', 'porUnidad']),
|
||||
// N fijo cuando fuenteDeN === 'fijo' (entrega inmediata)
|
||||
nFijo: z.number().int().min(0).optional(),
|
||||
nDefault: z.number().int().min(0),
|
||||
// null => el cotizador muestra "PENDIENTE" (desarrollos ya entregados)
|
||||
cuotaMantenimiento: z.number().min(0).nullable(),
|
||||
prefijoUnidadPdf: z.string().trim().min(1),
|
||||
propuestas: z.array(propuestaSchema).min(1, 'Se requiere al menos una propuesta'),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ParametrosDesarrolloJson = z.infer<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)
|
||||
}
|
||||
Reference in New Issue
Block a user