Compare commits

..

2 Commits

Author SHA1 Message Date
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
2fc80e7f9e feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol 2026-09-21 08:34:53 +00:00
123 changed files with 13804 additions and 498 deletions

View File

@@ -15,6 +15,7 @@
"seed:ai": "tsx scripts/seed-ai.ts", "seed:ai": "tsx scripts/seed-ai.ts",
"seed:usuarios": "tsx scripts/seed-usuarios.ts", "seed:usuarios": "tsx scripts/seed-usuarios.ts",
"seed:config": "tsx scripts/seed-config.js", "seed:config": "tsx scripts/seed-config.js",
"seed:desarrollos": "tsx scripts/seed-desarrollos.js",
"seed:unidades": "tsx scripts/seed-unidades-locales.js", "seed:unidades": "tsx scripts/seed-unidades-locales.js",
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"seed:catalogos": "node scripts/actualizar-catalogos-ia.js" "seed:catalogos": "node scripts/actualizar-catalogos-ia.js"

View File

@@ -0,0 +1,58 @@
-- Módulo Desarrollos: catálogo de desarrollos (ficha, modelos y reglas del
-- cotizador) más el piso (`nivel`) del inventario.
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
-- CreateTable Desarrollo
CREATE TABLE IF NOT EXISTS "Desarrollo" (
"id" TEXT NOT NULL PRIMARY KEY,
"clave" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"estado" TEXT NOT NULL DEFAULT 'ACTIVO',
"tipo" TEXT NOT NULL DEFAULT 'VERTICAL',
"constructora" TEXT,
"direccion" TEXT,
"zona" TEXT,
"descripcion" TEXT,
"amenidades" TEXT,
"fechaInicioObra" DATETIME,
"fechaEntregaProgramada" DATETIME,
"fechaEntregaReprogramada" DATETIME,
"activoEnCotizador" BOOLEAN NOT NULL DEFAULT true,
"activoEnAlta" BOOLEAN NOT NULL DEFAULT true,
"prefijoUnidadPdf" TEXT,
"imagenUrl" TEXT,
"logoUrl" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "Desarrollo_clave_key" ON "Desarrollo"("clave");
CREATE INDEX IF NOT EXISTS "Desarrollo_estado_idx" ON "Desarrollo"("estado");
-- CreateTable ModeloDesarrollo
CREATE TABLE IF NOT EXISTS "ModeloDesarrollo" (
"id" TEXT NOT NULL PRIMARY KEY,
"desarrolloId" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"recamaras" INTEGER,
"banos" REAL,
"estacionamientos" INTEGER,
"supConstruccion" REAL,
"activo" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "ModeloDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_nombre_key" ON "ModeloDesarrollo"("desarrolloId", "nombre");
CREATE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_idx" ON "ModeloDesarrollo"("desarrolloId");
-- CreateTable ReglaCotizadorDesarrollo
-- `desarrolloId` guarda el uuid de `Desarrollo.id` (lo que escriben el seed y
-- PUT /desarrollos/:id/reglas). El endpoint del cotizador traduce uuid → clave.
CREATE TABLE IF NOT EXISTS "ReglaCotizadorDesarrollo" (
"desarrolloId" TEXT NOT NULL PRIMARY KEY,
"parametrosJson" TEXT NOT NULL,
"updatedAt" DATETIME NOT NULL,
"updatedPorId" TEXT,
CONSTRAINT "ReglaCotizadorDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- AlterTable Unidad: piso del inventario ('' si no aplica)
ALTER TABLE "Unidad" ADD COLUMN "nivel" TEXT NOT NULL DEFAULT '';

View File

@@ -211,6 +211,7 @@ model Unidad {
modelo String @default("") modelo String @default("")
manzana String @default("") manzana String @default("")
lote String @default("") lote String @default("")
nivel String @default("") // piso (desarrollos verticales); '' si no aplica
supConstruccion Float @default(0) supConstruccion Float @default(0)
supExcedente Float @default(0) supExcedente Float @default(0)
supEstacionamiento Float @default(0) supEstacionamiento Float @default(0)
@@ -530,3 +531,61 @@ model SolicitudSoporte {
@@index([usuarioId]) @@index([usuarioId])
@@index([departamento]) @@index([departamento])
} }
// ── Módulo Desarrollos: catálogo de desarrollos nuevos ────────────────
// La BD es la fuente de verdad: la ficha del desarrollo, sus modelos y las
// reglas del cotizador (antes constantes fijas en el código de las apps).
model Desarrollo {
id String @id @default(uuid())
clave String @unique // SENDA, DIAGONAL… (forma los códigos de unidad)
nombre String // nombre comercial
estado String @default("ACTIVO") // ACTIVO | PREVENTA | CERRADO
tipo String @default("VERTICAL") // VERTICAL | HORIZONTAL
constructora String?
direccion String?
zona String?
descripcion String?
amenidades String? // texto libre o lista separada por comas
fechaInicioObra DateTime?
fechaEntregaProgramada DateTime?
fechaEntregaReprogramada DateTime?
activoEnCotizador Boolean @default(true) // aparece en el cotizador
activoEnAlta Boolean @default(true) // aparece en altas de lead/afluencia/cliente
prefijoUnidadPdf String? // p. ej. CASA (lo usa el PDF del cotizador)
imagenUrl String?
logoUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
modelos ModeloDesarrollo[]
reglas ReglaCotizadorDesarrollo?
@@index([estado])
}
model ModeloDesarrollo {
id String @id @default(uuid())
desarrolloId String
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
nombre String
recamaras Int?
banos Float?
estacionamientos Int?
supConstruccion Float?
activo Boolean @default(true)
@@unique([desarrolloId, nombre])
@@index([desarrolloId])
}
model ReglaCotizadorDesarrollo {
// `desarrolloId` guarda el uuid de `Desarrollo.id` (es lo que escriben el seed
// y PUT /desarrollos/:id/reglas). El endpoint que consume el cotizador
// (`/desarrollos/reglas-cotizador`) traduce uuid → clave antes de responder.
desarrolloId String @id
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
parametrosJson String // JSON con la forma ParametrosDesarrollo
updatedAt DateTime @updatedAt
updatedPorId String?
}

View File

@@ -17,8 +17,10 @@
* - Pagos programados: numPagos mensualidades desde MES INICIO * - Pagos programados: numPagos mensualidades desde MES INICIO
* (montoPagado=0; el módulo deriva VENCIDO por fecha). * (montoPagado=0; el módulo deriva VENCIDO por fecha).
* - Celda de grid con valor > 0 = abono real del mes → AplicacionPago * - Celda de grid con valor > 0 = abono real del mes → AplicacionPago
* (fechaPago = mes de la columna) sobre la mensualidad de ese mes; * (fechaPago = mes de la columna) sobre la mensualidad de ese mes; si no
* si no hay mensualidad programada en ese mes → Pago standalone pagado. * hay mensualidad programada en ese mes, o el abono excede lo que falta de
* la mensualidad, el excedente se registra como Pago standalone ya cubierto
* (así ningún abono supera el monto de su pago).
*/ */
import { PrismaClient } from '@prisma/client' import { PrismaClient } from '@prisma/client'
@@ -57,6 +59,7 @@ function norm(h) {
} }
const toStr = (v) => (v == null ? null : String(v).trim() || null) const toStr = (v) => (v == null ? null : String(v).trim() || null)
const round2 = (x) => Math.round(x * 100) / 100
const toFloat = (v) => { const toFloat = (v) => {
if (v == null || v === '') return null if (v == null || v === '') return null
if (typeof v === 'number') return v if (typeof v === 'number') return v
@@ -230,23 +233,27 @@ async function escribir(planesLeidos) {
} }
plan.engancheTotal = pagosProg.reduce((a, p) => a + p.monto, 0) plan.engancheTotal = pagosProg.reduce((a, p) => a + p.monto, 0)
// 2) abonos del grid sobre la mensualidad del mismo mes; sobrantes → standalone // 2) abonos del grid sobre la mensualidad del mismo mes. Un abono nunca
// puede exceder el monto de su pago: lo que sobra (y los meses sin
// mensualidad programada) se registra como pago nuevo ya cubierto, así
// Σ montoPagado == dinero recibido y ningún pago queda sobrepagado.
const aplicaciones = [] // { idxPago, fechaPago, monto } const aplicaciones = [] // { idxPago, fechaPago, monto }
const standalone = [] const standalone = []
for (const ab of abonosGrid) { for (const ab of [...abonosGrid].sort((a, b) => a.fecha - b.fecha)) {
// idx del arreglo == numPago (el inicial ocupa numPago 0 en el índice 0) // idx del arreglo == numPago (el inicial ocupa numPago 0 en el índice 0)
const idx = pagosProg.findIndex((p) => p.numPago >= 1 && claveMes(p.fechaVencimiento) === claveMes(ab.fecha)) const idx = pagosProg.findIndex((p) => p.numPago >= 1 && claveMes(p.fechaVencimiento) === claveMes(ab.fecha))
let restante = ab.monto
if (idx !== -1) { if (idx !== -1) {
aplicaciones.push({ idx, fechaPago: ab.fecha, monto: ab.monto }) const p = pagosProg[idx]
} else { const falta = Math.max(0, round2(p.monto - p.montoPagado))
standalone.push({ fecha: ab.fecha, monto: ab.monto }) const aplica = Math.min(restante, falta)
if (aplica > 0) {
aplicaciones.push({ idx, fechaPago: ab.fecha, monto: round2(aplica) })
p.montoPagado = round2(p.montoPagado + aplica)
restante = round2(restante - aplica)
} }
} }
if (restante > 0) standalone.push({ fecha: ab.fecha, monto: round2(restante) })
// aplicar abonos al montoPagado (sin pasar del monto programado)
for (const ap of aplicaciones) {
const p = pagosProg[ap.idx]
p.montoPagado = Math.min(p.monto, p.montoPagado + ap.monto)
} }
// Invariante del módulo: Σ montos de TODOS los pagos del plan == engancheTotal // Invariante del módulo: Σ montos de TODOS los pagos del plan == engancheTotal

View File

@@ -0,0 +1,157 @@
/**
* Siembra el catálogo `Desarrollo` + `ModeloDesarrollo` + `ReglaCotizadorDesarrollo`
* desde la realidad actual del sistema:
*
* - Desarrollos y modelos: lo que hoy hay en la tabla `Unidad` (espejo del
* inventario), más SOLE y MUTUO, que existen en captura y altas pero todavía
* no tienen inventario local.
* - Reglas del cotizador: copiadas (replicadas, no importadas) de las
* constantes de apps/cotizador/src/data/desarrollos.ts, que es la config con
* la que hoy cotiza producción.
*
* Idempotente: upsert por `clave`. NUNCA pisa lo ya editado: si la ficha o las
* reglas existen, el seed no las toca (el admin manda).
*
* Uso: npm run seed:desarrollos (tsx: la fuente de las reglas es TypeScript)
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
import { PrismaClient } from '@prisma/client'
import { DESARROLLOS as PARAMETROS_COTIZADOR } from '../../apps/cotizador/src/data/desarrollos.ts'
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
const prisma = new PrismaClient()
/**
* Desarrollos sin inventario local que sí existen en el ecosistema (captura y
* altas de lead/afluencia/cliente). Los modelos de SOLE vienen de la lista
* real del alta de clientes (apps/alta-clientes/src/data/constantes.ts);
* MUTUO captura el modelo en texto libre, así que se siembra sin modelos.
*/
const SIN_INVENTARIO = [
{ clave: 'SOLE', nombre: 'Sole', tipo: 'VERTICAL', modelos: ['ALBA', 'HORIZONTE', 'OCASO'] },
{ clave: 'MUTUO', nombre: 'Mutuo', tipo: 'VERTICAL', modelos: [] },
]
const normalizar = (v) => String(v ?? '').trim().toUpperCase()
/** Nombre comercial: el del cotizador si existe; si no, la clave capitalizada */
function nombreComercial(clave) {
const delCotizador = PARAMETROS_COTIZADOR[clave]?.nombre
if (typeof delCotizador === 'string' && delCotizador !== '') return delCotizador
return clave.charAt(0) + clave.slice(1).toLowerCase()
}
async function main() {
const [porDesarrollo, modelosInventario] = await Promise.all([
prisma.unidad.groupBy({ by: ['desarrollo'], _count: { codigo: true } }),
prisma.unidad.groupBy({ by: ['desarrollo', 'modelo'], _count: { codigo: true } }),
])
if (porDesarrollo.length === 0) {
throw new Error('La tabla Unidad está vacía: corre primero `npm run seed:unidades`')
}
const claves = new Set(porDesarrollo.map((d) => normalizar(d.desarrollo)))
for (const extra of SIN_INVENTARIO) claves.add(extra.clave)
const modelosPorClave = new Map()
for (const fila of modelosInventario) {
const clave = normalizar(fila.desarrollo)
const modelo = String(fila.modelo ?? '').trim()
if (modelo === '') continue
if (!modelosPorClave.has(clave)) modelosPorClave.set(clave, new Set())
modelosPorClave.get(clave).add(modelo)
}
for (const extra of SIN_INVENTARIO) {
if (!modelosPorClave.has(extra.clave)) modelosPorClave.set(extra.clave, new Set())
for (const modelo of extra.modelos) modelosPorClave.get(extra.clave).add(modelo)
}
let fichasNuevas = 0
let fichasExistentes = 0
let modelosNuevos = 0
let reglasNuevas = 0
for (const clave of [...claves].sort()) {
const extra = SIN_INVENTARIO.find((e) => e.clave === clave)
const parametros = PARAMETROS_COTIZADOR[clave]
const previa = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
const ficha = await prisma.desarrollo.upsert({
where: { clave },
// No pisa lo ya editado por el admin: si existe, se deja como está
update: {},
create: {
clave,
nombre: extra?.nombre ?? nombreComercial(clave),
// `tipo` no se deduce del inventario actual (los códigos y el prefijo
// del PDF no lo distinguen): queda el default VERTICAL y el admin lo
// ajusta desde el módulo.
tipo: extra?.tipo ?? 'VERTICAL',
prefijoUnidadPdf: typeof parametros?.prefijoUnidadPdf === 'string' ? parametros.prefijoUnidadPdf : null,
},
})
if (previa) fichasExistentes++
else fichasNuevas++
for (const nombre of [...(modelosPorClave.get(clave) ?? [])].sort()) {
const modeloPrevio = await prisma.modeloDesarrollo.findUnique({
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
select: { id: true },
})
await prisma.modeloDesarrollo.upsert({
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
update: {},
create: { desarrolloId: ficha.id, nombre },
})
if (!modeloPrevio) modelosNuevos++
}
if (parametros) {
const reglaPrevia = await prisma.reglaCotizadorDesarrollo.findUnique({
where: { desarrolloId: ficha.id },
select: { desarrolloId: true },
})
if (!reglaPrevia) {
await prisma.reglaCotizadorDesarrollo.create({
data: { desarrolloId: ficha.id, parametrosJson: JSON.stringify(parametros) },
})
reglasNuevas++
}
}
}
const total = await prisma.desarrollo.count()
const conReglas = await prisma.reglaCotizadorDesarrollo.count()
const totalModelos = await prisma.modeloDesarrollo.count()
// eslint-disable-next-line no-console
console.log(
`[seed-desarrollos] fichas: ${fichasNuevas} nueva(s), ${fichasExistentes} ya existían; ` +
`${modelosNuevos} modelo(s) nuevo(s); ${reglasNuevas} regla(s) de cotizador copiada(s)`,
)
// eslint-disable-next-line no-console
console.log(`[seed-desarrollos] total en BD: ${total} desarrollos, ${totalModelos} modelos, ${conReglas} con reglas`)
for (const d of await prisma.desarrollo.findMany({
orderBy: { clave: 'asc' },
include: { modelos: { orderBy: { nombre: 'asc' } }, reglas: { select: { desarrolloId: true } } },
})) {
// eslint-disable-next-line no-console
console.log(
` ${d.clave} (${d.estado}/${d.tipo}) — ${d.modelos.length} modelo(s)` +
`${d.reglas ? '' : ' — SIN reglas de cotizador'}: ${d.modelos.map((m) => m.nombre).join(', ') || '—'}`,
)
}
}
main()
.catch((err) => {
console.error('[seed-desarrollos] error:', err)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { DEPARTAMENTOS, DEPARTAMENTOS_POR_ROL, departamentosDeRol, rolPuedeDepartamento } from './campos-departamento.js'
/**
* Matriz de permisos de Captura (fuente de verdad del backend; el front la
* espeja en apps/landing/src/services/captura.ts para ocultar pestañas).
*/
describe('departamentosDeRol', () => {
it('los roles operativos ven exactamente su departamento', () => {
expect(departamentosDeRol('vendedor')).toEqual(['VENTAS'])
expect(departamentosDeRol('escrituracion')).toEqual(['ESCRITURACION'])
expect(departamentosDeRol('cobranza')).toEqual(['FINANZAS'])
expect(departamentosDeRol('postventa')).toEqual(['POSTVENTA'])
expect(departamentosDeRol('obra')).toEqual(['OBRA'])
})
it('dirección y administración ven todos', () => {
for (const rol of ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta', 'coordinador', 'director']) {
expect(departamentosDeRol(rol)).toEqual([...DEPARTAMENTOS])
}
})
it('tolera mayúsculas y espacios; sin token no restringe', () => {
expect(departamentosDeRol(' Vendedor ')).toEqual(['VENTAS'])
expect(departamentosDeRol(undefined)).toEqual([...DEPARTAMENTOS])
expect(departamentosDeRol('')).toEqual([...DEPARTAMENTOS])
})
it('un rol desconocido cae al fallback seguro (vendedor)', () => {
expect(departamentosDeRol('otro')).toEqual(['VENTAS'])
})
})
describe('rolPuedeDepartamento', () => {
it('cada clave de la matriz ve sus departamentos y ninguno más', () => {
for (const [rol, departamentos] of Object.entries(DEPARTAMENTOS_POR_ROL)) {
for (const departamento of DEPARTAMENTOS) {
expect(rolPuedeDepartamento(rol, departamento)).toBe(
(departamentos as readonly string[]).includes(departamento),
)
}
}
})
it('un departamento inexistente nunca se permite', () => {
expect(rolPuedeDepartamento('admin', 'INVENTADO')).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const
export type Departamento = (typeof DEPARTAMENTOS)[number]
export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: string; label: string; options?: string[]; conFecha?: string }[]> = { export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: string; label: string; options?: string[]; conFecha?: string }[]> = {
OBRA: [ OBRA: [
@@ -59,3 +60,44 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
} }
export const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] as const export const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] as const
// ── Permisos de captura por rol ───────────────────────────────────────
/**
* Departamentos que cada rol puede ver y capturar. Fuente de verdad del
* ecosistema: el front espeja esta misma matriz en services/captura.ts para
* ocultar pestañas, pero quien la hace cumplir es el API.
*
* Dirección y administración ven todos (incluye los roles legacy de la matriz
* de módulos: 'director', 'coordinador'); cada rol operativo sólo el suyo.
*/
export const DEPARTAMENTOS_POR_ROL: Record<string, readonly Departamento[]> = {
admin: DEPARTAMENTOS,
manager: DEPARTAMENTOS,
direccion_general: DEPARTAMENTOS,
director_ventas: DEPARTAMENTOS,
administracion_venta: DEPARTAMENTOS,
director: DEPARTAMENTOS,
coordinador: DEPARTAMENTOS,
vendedor: ['VENTAS'],
escrituracion: ['ESCRITURACION'],
cobranza: ['FINANZAS'],
postventa: ['POSTVENTA'],
obra: ['OBRA'],
}
/**
* Departamentos del rol. Sin token (AUTH_ENABLED=false, modo local) no hay
* restricción; un rol desconocido cae a vendedor, igual que modulosDeRol
* (fallback seguro: es el conjunto más chico).
*/
export function departamentosDeRol(rol: string | undefined): readonly Departamento[] {
const clave = (rol ?? '').trim().toLowerCase()
if (clave === '') return DEPARTAMENTOS
return DEPARTAMENTOS_POR_ROL[clave] ?? DEPARTAMENTOS_POR_ROL['vendedor']!
}
/** ¿El rol puede ver/capturar ese departamento? */
export function rolPuedeDepartamento(rol: string | undefined, departamento: string): boolean {
return (departamentosDeRol(rol) as readonly string[]).includes(departamento)
}

View File

@@ -15,6 +15,7 @@ export const MODULOS = [
'clientes-maestro', 'clientes-maestro',
'captura', 'captura',
'soporte', 'soporte',
'desarrollos',
] as const ] as const
export type Modulo = (typeof MODULOS)[number] export type Modulo = (typeof MODULOS)[number]
@@ -50,7 +51,8 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'], escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'], direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
// manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos // manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos
manager: MODULOS, // ni dar de alta desarrollos (módulo exclusivo del admin)
manager: MODULOS.filter((m) => m !== 'desarrollos'),
admin: MODULOS, admin: MODULOS,
} }

View File

@@ -194,6 +194,111 @@ describe('bulkEndpoint (POST /api/captura/bulk)', () => {
}) })
}) })
describe('permisos por rol (DEPARTAMENTOS_POR_ROL)', () => {
const bulk = (departamento: string, rol: string, campo: string) =>
reqFake({
body: { desarrollo: 'SENDA', departamento, registros: [{ unidad: 'U-101', campo, valor: null }] },
token: { sub: 'u1', rol },
})
it('vendedor: 403 al guardar fuera de VENTAS (POST /captura)', async () => {
await expect(
guardarEndpoint(
reqFake({
body: { desarrollo: 'SENDA', unidadCodigo: 'U-101', departamento: 'OBRA', campo: 'habitabilidad', valor: '2026-09-01' },
token: { sub: 'u1', rol: 'vendedor' },
}),
resFake().res,
),
).rejects.toMatchObject({ status: 403 })
expect(capturaService.guardarCampo).not.toHaveBeenCalled()
})
it('vendedor: 403 en FINANZAS y 204 en VENTAS (POST /captura/bulk)', async () => {
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
await expect(bulkEndpoint(bulk('FINANZAS', 'vendedor', 'bancario'), resFake().res)).rejects.toMatchObject({ status: 403 })
expect(capturaService.guardarBulk).not.toHaveBeenCalled()
const { res, status } = resFake()
await bulkEndpoint(bulk('VENTAS', 'vendedor', 'estatus'), res)
expect(status).toHaveBeenCalledWith(204)
expect(capturaService.guardarBulk).toHaveBeenCalledWith('SENDA', 'VENTAS', [{ unidad: 'U-101', campo: 'estatus', valor: null }], 'u1', 'vendedor')
})
it('cada rol operativo conserva su departamento y sólo ése', async () => {
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
const casos: [string, string, string][] = [
['vendedor', 'VENTAS', 'estatus'],
['escrituracion', 'ESCRITURACION', 'notaria'],
['cobranza', 'FINANZAS', 'bancario'],
['postventa', 'POSTVENTA', 'entrega_sac'],
['obra', 'OBRA', 'comentarios_obra'],
]
for (const [rol, departamento, campo] of casos) {
const { res, status } = resFake()
await bulkEndpoint(bulk(departamento, rol, campo), res)
expect(status).toHaveBeenCalledWith(204)
}
// El mismo rol no puede escribir en el departamento de otro
const { res } = resFake()
await expect(bulkEndpoint(bulk('VENTAS', 'obra', 'estatus'), res)).rejects.toMatchObject({ status: 403 })
})
it('admin, manager, dirección y administración de venta pueden con todos los departamentos', async () => {
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
for (const rol of ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta', 'coordinador']) {
await bulkEndpoint(bulk('OBRA', rol, 'comentarios_obra'), resFake().res)
}
expect(capturaService.guardarBulk).toHaveBeenCalledTimes(6)
})
it('sin token (AUTH_ENABLED=false, modo local) no se restringe', async () => {
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
const { res, status } = resFake()
await bulkEndpoint(
reqFake({ body: { desarrollo: 'SENDA', departamento: 'OBRA', registros: [{ unidad: 'U-101', campo: 'comentarios_obra', valor: 'ok' }] } }),
res,
)
expect(status).toHaveBeenCalledWith(204)
})
it('GET: 403 si el rol pide un departamento que no le toca', async () => {
await expect(
listarEndpoint(reqFake({ query: { desarrollo: 'SENDA', departamento: 'OBRA' }, token: { sub: 'u1', rol: 'vendedor' } }), resFake().res),
).rejects.toMatchObject({ status: 403 })
expect(capturaService.obtenerCapturas).not.toHaveBeenCalled()
})
it('GET: filtra los campos de los demás departamentos', async () => {
vi.mocked(capturaService.obtenerCapturas).mockResolvedValue({
'U-101': { estatus: 'VENDIDO', comentarios_obra: 'en proceso' },
'U-102': { comentarios_obra: 'sin ventas' },
})
const { res, json } = resFake()
await listarEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'vendedor' } }), res)
expect(json).toHaveBeenCalledWith({ 'U-101': { estatus: 'VENDIDO' } })
})
it('GET /resumen: sólo devuelve los departamentos del rol', async () => {
vi.mocked(capturaService.resumenAvance).mockResolvedValue([
{ departamento: 'OBRA', camposTotales: 14, camposLlenos: 2, porcentaje: 14 },
{ departamento: 'VENTAS', camposTotales: 10, camposLlenos: 5, porcentaje: 50 },
])
const { res, json } = resFake()
await resumenEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'vendedor' } }), res)
expect(json).toHaveBeenCalledWith([{ departamento: 'VENTAS', camposTotales: 10, camposLlenos: 5, porcentaje: 50 }])
})
})
// ── importarEndpoint ──────────────────────────────────────────────── // ── importarEndpoint ────────────────────────────────────────────────
describe('importarEndpoint (POST /api/captura/importar)', () => { describe('importarEndpoint (POST /api/captura/importar)', () => {

View File

@@ -1,10 +1,52 @@
import type { NextFunction, Request, Response } from 'express' import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import * as capturaService from '../services/captura-departamento.service.js' import * as capturaService from '../services/captura-departamento.service.js'
import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js' import {
CAMPOS_POR_DEPARTAMENTO,
DEPARTAMENTOS,
departamentosDeRol,
rolPuedeDepartamento,
} from '../config/campos-departamento.js'
import { AppError, BadRequestError } from '../utils/errors.js' import { AppError, BadRequestError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js' import { esAdminOManager } from '../utils/roles.js'
// ── Permisos por rol ─────────────────────────────────────────────────
/**
* El rol debe tener permitido el departamento (matriz DEPARTAMENTOS_POR_ROL):
* un vendedor sólo captura VENTAS aunque el front la oculte; sin esta guarda
* un token de vendedor podía escribir en OBRA o FINANZAS.
*/
function requireDepartamentoPermitido(rol: string | undefined, departamento: string): void {
if (!rolPuedeDepartamento(rol, departamento)) {
throw new AppError(403, `Tu rol no tiene acceso al departamento ${departamento}`)
}
}
/** Campos visibles para el rol (unión de los campos de sus departamentos) */
function camposPermitidos(rol: string | undefined): Set<string> {
const campos = departamentosDeRol(rol).flatMap((d) => (CAMPOS_POR_DEPARTAMENTO[d] ?? []).map((c) => c.campo))
return new Set(campos)
}
/**
* La lectura también respeta la matriz: con un token de rol limitado sólo se
* devuelven los campos de sus departamentos (el front ya filtra las pestañas,
* esto evita que los datos lleguen al cliente).
*/
function filtrarPorRol(
datos: Record<string, Record<string, string | null>>,
rol: string | undefined,
): Record<string, Record<string, string | null>> {
const permitidos = camposPermitidos(rol)
const salida: Record<string, Record<string, string | null>> = {}
for (const [unidad, campos] of Object.entries(datos)) {
const visibles = Object.fromEntries(Object.entries(campos).filter(([campo]) => permitidos.has(campo)))
if (Object.keys(visibles).length > 0) salida[unidad] = visibles
}
return salida
}
// ── Validación de campos por departamento ──────────────────────────── // ── Validación de campos por departamento ────────────────────────────
/** /**
@@ -44,8 +86,15 @@ const listarSchema = z.object({
export async function listarEndpoint(req: Request, res: Response): Promise<void> { export async function listarEndpoint(req: Request, res: Response): Promise<void> {
const query = listarSchema.parse(req.query) const query = listarSchema.parse(req.query)
const rol = req.usuarioToken?.rol
if (query.departamento !== undefined) {
if (!DEPARTAMENTOS.includes(query.departamento as (typeof DEPARTAMENTOS)[number])) {
throw new BadRequestError(`Departamento inválido: ${query.departamento}`)
}
requireDepartamentoPermitido(rol, query.departamento)
}
const capturas = await capturaService.obtenerCapturas(query.desarrollo, query.departamento) const capturas = await capturaService.obtenerCapturas(query.desarrollo, query.departamento)
res.json(capturas) res.json(filtrarPorRol(capturas, rol))
} }
// ── GET /api/captura/unidades?desarrollo=X ─────────────────────────── // ── GET /api/captura/unidades?desarrollo=X ───────────────────────────
@@ -69,7 +118,8 @@ const resumenSchema = z.object({
export async function resumenEndpoint(req: Request, res: Response): Promise<void> { export async function resumenEndpoint(req: Request, res: Response): Promise<void> {
const query = resumenSchema.parse(req.query) const query = resumenSchema.parse(req.query)
const resumen = await capturaService.resumenAvance(query.desarrollo) const resumen = await capturaService.resumenAvance(query.desarrollo)
res.json(resumen) const rol = req.usuarioToken?.rol
res.json(resumen.filter((r) => rolPuedeDepartamento(rol, r.departamento)))
} }
// ── POST /api/captura — guardar campo individual ───────────────────── // ── POST /api/captura — guardar campo individual ─────────────────────
@@ -89,6 +139,7 @@ export async function guardarEndpoint(req: Request, res: Response): Promise<void
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) { if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`) throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
} }
requireDepartamentoPermitido(req.usuarioToken?.rol, body.departamento)
validarCamposDelDepartamento(body.departamento, [{ unidad: body.unidadCodigo, campo: body.campo, valor: body.valor ?? null }]) validarCamposDelDepartamento(body.departamento, [{ unidad: body.unidadCodigo, campo: body.campo, valor: body.valor ?? null }])
await capturaService.guardarCampo( await capturaService.guardarCampo(
@@ -124,6 +175,7 @@ export async function bulkEndpoint(req: Request, res: Response): Promise<void> {
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) { if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`) throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
} }
requireDepartamentoPermitido(req.usuarioToken?.rol, body.departamento)
validarCamposDelDepartamento( validarCamposDelDepartamento(
body.departamento, body.departamento,
body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })), body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })),

View File

@@ -6,6 +6,7 @@ const servicioMock = vi.hoisted(() => ({
listar: vi.fn(), listar: vi.fn(),
obtener: vi.fn(), obtener: vi.fn(),
contar: vi.fn(), contar: vi.fn(),
catalogos: vi.fn(),
exportar: vi.fn(), exportar: vi.fn(),
importarDesdeExcel: vi.fn(), importarDesdeExcel: vi.fn(),
})) }))
@@ -14,6 +15,7 @@ vi.mock('../services/cliente-maestro.service.js', () => servicioMock)
import { import {
listarEndpoint, listarEndpoint,
catalogosEndpoint,
exportarEndpoint, exportarEndpoint,
obtenerEndpoint, obtenerEndpoint,
importarEndpoint, importarEndpoint,
@@ -77,6 +79,23 @@ describe('listarEndpoint', () => {
}) })
}) })
// ── GET /api/clientes-maestro/catalogos ──────────────────────────────
describe('catalogosEndpoint', () => {
it('200 con desarrollos y estatus reales', async () => {
servicioMock.catalogos.mockResolvedValue({
desarrollos: ['MUTUO - EDIFICACION', 'SOLE'],
estatuses: ['ENTREGA'],
})
const { res, json } = resFake()
await catalogosEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith({
desarrollos: ['MUTUO - EDIFICACION', 'SOLE'],
estatuses: ['ENTREGA'],
})
})
})
// ── GET /api/clientes-maestro/exportar ─────────────────────────────── // ── GET /api/clientes-maestro/exportar ───────────────────────────────
describe('exportarEndpoint', () => { describe('exportarEndpoint', () => {

View File

@@ -22,6 +22,12 @@ export async function listarEndpoint(req: Request, res: Response): Promise<void>
res.json(resultado) res.json(resultado)
} }
// ── GET /api/clientes-maestro/catalogos ──────────────────────────────
export async function catalogosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await clienteMaestroService.catalogos())
}
// ── GET /api/clientes-maestro/exportar ─────────────────────────────── // ── GET /api/clientes-maestro/exportar ───────────────────────────────
export async function exportarEndpoint(req: Request, res: Response): Promise<void> { export async function exportarEndpoint(req: Request, res: Response): Promise<void> {

View File

@@ -7,6 +7,9 @@ const prismaMock = vi.hoisted(() => ({
unidad: { unidad: {
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
capturaDepartamento: {
findMany: vi.fn(),
},
leadCaptura: { leadCaptura: {
findFirst: vi.fn(), findFirst: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
@@ -30,12 +33,17 @@ const prismaMock = vi.hoisted(() => ({
auditoriaLog: { auditoriaLog: {
create: vi.fn().mockResolvedValue({}), create: vi.fn().mockResolvedValue({}),
}, },
// Reglas comerciales configurables por el admin (vacías = constantes)
reglaCotizadorDesarrollo: {
findMany: vi.fn(),
},
$transaction: vi.fn(), $transaction: vi.fn(),
})) }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js' import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
import { DESARROLLOS } from '../services/cotizacion.service.js'
/** /**
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la * Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
@@ -77,6 +85,7 @@ function resFake() {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad) prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findFirst.mockResolvedValue(null) prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(null) prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
@@ -87,6 +96,7 @@ beforeEach(() => {
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data })) prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({}) prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops)) prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
}) })
@@ -184,6 +194,150 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled() expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
}) })
it('toma la fecha de entrega de la captura: mensualidad y total ya no salen nulos', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
])
const { res, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
const respuesta = json.mock.calls[0]?.[0] as {
numMensualidades: number
mensualidad: number
propuesta: { numMensualidades: number; mensualidad: number; mensualidadesTotal: number }
}
expect(respuesta.numMensualidades).toBeGreaterThan(0)
expect(respuesta.numMensualidades).toBe(respuesta.propuesta.numMensualidades)
expect(respuesta.propuesta.mensualidad).toBeGreaterThan(0)
expect(respuesta.propuesta.mensualidadesTotal).toBeGreaterThan(0)
})
it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res)
// DIAGONAL bancario sin acabados: remanente 75,000 en 12 pagos
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }),
})
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; propuesta: { numMensualidades: number } }
expect(respuesta.numMensualidades).toBe(12)
expect(respuesta.propuesta.numMensualidades).toBe(12)
})
it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => {
prismaMock.unidad.findUnique.mockResolvedValue({ ...filaUnidad, codigo: 'ATELIER-10', status: 'VENDIDO' })
const { res } = resFake()
await expect(crearCotizacion(reqFake({ ...bodyBase, unidadCodigo: 'ATELIER-10' }), res)).rejects.toMatchObject({
status: 409,
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
})
describe('crearCotizacion — reglas comerciales del admin', () => {
/** Reglas guardadas del desarrollo, con los ajustes del test */
function reglasGuardadas(
desarrollo: string,
ajustes: { propuestaInactiva?: string; activoEnCotizador?: boolean; descuentoContado?: number } = {},
) {
const base = DESARROLLOS[desarrollo]!
return [
{
desarrolloId: desarrollo,
parametrosJson: JSON.stringify({
...base,
...(ajustes.activoEnCotizador !== undefined
? { activoEnCotizador: ajustes.activoEnCotizador }
: {}),
propuestas: base.propuestas.map((p) => ({
...p,
activa: p.tipo !== ajustes.propuestaInactiva,
...(p.tipo === 'CONTADO' && ajustes.descuentoContado !== undefined
? { descuentoDefault: ajustes.descuentoContado }
: {}),
})),
}),
},
]
}
it('sin reglas guardadas cotiza igual que siempre (respaldo en constantes)', async () => {
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0 }),
})
})
it('usa el descuento configurado por el admin', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
)
const { res, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0.1, precioFinal: 900_000 }),
})
})
it('propuesta desactivada → 409 con el mensaje del servidor y sin persistir', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
)
const { res } = resFake()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
status: 409,
message: expect.stringContaining('La propuesta BANCARIO no está disponible'),
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
// Las otras propuestas siguen cotizándose
const { res: res2, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res2)
expect(status).toHaveBeenCalledWith(201)
})
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { activoEnCotizador: false }),
)
const { res } = resFake()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
status: 409,
message: expect.stringContaining('no está disponible en el cotizador'),
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('JSON corrupto en la BD: cotiza con las constantes (un warning, sin fallar)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: '{corrupto' },
])
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0, precioLista: 1_000_000 }),
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
warn.mockRestore()
})
}) })
describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => { describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => {

View File

@@ -1,7 +1,13 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import { getUnidadLocal } from '../services/unidades-local.service.js' import { getUnidadLocal } from '../services/unidades-local.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js' import {
calcularPropuesta,
obtenerPrecioLista,
propuestasVigentes,
TIPO_PROPUESTA_API,
} from '../services/cotizacion.service.js'
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
import { import {
procesarAlertasInventario, procesarAlertasInventario,
type ResultadoAlertas, type ResultadoAlertas,
@@ -9,7 +15,7 @@ import {
import { prisma } from '../services/prisma.js' import { prisma } from '../services/prisma.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import { actividadLocalService } from '../services/actividad-local.service.js' import { actividadLocalService } from '../services/actividad-local.service.js'
import { NotFoundError } from '../utils/errors.js' import { NotFoundError, AppError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js' import * as auditoria from '../services/auditoria.service.js'
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js' import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
@@ -24,13 +30,28 @@ const crearCotizacionSchema = z.object({
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']), tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%) descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a // Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
// esquemas con crédito (bancario/Infonavit) // esquemas con crédito (bancario/Infonavit). El Excel permite 2.5%–5%.
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(), enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
// Mensualidades elegidas por el asesor; sin él se resuelve con la fecha de
// entrega de la unidad (o el plazo por defecto del desarrollo)
numMensualidades: z.number().int().min(1).max(120).optional(),
// Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi // Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi
// día manda el id; sin él se busca por teléfono) // día manda el id; sin él se busca por teléfono)
oportunidadId: z.string().trim().min(1).optional(), oportunidadId: z.string().trim().min(1).optional(),
}) })
/** Unidad cotizable: solo inmuebles DISPONIBLES (apartar/vender no se puede cotizar dos veces) */
function validarUnidadDisponible(unidad: { codigo: string; status: string }): void {
const status = unidad.status.trim().toUpperCase()
if (status !== 'DISPONIBLE') {
throw new AppError(
409,
`La unidad ${unidad.codigo} no está disponible (estatus: ${unidad.status.trim() || 'desconocido'}). ` +
'Elige otra unidad del inventario disponible.',
)
}
}
/** /**
* Oportunidad local que tenga ese teléfono (match de cotizaciones). Mismo * Oportunidad local que tenga ese teléfono (match de cotizaciones). Mismo
* criterio que el funnel: primero el mapping histórico (lead → su * criterio que el funnel: primero el mapping histórico (lead → su
@@ -57,9 +78,39 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
// Inventario: SIEMPRE el espejo local (tabla Unidad); 404 si no existe // Inventario: SIEMPRE el espejo local (tabla Unidad); 404 si no existe
const unidad = await getUnidadLocal(body.unidadCodigo) const unidad = await getUnidadLocal(body.unidadCodigo)
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`) if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
// Solo se cotiza inventario disponible: evita cotizar (y apartar) dos veces
// el mismo inmueble ya apartado/vendido
validarUnidadDisponible(unidad)
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
// respaldo en las constantes históricas. Un desarrollo o una propuesta
// desactivados se rechazan con el mensaje que muestra el cotizador.
const reglas = await getReglasDesarrollo(unidad.desarrollo)
if (reglas.activoEnCotizador === false) {
throw new AppError(
409,
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
)
}
const tipoInterno = TIPO_PROPUESTA_API[body.tipoPropuesta]
if (!propuestasVigentes(reglas).some((p) => p.tipo === tipoInterno)) {
throw new AppError(
409,
`La propuesta ${body.tipoPropuesta} no está disponible para el desarrollo ${reglas.id}. Elige otra propuesta.`,
)
}
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento, body.enganchePct) const propuesta = calcularPropuesta(
unidad,
body.tipoPropuesta,
body.acabados,
apartado,
body.descuento,
body.enganchePct,
body.numMensualidades,
reglas,
)
const precioLista = obtenerPrecioLista(unidad, body.acabados) const precioLista = obtenerPrecioLista(unidad, body.acabados)
const cotizacion = await prisma.cotizacion.create({ const cotizacion = await prisma.cotizacion.create({
@@ -77,7 +128,9 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
precioLista, precioLista,
precioFinal: propuesta.precioFinal, precioFinal: propuesta.precioFinal,
enganche: propuesta.enganche, enganche: propuesta.enganche,
numMensualidades: propuesta.numMensualidades ?? 0, // 0 => plazo desconocido (sin fecha de entrega) // 0 => no se pudo resolver el plazo (no debería ocurrir: los desarrollos
// tienen plazo por defecto). Si no, el N elegido por el vendedor.
numMensualidades: propuesta.numMensualidades ?? 0,
mensualidad: propuesta.mensualidad, mensualidad: propuesta.mensualidad,
pagoFinal: propuesta.pagoFinal, pagoFinal: propuesta.pagoFinal,
}, },
@@ -165,6 +218,7 @@ export async function obtenerCotizacionPublica(req: Request, res: Response): Pro
tipoPropuesta: cotizacion.tipoPropuesta, tipoPropuesta: cotizacion.tipoPropuesta,
precioFinal: cotizacion.precioFinal, precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche, enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades,
mensualidad: cotizacion.mensualidad, mensualidad: cotizacion.mensualidad,
pagoFinal: cotizacion.pagoFinal, pagoFinal: cotizacion.pagoFinal,
vigencia: vigenciaCotizacion(cotizacion.createdAt).toISOString(), vigencia: vigenciaCotizacion(cotizacion.createdAt).toISOString(),

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import {
cambiarEtapaOportunidadEndpoint, cambiarEtapaOportunidadEndpoint,
ETAPAS_OPORTUNIDAD_PERMITIDAS, ETAPAS_OPORTUNIDAD_PERMITIDAS,
perderOportunidadEndpoint, perderOportunidadEndpoint,
STATUS_NO_BLOQUEABLES,
} from './oportunidades.controller.js' } from './oportunidades.controller.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ---------- // ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -17,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({
unidad: { unidad: {
updateMany: vi.fn(), updateMany: vi.fn(),
}, },
cotizacion: {
findFirst: vi.fn(),
},
automatizacionState: { automatizacionState: {
findUnique: vi.fn(), findUnique: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
@@ -53,6 +57,7 @@ beforeEach(() => {
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data })) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 }) prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 })
prismaMock.cotizacion.findFirst.mockResolvedValue(null)
prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({}) prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops)) prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
@@ -149,7 +154,7 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', etapa: 'Agendo Cita', fecha: '2026-09-10' }) expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', etapa: 'Agendo Cita', fecha: '2026-09-10' })
}) })
it("'Aparto': crea el apartado local (crmLocal.apartar) y bloquea la unidad del inventario", async () => { it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson y bloquea la unidad del inventario", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op', leadId: 'loc_op',
usuarioId: 'u-1', usuarioId: 'u-1',
@@ -164,22 +169,57 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res) await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
// Oportunidad en Aparto + apartado local en Pago Apartado + cola // Oportunidad en Aparto + apartado local en Pago Apartado con la unidad + cola
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({ expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_op' }, where: { leadId: 'loc_op' },
data: { etapa: 'Aparto', pendienteSync: true }, data: { etapa: 'Aparto', pendienteSync: true },
}) })
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }), data: expect.objectContaining({
board: 'APARTADO',
etapa: 'Pago Apartado',
nombreLead: 'Luis Demo',
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
}),
}) })
// La unidad asignada queda APARTADA en el espejo local // La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-101' }, where: { codigo: 'U-101', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
data: { status: 'APARTADA' }, data: { status: 'APARTADO' },
}) })
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' }) expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
}) })
it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op',
usuarioId: 'u-1',
nombreLead: 'Luis Demo',
telefono: null,
email: null,
desarrollo: 'SENDA',
modelo: null,
datosJson: null,
})
prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' })
const { res } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
expect(prismaMock.cotizacion.findFirst).toHaveBeenCalledWith({
where: { mondayItemId: 'loc_op' },
orderBy: { createdAt: 'desc' },
select: { unidadCodigo: true },
})
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ datosJson: JSON.stringify({ unidadCodigo: 'SENDA-411-1-7' }) }),
})
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
data: { status: 'APARTADO' },
})
})
it("'Aparto' sin unidad asignada no toca el inventario", async () => { it("'Aparto' sin unidad asignada no toca el inventario", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op', leadId: 'loc_op',

View File

@@ -52,9 +52,40 @@ const cambiarEtapaSchema = z.object({
fecha: z.string().optional(), fecha: z.string().optional(),
}) })
/**
* Estatus de inventario que un apartado nunca debe pisar: la unidad ya se
* vendió, se escrituró o no está a la venta (BLOQUEADO).
*/
export const STATUS_NO_BLOQUEABLES = ['VENDIDO', 'ESCRITURADO', 'BLOQUEADO']
/**
* Unidad asignada a la oportunidad, leída de su `datosJson` (la deja el alta de
* clientes / la afluencia) o, si no está ahí, de la cotización más reciente
* ligada a ella (`Cotizacion.mondayItemId`): el vendedor cotiza y después
* aparta, así que la unidad cotizada ES la que se está apartando.
*/
async function unidadDeOportunidad(itemId: string, datosJson: string | null): Promise<string | null> {
if (datosJson) {
try {
const datos = JSON.parse(datosJson) as Record<string, unknown>
if (typeof datos['unidadCodigo'] === 'string' && datos['unidadCodigo'].trim() !== '') {
return datos['unidadCodigo'].trim()
}
} catch { /* datosJson inválido: se ignora */ }
}
const cotizacion = await prisma.cotizacion.findFirst({
where: { mondayItemId: itemId },
orderBy: { createdAt: 'desc' },
select: { unidadCodigo: true },
})
const codigo = cotizacion?.unidadCodigo.trim()
return codigo !== undefined && codigo !== '' ? codigo : null
}
/** /**
* Cambio de etapa local: guarda la etapa en la fila local y encola * Cambio de etapa local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar). * CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar),
* dejando la unidad apartada en su `datosJson`.
* Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad). * Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad).
*/ */
async function cambiarEtapaLocal( async function cambiarEtapaLocal(
@@ -64,17 +95,12 @@ async function cambiarEtapaLocal(
usuarioId: string | undefined, usuarioId: string | undefined,
): Promise<string | null> { ): Promise<string | null> {
if (etapa === 'Aparto') { if (etapa === 'Aparto') {
await crmLocal.apartar(itemId, usuarioId)
// Leer la oportunidad local para obtener el unidadCodigo y bloquear la unidad
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
let unidadCodigo = '' const unidadCodigo = await unidadDeOportunidad(itemId, captura?.datosJson ?? null)
if (captura?.datosJson) { await crmLocal.apartar(itemId, usuarioId, {
try { ...(unidadCodigo !== null ? { unidadCodigo } : {}),
const datos = JSON.parse(captura.datosJson) as Record<string, unknown> })
if (typeof datos['unidadCodigo'] === 'string') unidadCodigo = datos['unidadCodigo'] return unidadCodigo
} catch { /* datosJson inválido: se ignora */ }
}
return unidadCodigo.trim() !== '' ? unidadCodigo.trim() : null
} }
await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', { await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', {
fecha, fecha,
@@ -89,10 +115,15 @@ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Respons
const body = cambiarEtapaSchema.parse(req.body) const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId']) const itemId = String(req.params['itemId'])
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub) const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub)
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada // Bloquear la unidad del inventario local si la oportunidad tiene una asignada.
// Nunca se degrada una unidad ya VENDIDA/ESCRITURADA/BLOQUEADA: el apartado
// solo bloquea inventario vendible.
if (body.etapa === 'Aparto' && unidadCodigo !== null) { if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try { try {
await prisma.unidad.updateMany({ where: { codigo: unidadCodigo }, data: { status: 'APARTADA' } }) await prisma.unidad.updateMany({
where: { codigo: unidadCodigo, status: { notIn: STATUS_NO_BLOQUEABLES } },
data: { status: 'APARTADO' },
})
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err) console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { resolverFiltroMios } from './planes.controller.js' import type { Request, Response } from 'express'
import { ROLES_EDITAN_PLAN, requireEditaPlan, resolverFiltroMios } from './planes.controller.js'
describe('resolverFiltroMios (?mios=1)', () => { describe('resolverFiltroMios (?mios=1)', () => {
it('sin ?mios=1 no filtra nada', () => { it('sin ?mios=1 no filtra nada', () => {
@@ -20,3 +21,35 @@ describe('resolverFiltroMios (?mios=1)', () => {
expect(resolverFiltroMios('1', undefined)).toBeUndefined() expect(resolverFiltroMios('1', undefined)).toBeUndefined()
}) })
}) })
const reqCon = (rol?: string): Request =>
({
body: {},
params: { id: 'plan-1' },
...(rol ? { usuarioToken: { sub: 'u-1', username: 'x', rol } } : {}),
}) as unknown as Request
describe('requireEditaPlan (PATCH /planes/:id)', () => {
it('admin, manager, dirección y sus roles legacy pasan', () => {
for (const rol of ROLES_EDITAN_PLAN) {
const next = vi.fn()
requireEditaPlan(reqCon(rol), {} as Response, next)
expect(next).toHaveBeenCalled()
}
expect(ROLES_EDITAN_PLAN).toEqual(
expect.arrayContaining(['admin', 'manager', 'direccion_general', 'director_ventas']),
)
})
it('los roles de cobranza que no son dirección quedan fuera', () => {
for (const rol of ['vendedor', 'administracion_venta', 'escrituracion']) {
expect(() => requireEditaPlan(reqCon(rol), {} as Response, vi.fn())).toThrow(
'Solo admin, manager o dirección pueden modificar el plan',
)
}
})
it('sin token también rechaza (403)', () => {
expect(() => requireEditaPlan(reqCon(), {} as Response, vi.fn())).toThrow()
})
})

View File

@@ -1,4 +1,4 @@
import type { Request, Response } from 'express' import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import { prisma } from '../services/prisma.js' import { prisma } from '../services/prisma.js'
import { import {
@@ -18,7 +18,7 @@ import {
validarAbono, validarAbono,
type PlanPago as PlanPagoDominio, type PlanPago as PlanPagoDominio,
} from '../services/cobranza.service.js' } from '../services/cobranza.service.js'
import { BadRequestError, NotFoundError } from '../utils/errors.js' import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client' import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ---------- // ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
@@ -319,3 +319,48 @@ export async function cancelarPlan(req: Request, res: Response): Promise<void> {
const actualizado = await cargarPlan(plan.id) const actualizado = await cargarPlan(plan.id)
res.json(conCalculados(aDominio(actualizado))) res.json(conCalculados(aDominio(actualizado)))
} }
// ── Edición de parámetros del plan (tasa de mora) ────────────────────
/** Roles que pueden editar el plan: admin, manager y dirección (y sus legacy) */
export const ROLES_EDITAN_PLAN: readonly string[] = [
'admin',
'manager',
'direccion_general',
'director_ventas',
// Legacy: la matriz de roles los homologa a dirección de ventas
'coordinador',
'corporativo',
]
/** Middleware: sin rol autorizado no se edita el plan (403) */
export function requireEditaPlan(req: Request, _res: Response, next: NextFunction): void {
const rol = req.usuarioToken?.rol
if (!rol || !ROLES_EDITAN_PLAN.includes(rol)) {
throw new AppError(403, 'Solo admin, manager o dirección pueden modificar el plan')
}
next()
}
const actualizarPlanSchema = z.object({
// Tasa mensual en % sobre el restante de cada pago vencido (0 = sin mora)
tasaMoraMensual: z
.number()
.min(0, 'La tasa de mora no puede ser negativa')
.max(100, 'La tasa de mora mensual no puede exceder 100%'),
})
/**
* PATCH /api/planes/:id — actualiza la tasa de mora mensual del plan.
* (El calendario de pagos no se edita: cambiarlo requiere recrear el plan.)
*/
export async function actualizarPlan(req: Request, res: Response): Promise<void> {
const body = actualizarPlanSchema.parse(req.body)
const plan = await cargarPlan(String(req.params['id']))
const actualizado = await prisma.planPago.update({
where: { id: plan.id },
data: { tasaMoraMensual: body.tasaMoraMensual },
include: includeCompleto,
})
res.json(conCalculados(aDominio(actualizado)))
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { exigirRolCobranza } from './recordatorios.controller.js'
describe('exigirRolCobranza (motor de recordatorios)', () => {
it('permite los roles con el módulo de cobranza', () => {
for (const rol of [
'admin',
'manager',
'administracion_venta',
'direccion_general',
'director_ventas',
'escrituracion',
'coordinador', // legacy → director_ventas
'corporativo', // legacy → director_ventas
]) {
expect(() => exigirRolCobranza(rol)).not.toThrow()
}
})
it('rechaza al vendedor (no tiene el módulo) y a "cobranza", que es módulo y no rol', () => {
expect(() => exigirRolCobranza('vendedor')).toThrow('Tu rol no tiene acceso a la cobranza')
expect(() => exigirRolCobranza('cobranza')).toThrow('Tu rol no tiene acceso a la cobranza')
})
it('sin token (AUTH_ENABLED=false) no bloquea el modo sin autenticación', () => {
expect(() => exigirRolCobranza(undefined)).not.toThrow()
})
})

View File

@@ -3,11 +3,35 @@ import { prisma } from '../services/prisma.js'
import { recordatoriosCobranzaService } from '../services/recordatorios-cobranza.service.js' import { recordatoriosCobranzaService } from '../services/recordatorios-cobranza.service.js'
import { AppError } from '../utils/errors.js' import { AppError } from '../utils/errors.js'
// Roles que pueden disparar una pasada manual del motor de recordatorios // Roles del ecosistema cuyo perfil incluye el módulo de cobranza (matriz de
const ROLES_RUN: readonly string[] = ['admin', 'cobranza'] // config/roles.ts): los que pueden ver y disparar el motor de recordatorios.
// 'cobranza' es un MÓDULO, no un rol: no existe como rol de usuario.
const ROLES_COBRANZA: readonly string[] = [
'admin',
'manager',
'administracion_venta',
'direccion_general',
'director_ventas',
'escrituracion',
// Roles legacy (se homologan a director_ventas en la matriz)
'coordinador',
'corporativo',
]
/**
* Exige rol con cobranza. Sin token (AUTH_ENABLED=false) se permite, igual que
* el resto de la API en ese modo.
*/
export function exigirRolCobranza(rol: string | undefined): void {
if (!rol) return
if (!ROLES_COBRANZA.includes(rol)) {
throw new AppError(403, 'Tu rol no tiene acceso a la cobranza')
}
}
/** GET /api/cobranza/recordatorios — historial del log (más recientes primero) */ /** GET /api/cobranza/recordatorios — historial del log (más recientes primero) */
export async function listarRecordatorios(req: Request, res: Response): Promise<void> { export async function listarRecordatorios(req: Request, res: Response): Promise<void> {
exigirRolCobranza(req.usuarioToken?.rol)
const limite = Math.min(Number(req.query['limite'] ?? 200) || 200, 500) const limite = Math.min(Number(req.query['limite'] ?? 200) || 200, 500)
const registros = await prisma.recordatorioLog.findMany({ const registros = await prisma.recordatorioLog.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
@@ -17,15 +41,12 @@ export async function listarRecordatorios(req: Request, res: Response): Promise<
} }
/** /**
* POST /api/cobranza/recordatorios/run — pasada manual del motor (admin/cobranza). * POST /api/cobranza/recordatorios/run — pasada manual del motor (roles con
* Con ?dry=1 solo lista lo que enviaría, sin mandar nada ni escribir en el log. * cobranza). Con ?dry=1 solo lista lo que enviaría, sin mandar nada ni escribir
* en el log.
*/ */
export async function correrRecordatorios(req: Request, res: Response): Promise<void> { export async function correrRecordatorios(req: Request, res: Response): Promise<void> {
const token = req.usuarioToken exigirRolCobranza(req.usuarioToken?.rol)
// Sin token (AUTH_ENABLED=false) se permite; con token se exige rol admin/cobranza
if (token && !ROLES_RUN.includes(token.rol)) {
throw new AppError(403, 'Solo admin o cobranza pueden ejecutar el motor de recordatorios')
}
const dryRun = req.query['dry'] === '1' const dryRun = req.query['dry'] === '1'
res.json(await recordatoriosCobranzaService.ejecutarPasada({ dryRun })) res.json(await recordatoriosCobranzaService.ejecutarPasada({ dryRun }))
} }

View File

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

View File

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

View File

@@ -0,0 +1,478 @@
import { Readable } from 'node:stream'
import fs from 'node:fs'
import path from 'node:path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// UPLOADS_DIR propio del test: se fija antes de que config lo lea (dotenv no
// pisa una variable ya definida), así la carpeta queda aislada del repo.
const uploadsDir = vi.hoisted(() => {
const dir = `${process.env['TMPDIR'] ?? '/tmp'}/frasa-soporte-test-${Date.now()}`
process.env['UPLOADS_DIR'] = dir
return dir
})
const prismaMock = vi.hoisted(() => ({
solicitudSoporte: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
}))
const auditoriaMock = vi.hoisted(() => ({ registrar: vi.fn() }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/auditoria.service.js', () => auditoriaMock)
import { config } from '../config/index.js'
import {
cambiarEstadoSolicitudEndpoint,
crearSolicitudSoporteEndpoint,
descargarArchivoSoporteEndpoint,
listarSolicitudesSoporteEndpoint,
requireAdminOManager,
uploadArchivosSoporte,
} from './soporte.controller.js'
// ── Helpers ──────────────────────────────────────────────────────────
type ReqConId = Request & { solicitudSoporteId?: string }
function reqFake(overrides: Partial<ReqConId> = {}): ReqConId {
return {
body: {},
params: {},
query: {},
usuarioToken: { sub: 'u-1', username: 'vendedor', rol: 'vendedor' },
...overrides,
} as ReqConId
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
const setHeader = vi.fn()
const sendFile = vi.fn()
return {
res: { json, status, setHeader, sendFile } as unknown as Response,
status,
json,
setHeader,
sendFile,
}
}
function filaFake(overrides: Record<string, unknown> = {}) {
return {
id: 'sol-1',
usuarioId: 'u-1',
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
responsable: null,
fechaSolicitud: new Date('2026-09-21T00:00:00.000Z'),
fechaLimite: null,
notas: null,
archivos: null,
solicitante: 'Vendedor',
solicitanteEmail: null,
estado: 'ABIERTA',
createdAt: new Date('2026-09-21T00:00:00.000Z'),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...overrides,
}
}
/** Request multipart real (stream) para ejercitar el middleware de multer */
function reqMultipart(partes: Array<{ name: string; filename?: string; tipo: string; contenido: string }>): ReqConId {
const boundary = '----frasaTestBoundary'
const trozos = partes.map((p) => {
const disposicion = p.filename
? `Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n`
: `Content-Disposition: form-data; name="${p.name}"\r\n`
return Buffer.from(
`--${boundary}\r\n${disposicion}Content-Type: ${p.tipo}\r\n\r\n${p.contenido}\r\n`,
'binary',
)
})
const cuerpo = Buffer.concat([...trozos, Buffer.from(`--${boundary}--\r\n`, 'binary')])
const req = Readable.from([cuerpo]) as unknown as ReqConId
// type-is (usado por multer) exige content-type + content-length para
// considerar la petición "multipart con body"
req.headers = {
'content-type': `multipart/form-data; boundary=${boundary}`,
'content-length': String(cuerpo.length),
} as ReqConId['headers']
return req
}
const bodyValido = {
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
fechaSolicitud: '2026-09-21',
email: 'vendedor@frasa.mx',
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.solicitudSoporte.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) =>
filaFake(data),
)
prismaMock.solicitudSoporte.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) =>
filaFake(data),
)
})
afterAll(() => {
fs.rmSync(uploadsDir, { recursive: true, force: true })
})
// ── Subida real de adjuntos (multer + carpeta por solicitud) ──────────
describe('uploadArchivosSoporte', () => {
function subir(req: ReqConId): Promise<Error | null> {
return new Promise((resolve) => {
uploadArchivosSoporte(req, {} as Response, (err?: unknown) => resolve((err as Error) ?? null))
})
}
it('guarda el archivo en uploads/soporte/<idSolicitud>/ conservando el nombre', async () => {
const req = reqMultipart([
{ name: 'archivos', filename: 'carta poder.pdf', tipo: 'application/pdf', contenido: '%PDF-1.4 hola' },
])
req.solicitudSoporteId = 'sol-abc'
expect(await subir(req)).toBeNull()
const archivos = req.files as Express.Multer.File[]
expect(archivos).toHaveLength(1)
const relativa = path.relative(config.uploadsDir, archivos[0]!.path)
expect(relativa.startsWith(`soporte${path.sep}sol-abc${path.sep}`)).toBe(true)
expect(relativa.endsWith('-carta poder.pdf')).toBe(true)
expect(fs.readFileSync(archivos[0]!.path, 'utf8')).toBe('%PDF-1.4 hola')
})
it('rechaza tipos no permitidos (texto plano)', async () => {
const req = reqMultipart([
{ name: 'archivos', filename: 'notas.txt', tipo: 'text/plain', contenido: 'hola' },
])
req.solicitudSoporteId = 'sol-txt'
const err = await subir(req)
expect(err).toBeInstanceOf(Error)
expect(err?.message).toContain('Tipo de archivo no permitido')
})
it('si multer corta (demasiados archivos) borra la carpeta de la solicitud', async () => {
const partes = Array.from({ length: 11 }, (_, i) => ({
name: 'archivos',
filename: `doc-${i}.pdf`,
tipo: 'application/pdf',
contenido: 'pdf',
}))
const req = reqMultipart(partes)
req.solicitudSoporteId = 'sol-limite'
const err = await subir(req)
expect(err).toBeInstanceOf(Error)
expect(err?.message).toContain('Too many files')
expect(fs.existsSync(path.join(config.uploadsDir, 'soporte', 'sol-limite'))).toBe(false)
})
it('sanea el nombre original (no permite rutas)', async () => {
const req = reqMultipart([
{ name: 'archivos', filename: '../../evil.png', tipo: 'image/png', contenido: 'PNG' },
])
req.solicitudSoporteId = 'sol-evil'
expect(await subir(req)).toBeNull()
const archivos = req.files as Express.Multer.File[]
const relativa = path.relative(config.uploadsDir, archivos[0]!.path)
expect(relativa).not.toContain('..')
expect(relativa.startsWith(`soporte${path.sep}sol-evil${path.sep}`)).toBe(true)
})
})
// ── POST /api/soporte ────────────────────────────────────────────────
describe('crearSolicitudSoporteEndpoint', () => {
it('201 guardando las rutas relativas de los adjuntos subidos', async () => {
const solicitado = 'a1b2c3d4-factura.pdf'
const rutaAbsoluta = path.join(config.uploadsDir, 'soporte', 'sol-1', solicitado)
fs.mkdirSync(path.dirname(rutaAbsoluta), { recursive: true })
fs.writeFileSync(rutaAbsoluta, 'pdf')
const req = reqFake({
body: bodyValido,
solicitudSoporteId: 'sol-1',
files: [
{
path: rutaAbsoluta,
originalname: 'factura.pdf',
mimetype: 'application/pdf',
size: 3,
} as Express.Multer.File,
],
})
const { res, status, json } = resFake()
await crearSolicitudSoporteEndpoint(req, res)
expect(status).toHaveBeenCalledWith(201)
const data = prismaMock.solicitudSoporte.create.mock.calls[0]![0] as { data: Record<string, unknown> }
expect(data.data['id']).toBe('sol-1')
expect(JSON.parse(String(data.data['archivos']))).toEqual([
path.join('soporte', 'sol-1', solicitado),
])
expect(data.data['solicitanteEmail']).toBe('vendedor@frasa.mx')
expect(json).toHaveBeenCalled()
expect(auditoriaMock.registrar).toHaveBeenCalled()
})
it('si el body no valida borra la carpeta de adjuntos y propaga el error', async () => {
const carpeta = path.join(config.uploadsDir, 'soporte', 'sol-mala')
fs.mkdirSync(carpeta, { recursive: true })
fs.writeFileSync(path.join(carpeta, 'x.pdf'), 'pdf')
const req = reqFake({ body: { ...bodyValido, fechaSolicitud: '' }, solicitudSoporteId: 'sol-mala' })
await expect(crearSolicitudSoporteEndpoint(req, resFake().res)).rejects.toThrow()
expect(fs.existsSync(carpeta)).toBe(false)
expect(prismaMock.solicitudSoporte.create).not.toHaveBeenCalled()
})
it('acepta JSON sin adjuntos (compatibilidad) y guarda archivos: null', async () => {
const req = reqFake({ body: bodyValido, solicitudSoporteId: 'sol-json' })
const { res, status } = resFake()
await crearSolicitudSoporteEndpoint(req, res)
expect(status).toHaveBeenCalledWith(201)
const data = prismaMock.solicitudSoporte.create.mock.calls[0]![0] as { data: Record<string, unknown> }
expect(data.data['archivos']).toBeNull()
})
})
// ── GET /api/soporte (visibilidad + filtros) ─────────────────────────
describe('listarSolicitudesSoporteEndpoint', () => {
it('el manager ve TODAS las solicitudes (sin filtro por usuario)', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
const { res } = resFake()
await listarSolicitudesSoporteEndpoint(
reqFake({ usuarioToken: { sub: 'm-1', username: 'manager', rol: 'manager' } }),
res,
)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({
where: {},
orderBy: { createdAt: 'desc' },
})
})
it('el admin ve TODAS las solicitudes', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
await listarSolicitudesSoporteEndpoint(
reqFake({ usuarioToken: { sub: 'a-1', username: 'admin', rol: 'admin' } }),
resFake().res,
)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: {} }),
)
})
it('el vendedor solo ve las suyas', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
await listarSolicitudesSoporteEndpoint(reqFake({ usuarioToken: { sub: 'v-9', username: 'v', rol: 'vendedor' } }), resFake().res)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({
where: { usuarioId: 'v-9' },
orderBy: { createdAt: 'desc' },
})
})
it('aplica filtros de estado y departamento', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
await listarSolicitudesSoporteEndpoint(
reqFake({
query: { estado: 'EN_PROCESO', departamento: 'Cobranza' },
usuarioToken: { sub: 'm-1', username: 'manager', rol: 'manager' },
}),
resFake().res,
)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({
where: { estado: 'EN_PROCESO', departamento: 'Cobranza' },
orderBy: { createdAt: 'desc' },
})
})
it('ignora filtros vacíos y rechaza estados inválidos', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
await listarSolicitudesSoporteEndpoint(
reqFake({ query: { estado: '', departamento: '' } }),
resFake().res,
)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { usuarioId: 'u-1' } }),
)
await expect(
listarSolicitudesSoporteEndpoint(reqFake({ query: { estado: 'LOQUESEA' } }), resFake().res),
).rejects.toThrow()
})
it('serializa archivos JSON a array', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([
filaFake({ archivos: JSON.stringify(['soporte/sol-1/a.pdf']) }),
])
const { res, json } = resFake()
await listarSolicitudesSoporteEndpoint(reqFake(), res)
expect((json.mock.calls[0]![0] as Array<{ archivos: string[] }>)[0]!.archivos).toEqual(['soporte/sol-1/a.pdf'])
})
})
// ── GET /api/soporte/:id/archivos/:indice ────────────────────────────
describe('descargarArchivoSoporteEndpoint', () => {
function crearArchivo(relativa: string, contenido = 'contenido'): string {
const absoluta = path.join(config.uploadsDir, relativa)
fs.mkdirSync(path.dirname(absoluta), { recursive: true })
fs.writeFileSync(absoluta, contenido)
return absoluta
}
it('el dueño descarga su adjunto', async () => {
const absoluta = crearArchivo(path.join('soporte', 'sol-1', 'a1-doc.pdf'))
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ usuarioId: 'u-1', archivos: JSON.stringify([path.relative(config.uploadsDir, absoluta)]) }),
)
const { res, setHeader, sendFile } = resFake()
await descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), res)
expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf')
expect(sendFile).toHaveBeenCalledWith(absoluta)
})
it('el manager descarga el adjunto de otro usuario', async () => {
const absoluta = crearArchivo(path.join('soporte', 'sol-1', 'a1-doc.png'))
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ usuarioId: 'otro', archivos: JSON.stringify([path.relative(config.uploadsDir, absoluta)]) }),
)
const { res, sendFile } = resFake()
await descargarArchivoSoporteEndpoint(
reqFake({
params: { id: 'sol-1', indice: '0' },
usuarioToken: { sub: 'm-1', username: 'm', rol: 'manager' },
}),
res,
)
expect(sendFile).toHaveBeenCalledWith(absoluta)
})
it('403 si la solicitud es de otro usuario y no es admin/manager', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ usuarioId: 'otro', archivos: JSON.stringify(['soporte/otro/x.pdf']) }),
)
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), resFake().res),
).rejects.toThrow('Sin permiso')
})
it('404 si la solicitud o el índice no existen', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(null)
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'nope', indice: '0' } }), resFake().res),
).rejects.toThrow('Solicitud no encontrada')
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ archivos: JSON.stringify(['soporte/sol-1/a.pdf']) }),
)
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '5' } }), resFake().res),
).rejects.toThrow('Archivo no encontrado')
})
it('400 si la ruta guardada apunta fuera de uploads (path traversal)', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ archivos: JSON.stringify(['../../etc/passwd']) }),
)
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), resFake().res),
).rejects.toThrow('Ruta de archivo inválida')
})
it('400 si el índice no es un entero válido', async () => {
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: 'x' } }), resFake().res),
).rejects.toThrow('Índice de archivo inválido')
})
})
// ── PATCH /api/soporte/:id ───────────────────────────────────────────
describe('cambiarEstadoSolicitudEndpoint', () => {
it('cambia el estado y deja auditoría', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(filaFake({ estado: 'ABIERTA' }))
const { res, json } = resFake()
await cambiarEstadoSolicitudEndpoint(
reqFake({
params: { id: 'sol-1' },
body: { estado: 'EN_PROCESO' },
usuarioToken: { sub: 'm-1', username: 'm', rol: 'manager' },
}),
res,
)
expect(prismaMock.solicitudSoporte.update).toHaveBeenCalledWith({
where: { id: 'sol-1' },
data: { estado: 'EN_PROCESO' },
})
expect(json).toHaveBeenCalledWith(expect.objectContaining({ estado: 'EN_PROCESO' }))
expect(auditoriaMock.registrar).toHaveBeenCalledWith(
'm-1',
'CAMBIAR_ESTADO_SOPORTE',
'soporte',
'sol-1',
'ABIERTA→EN_PROCESO',
)
})
it('404 si la solicitud no existe', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(null)
await expect(
cambiarEstadoSolicitudEndpoint(reqFake({ params: { id: 'nope' }, body: { estado: 'CERRADA' } }), resFake().res),
).rejects.toThrow('Solicitud no encontrada')
})
it('400 si el estado no es válido', async () => {
await expect(
cambiarEstadoSolicitudEndpoint(reqFake({ params: { id: 'sol-1' }, body: { estado: 'VOLANDO' } }), resFake().res),
).rejects.toThrow()
})
})
describe('requireAdminOManager', () => {
it('deja pasar a admin y manager', () => {
for (const rol of ['admin', 'manager']) {
const next = vi.fn()
requireAdminOManager(reqFake({ usuarioToken: { sub: 'x', username: 'x', rol } }), resFake().res, next)
expect(next).toHaveBeenCalled()
}
})
it('rechaza a un vendedor', () => {
expect(() =>
requireAdminOManager(reqFake(), resFake().res, vi.fn()),
).toThrow('Se requiere rol admin o manager')
})
})

View File

@@ -1,21 +1,44 @@
import type { Request, Response } from 'express' import type { NextFunction, Request, Response } from 'express'
import fs from 'node:fs'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import multer from 'multer'
import { z } from 'zod' import { z } from 'zod'
import { config } from '../config/index.js'
import { prisma } from '../services/prisma.js' import { prisma } from '../services/prisma.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js'
import * as auditoria from '../services/auditoria.service.js' import * as auditoria from '../services/auditoria.service.js'
/** /**
* Módulo Soporte: solicitudes dirigidas a los departamentos internos * Módulo Soporte: solicitudes dirigidas a los departamentos internos
* (Sistemas, Administración, Ventas, Cobranza, Escrituración, Dirección). * (Sistemas, Administración, Ventas, Cobranza, Escrituración, Dirección).
* *
* - POST /api/soporte — crear solicitud (cualquier usuario autenticado). * - POST /api/soporte — crear solicitud (cualquier usuario autenticado). Acepta
* - GET /api/soporte — listar solicitudes: propias para cualquier usuario, * multipart con los adjuntos reales en el campo `archivos` (PDF/JPG/PNG,
* TODAS para admin (con filtro opcional ?estado=ABIERTA|EN_PROCESO|CERRADA). * máx 10 MB c/u, hasta 10) o JSON sin adjuntos.
* - GET /api/soporte — listar solicitudes: propias para un vendedor, TODAS
* para admin y manager, con filtros opcionales ?estado= y ?departamento=.
* - GET /api/soporte/:id/archivos/:indice — descargar un adjunto (dueño,
* admin o manager).
* - PATCH /api/soporte/:id — cambiar el estado (solo admin/manager).
*/ */
const DEPARTAMENTOS_SOPORTE = ['Sistemas', 'Administración', 'Ventas', 'Cobranza', 'Escrituración', 'Dirección'] as const const DEPARTAMENTOS_SOPORTE = ['Sistemas', 'Administración', 'Ventas', 'Cobranza', 'Escrituración', 'Dirección'] as const
const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
const MIME_PERMITIDOS_SOPORTE = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
const EXT_POR_MIME_SOPORTE: Record<string, string> = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
}
const MAX_ARCHIVOS_SOPORTE = 10
const MAX_BYTES_SOPORTE = 10 * 1024 * 1024
const crearSolicitudSchema = z.object({ const crearSolicitudSchema = z.object({
titulo: z.string().trim().min(1, 'El título es obligatorio'), titulo: z.string().trim().min(1, 'El título es obligatorio'),
descripcion: z.string().trim().min(1, 'La descripción es obligatoria'), descripcion: z.string().trim().min(1, 'La descripción es obligatoria'),
@@ -29,16 +52,104 @@ const crearSolicitudSchema = z.object({
// username del token si viene vacío // username del token si viene vacío
solicitante: z.string().trim().min(1).optional(), solicitante: z.string().trim().min(1).optional(),
email: z.string().trim().email('Email inválido').optional(), email: z.string().trim().email('Email inválido').optional(),
// Rutas/nombres de archivos adjuntos (sin endpoint de upload: el front manda // Rutas relativas de adjuntos YA subidos; con multipart se ignoran y se toman
// nombres; se guardan como JSON array) // los archivos reales que entregó multer (nadie declara rutas a mano)
archivos: z.array(z.string().trim().min(1)).optional(), archivos: z.array(z.string().trim().min(1)).optional(),
}) })
const listarSolicitudSchema = z.object({ const listarSolicitudSchema = z.object({
estado: z.enum(ESTADOS_SOPORTE).optional(), // '' = sin filtro: el front a veces manda el select vacío
estado: z.enum(ESTADOS_SOPORTE).or(z.literal('')).optional(),
departamento: z.enum(DEPARTAMENTOS_SOPORTE).or(z.literal('')).optional(),
}) })
/** Serializa la fila para el front: archivos JSON → array (tolerante a basura) */ const cambiarEstadoSchema = z.object({
estado: z.enum(ESTADOS_SOPORTE),
})
/** Request con el id de la solicitud ya reservado para la carpeta de adjuntos */
type RequestConIdSoporte = Request & { solicitudSoporteId?: string }
/**
* Id de la solicitud en curso. Se reserva ANTES de multer para que los adjuntos
* aterricen en uploads/soporte/<id>/ con el mismo id que tendrá la fila.
*/
function idSolicitudDe(req: Request): string {
const conId = req as RequestConIdSoporte
conId.solicitudSoporteId ??= randomUUID()
return conId.solicitudSoporteId
}
/** Carpeta de adjuntos de una solicitud: uploads/soporte/<id>/ */
function carpetaSoporte(id: string): string {
return path.join(config.uploadsDir, 'soporte', id)
}
/** Nombre seguro y único: uuid corto + nombre original saneado (sin rutas) */
function nombreSeguro(original: string): string {
const base = path.basename(original).replace(/[^\w.\- ]+/g, '_').slice(-80).trim()
return base.length > 0 ? base : 'archivo'
}
const storageSoporte = multer.diskStorage({
destination: (req, _file, cb) => {
const dir = carpetaSoporte(idSolicitudDe(req))
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (_req, file, cb) => {
const ext = EXT_POR_MIME_SOPORTE[file.mimetype] ?? 'bin'
const base = nombreSeguro(file.originalname)
cb(null, `${randomUUID().slice(0, 8)}-${base.endsWith(`.${ext}`) ? base : `${base}.${ext}`}`)
},
})
const multerArchivosSoporte = multer({
storage: storageSoporte,
limits: { fileSize: MAX_BYTES_SOPORTE, files: MAX_ARCHIVOS_SOPORTE },
fileFilter: (_req, file, cb) => {
if (!MIME_PERMITIDOS_SOPORTE.has(file.mimetype)) {
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype} (solo PDF, JPG o PNG)`))
return
}
cb(null, true)
},
}).array('archivos', MAX_ARCHIVOS_SOPORTE)
/**
* Middleware de subida: recibe hasta 10 adjuntos en el campo `archivos`. Si
* multer corta a mitad (tipo no permitido, archivo > 10 MB, demasiados
* archivos) borra lo que haya quedado en la carpeta de la solicitud.
*/
export function uploadArchivosSoporte(req: Request, res: Response, next: NextFunction): void {
multerArchivosSoporte(req, res, (err?: unknown) => {
if (err) {
borrarCarpetaSoporte(idSolicitudDe(req))
next(err)
return
}
next()
})
}
/** Borra la carpeta de adjuntos de una solicitud que no llegó a crearse */
function borrarCarpetaSoporte(id: string): void {
fs.rmSync(carpetaSoporte(id), { recursive: true, force: true })
}
/** archivos JSON → array de rutas relativas (tolerante a basura) */
function parsearArchivos(archivos: string | null): string[] {
if (!archivos) return []
try {
const parseado: unknown = JSON.parse(archivos)
if (Array.isArray(parseado)) return parseado.filter((a): a is string => typeof a === 'string')
return []
} catch {
return []
}
}
/** Serializa la fila para el front: archivos JSON → array de rutas */
function aPublico(fila: { function aPublico(fila: {
id: string id: string
usuarioId: string usuarioId: string
@@ -57,24 +168,40 @@ function aPublico(fila: {
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
}) { }) {
let archivos: string[] = [] return { ...fila, archivos: parsearArchivos(fila.archivos) }
if (fila.archivos) {
try {
const parseado: unknown = JSON.parse(fila.archivos)
if (Array.isArray(parseado)) archivos = parseado.filter((a): a is string => typeof a === 'string')
} catch {
archivos = []
}
}
return { ...fila, archivos }
} }
/** POST /api/soporte — crear solicitud de soporte */ /** MIME por extensión para servir el adjunto descargado */
function mimeDeArchivo(ruta: string): string {
const ext = path.extname(ruta).toLowerCase()
if (ext === '.pdf') return 'application/pdf'
if (ext === '.png') return 'image/png'
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
return 'application/octet-stream'
}
/** POST /api/soporte — crear solicitud de soporte (JSON o multipart) */
export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise<void> { export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise<void> {
const body = crearSolicitudSchema.parse(req.body) const id = idSolicitudDe(req)
let body: z.infer<typeof crearSolicitudSchema>
try {
body = crearSolicitudSchema.parse(req.body)
} catch (err) {
// Los adjuntos ya están en disco: sin solicitud que los referencie, sobran
borrarCarpetaSoporte(id)
throw err
}
const subidos = (req.files ?? []) as Express.Multer.File[]
const archivos =
subidos.length > 0
? subidos.map((f) => path.relative(config.uploadsDir, f.path))
: (body.archivos ?? [])
const usuarioId = req.usuarioToken?.sub ?? 'desconocido' const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
const fila = await prisma.solicitudSoporte.create({ const fila = await prisma.solicitudSoporte.create({
data: { data: {
id,
usuarioId, usuarioId,
titulo: body.titulo, titulo: body.titulo,
descripcion: body.descripcion, descripcion: body.descripcion,
@@ -84,7 +211,7 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response)
fechaSolicitud: body.fechaSolicitud, fechaSolicitud: body.fechaSolicitud,
fechaLimite: body.fechaLimite ?? null, fechaLimite: body.fechaLimite ?? null,
notas: body.notas ?? null, notas: body.notas ?? null,
archivos: body.archivos !== undefined ? JSON.stringify(body.archivos) : null, archivos: archivos.length > 0 ? JSON.stringify(archivos) : null,
solicitante: body.solicitante ?? req.usuarioToken?.username ?? '', solicitante: body.solicitante ?? req.usuarioToken?.username ?? '',
solicitanteEmail: body.email ?? null, solicitanteEmail: body.email ?? null,
}, },
@@ -93,15 +220,18 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response)
auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento) auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento)
} }
/** GET /api/soporte — listar solicitudes (propias; admin ve todas) */ /**
* GET /api/soporte — listar solicitudes. Admin y manager ven TODAS (la UI les
* dice "Todas las solicitudes"); un vendedor solo ve las suyas.
*/
export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise<void> { export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise<void> {
const { estado } = listarSolicitudSchema.parse(req.query) const { estado, departamento } = listarSolicitudSchema.parse(req.query)
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {}
// Admin ve todas; el resto solo las suyas if (!esAdminOManager(req.usuarioToken?.rol)) {
if (req.usuarioToken?.rol !== 'admin') {
where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido' where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido'
} }
if (estado !== undefined) where['estado'] = estado if (estado) where['estado'] = estado
if (departamento) where['departamento'] = departamento
const filas = await prisma.solicitudSoporte.findMany({ const filas = await prisma.solicitudSoporte.findMany({
where, where,
@@ -109,3 +239,68 @@ export async function listarSolicitudesSoporteEndpoint(req: Request, res: Respon
}) })
res.json(filas.map(aPublico)) res.json(filas.map(aPublico))
} }
/** Middleware: solo admin/manager pueden mover solicitudes de estado */
export function requireAdminOManager(req: Request, _res: Response, next: NextFunction): void {
if (!esAdminOManager(req.usuarioToken?.rol)) {
throw new AppError(403, 'Se requiere rol admin o manager')
}
next()
}
/** PATCH /api/soporte/:id — cambiar estado (admin/manager) */
export async function cambiarEstadoSolicitudEndpoint(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const { estado } = cambiarEstadoSchema.parse(req.body)
const existente = await prisma.solicitudSoporte.findUnique({ where: { id } })
if (!existente) throw new NotFoundError('Solicitud no encontrada')
const fila = await prisma.solicitudSoporte.update({ where: { id }, data: { estado } })
res.json(aPublico(fila))
auditoria.registrar(
req.usuarioToken?.sub ?? 'desconocido',
'CAMBIAR_ESTADO_SOPORTE',
'soporte',
fila.id,
`${existente.estado}→${estado}`,
)
}
/**
* GET /api/soporte/:id/archivos/:indice — descarga un adjunto de la solicitud.
* Solo el dueño, un admin o un manager. El índice es la posición en
* `archivos`, así el cliente nunca propone una ruta.
*/
export async function descargarArchivoSoporteEndpoint(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const indice = Number(req.params['indice'])
if (!Number.isInteger(indice) || indice < 0) {
throw new BadRequestError('Índice de archivo inválido')
}
const fila = await prisma.solicitudSoporte.findUnique({ where: { id } })
if (!fila) throw new NotFoundError('Solicitud no encontrada')
const propio = fila.usuarioId === (req.usuarioToken?.sub ?? 'desconocido')
if (!propio && !esAdminOManager(req.usuarioToken?.rol)) {
throw new AppError(403, 'Sin permiso para ver los adjuntos de esta solicitud')
}
const archivo = parsearArchivos(fila.archivos)[indice]
if (archivo === undefined) throw new NotFoundError('Archivo no encontrado')
// Guarda anti path-traversal: la ruta guardada debe vivir dentro de uploadsDir
const rutaAbsoluta = path.resolve(config.uploadsDir, archivo)
if (!rutaAbsoluta.startsWith(config.uploadsDir + path.sep)) {
throw new BadRequestError('Ruta de archivo inválida')
}
if (!fs.existsSync(rutaAbsoluta)) throw new NotFoundError('Archivo no encontrado en disco')
res.setHeader('Content-Type', mimeDeArchivo(rutaAbsoluta))
res.setHeader(
'Content-Disposition',
`attachment; filename="${path.basename(rutaAbsoluta).replace(/[^\w.\- ]+/g, '_')}"`,
)
res.sendFile(rutaAbsoluta)
}

View File

@@ -9,6 +9,9 @@ const prismaMock = vi.hoisted(() => ({
findMany: vi.fn(), findMany: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
capturaDepartamento: {
findMany: vi.fn(),
},
})) }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -37,6 +40,7 @@ beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([filaLocal]) prismaMock.unidad.findMany.mockResolvedValue([filaLocal])
prismaMock.unidad.findUnique.mockResolvedValue(filaLocal) prismaMock.unidad.findUnique.mockResolvedValue(filaLocal)
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
}) })
describe('unidades.controller (local)', () => { describe('unidades.controller (local)', () => {
@@ -58,6 +62,18 @@ describe('unidades.controller (local)', () => {
}) })
}) })
it('la fecha de entrega sale de la captura del departamento (ESCRITURACION/OBRA)', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
])
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ fechaEntrega: '2027-10-08' })
})
it('los filtros ?disponibles y ?desarrollo se aplican igual', async () => { it('los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
prismaMock.unidad.findMany.mockResolvedValue([ prismaMock.unidad.findMany.mockResolvedValue([
filaLocal, filaLocal,

View File

@@ -124,6 +124,34 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
} }
}) })
it('deja pasar los endpoints públicos de desarrollos (micrositio sin sesión)', () => {
const casos: Partial<Request>[] = [
{ method: 'GET', path: '/publico/desarrollos' },
{ method: 'GET', path: '/publico/desarrollos/SENDA' },
]
for (const c of casos) {
vi.clearAllMocks()
authMiddleware(reqFake(c), resFake().res, next)
expect(next).toHaveBeenCalledOnce()
}
})
it('el patrón público de desarrollos no abre el CRUD de admin', () => {
const casos: Partial<Request>[] = [
{ method: 'POST', path: '/publico/desarrollos' },
{ method: 'DELETE', path: '/publico/desarrollos/SENDA' },
{ method: 'GET', path: '/publico/desarrollos/SENDA/unidades' },
{ method: 'GET', path: '/desarrollos' },
{ method: 'GET', path: '/desarrollos/d-1' },
{ method: 'POST', path: '/desarrollos/d-1/unidades' },
]
for (const c of casos) {
const { res, status } = resFake()
authMiddleware(reqFake(c), res, next)
expect(status).toHaveBeenCalledWith(401)
}
})
it('el token interno abre solo POST /cotizaciones/alertas-inventario', () => { it('el token interno abre solo POST /cotizaciones/alertas-inventario', () => {
authMiddleware( authMiddleware(
reqFake({ reqFake({

View File

@@ -20,6 +20,10 @@ const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [
{ metodo: 'GET', patron: /^\/clientes\/publico\/[^/]+$/ }, { metodo: 'GET', patron: /^\/clientes\/publico\/[^/]+$/ },
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/firma$/ }, { metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/firma$/ },
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/documentos$/ }, { metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/documentos$/ },
// Micrositio: fichas públicas de desarrollos y su inventario disponible
// (solo información comercial, jamás datos de clientes ni cotizaciones).
{ metodo: 'GET', patron: /^\/publico\/desarrollos$/ },
{ metodo: 'GET', patron: /^\/publico\/desarrollos\/[^/]+$/ },
] ]
declare module 'express-serve-static-core' { declare module 'express-serve-static-core' {

View File

@@ -2,6 +2,7 @@ import { AppError } from '../utils/errors.js'
import { Router } from 'express' import { Router } from 'express'
import { health } from '../controllers/health.controller.js' import { health } from '../controllers/health.controller.js'
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js' import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
import { reglasCotizadorEndpoint } from '../controllers/reglas-cotizador.controller.js'
import { import {
alertasInventarioEndpoint, alertasInventarioEndpoint,
crearCotizacion, crearCotizacion,
@@ -36,6 +37,7 @@ import {
uploadDocumentosPublico, uploadDocumentosPublico,
} from '../controllers/liga.controller.js' } from '../controllers/liga.controller.js'
import { import {
actualizarPlan,
agregarNota, agregarNota,
cancelarPlan, cancelarPlan,
cobranzaHoy, cobranzaHoy,
@@ -43,6 +45,7 @@ import {
listarPlanes, listarPlanes,
obtenerPlan, obtenerPlan,
registrarAbonoEndpoint, registrarAbonoEndpoint,
requireEditaPlan,
timelinePlan, timelinePlan,
} from '../controllers/planes.controller.js' } from '../controllers/planes.controller.js'
import { import {
@@ -148,14 +151,34 @@ import {
import { import {
listarEndpoint as listarMaestroEndpoint, listarEndpoint as listarMaestroEndpoint,
exportarEndpoint as exportarMaestroEndpoint, exportarEndpoint as exportarMaestroEndpoint,
catalogosEndpoint as catalogosMaestroEndpoint,
obtenerEndpoint as obtenerMaestroEndpoint, obtenerEndpoint as obtenerMaestroEndpoint,
importarEndpoint as importarMaestroEndpoint, importarEndpoint as importarMaestroEndpoint,
requireAdmin as requireAdminMaestro, requireAdmin as requireAdminMaestro,
} from '../controllers/cliente-maestro.controller.js' } from '../controllers/cliente-maestro.controller.js'
import { import {
cambiarEstadoSolicitudEndpoint,
crearSolicitudSoporteEndpoint, crearSolicitudSoporteEndpoint,
descargarArchivoSoporteEndpoint,
listarSolicitudesSoporteEndpoint, listarSolicitudesSoporteEndpoint,
requireAdminOManager,
uploadArchivosSoporte,
} from '../controllers/soporte.controller.js' } from '../controllers/soporte.controller.js'
import {
actualizarDesarrolloEndpoint,
actualizarModeloEndpoint,
agregarModeloEndpoint,
crearDesarrolloEndpoint,
crearUnidadesEndpoint,
eliminarDesarrolloEndpoint,
eliminarModeloEndpoint,
eliminarUnidadEndpoint,
guardarReglasEndpoint,
listarDesarrollosEndpoint,
listarDesarrollosPublicosEndpoint,
obtenerDesarrolloEndpoint,
obtenerDesarrolloPublicoEndpoint,
} from '../controllers/desarrollos.controller.js'
export const apiRouter = Router() export const apiRouter = Router()
@@ -169,6 +192,11 @@ apiRouter.post('/auth/cambiar-password', cambiarPasswordEndpoint)
apiRouter.get('/unidades', listarUnidades) apiRouter.get('/unidades', listarUnidades)
apiRouter.get('/unidades/:codigo', obtenerUnidad) apiRouter.get('/unidades/:codigo', obtenerUnidad)
// Reglas comerciales del cotizador (BD del admin con respaldo en constantes).
// Ruta fija: debe ir ANTES de cualquier '/desarrollos/:id' que agregue el
// módulo de administración para no quedar tapada por el parámetro.
apiRouter.get('/desarrollos/reglas-cotizador', reglasCotizadorEndpoint)
apiRouter.post('/cotizaciones', crearCotizacion) apiRouter.post('/cotizaciones', crearCotizacion)
apiRouter.get('/cotizaciones', listarCotizaciones) apiRouter.get('/cotizaciones', listarCotizaciones)
apiRouter.get('/cotizaciones/mias', listarMisCotizaciones) apiRouter.get('/cotizaciones/mias', listarMisCotizaciones)
@@ -229,6 +257,8 @@ apiRouter.get('/planes/:id/timeline', requireModuloCobranza, timelinePlan)
apiRouter.post('/planes/:id/abonos', requireModuloCobranza, registrarAbonoEndpoint) apiRouter.post('/planes/:id/abonos', requireModuloCobranza, registrarAbonoEndpoint)
apiRouter.post('/planes/:id/notas', requireModuloCobranza, agregarNota) apiRouter.post('/planes/:id/notas', requireModuloCobranza, agregarNota)
apiRouter.post('/planes/:id/cancelar', requireModuloCobranza, cancelarPlan) apiRouter.post('/planes/:id/cancelar', requireModuloCobranza, cancelarPlan)
// Editar la tasa de mora del plan: solo admin/manager/dirección
apiRouter.patch('/planes/:id', requireModuloCobranza, requireEditaPlan, actualizarPlan)
apiRouter.get('/cobranza/recordatorios', listarRecordatorios) apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios) apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
@@ -345,11 +375,33 @@ apiRouter.post('/push/desuscribir', desuscribirEndpoint)
// ── Clientes Maestro ────────────────────────────────────────────── // ── Clientes Maestro ──────────────────────────────────────────────
apiRouter.get('/clientes-maestro', listarMaestroEndpoint) apiRouter.get('/clientes-maestro', listarMaestroEndpoint)
// Exportar va ANTES de /:numCliente para no colisionar con el parámetro // Rutas fijas ANTES de /:numCliente para no colisionar con el parámetro
apiRouter.get('/clientes-maestro/catalogos', catalogosMaestroEndpoint)
apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint) apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint)
apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint) apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint)
apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint) apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint)
// ── Módulo Soporte: solicitudes a departamentos internos ────────── // ── Módulo Soporte: solicitudes a departamentos internos ──────────
apiRouter.post('/soporte', crearSolicitudSoporteEndpoint) apiRouter.post('/soporte', uploadArchivosSoporte, crearSolicitudSoporteEndpoint)
apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint) apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint)
apiRouter.get('/soporte/:id/archivos/:indice', descargarArchivoSoporteEndpoint)
apiRouter.patch('/soporte/:id', requireAdminOManager, cambiarEstadoSolicitudEndpoint)
// ── Módulo Desarrollos: catálogo en BD (solo admin) ─────────────────
// Público primero (sin auth, lo consume el micrositio): ver RUTAS_PUBLICAS/
// PATRONES_PUBLICOS en middleware/auth.middleware.ts
apiRouter.get('/publico/desarrollos', listarDesarrollosPublicosEndpoint)
apiRouter.get('/publico/desarrollos/:clave', obtenerDesarrolloPublicoEndpoint)
apiRouter.get('/desarrollos', requireAdminEstricto, listarDesarrollosEndpoint)
apiRouter.post('/desarrollos', requireAdminEstricto, crearDesarrolloEndpoint)
// Rutas fijas de modelos ANTES de /desarrollos/:id para no colisionar con el parámetro
apiRouter.patch('/desarrollos/modelos/:modeloId', requireAdminEstricto, actualizarModeloEndpoint)
apiRouter.delete('/desarrollos/modelos/:modeloId', requireAdminEstricto, eliminarModeloEndpoint)
apiRouter.get('/desarrollos/:id', requireAdminEstricto, obtenerDesarrolloEndpoint)
apiRouter.patch('/desarrollos/:id', requireAdminEstricto, actualizarDesarrolloEndpoint)
apiRouter.delete('/desarrollos/:id', requireAdminEstricto, eliminarDesarrolloEndpoint)
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)

View File

@@ -18,6 +18,7 @@ import {
listar, listar,
obtener, obtener,
contar, contar,
catalogos,
exportar, exportar,
importarDesdeExcel, importarDesdeExcel,
} from './cliente-maestro.service.js' } from './cliente-maestro.service.js'
@@ -49,23 +50,31 @@ describe('listar', () => {
await listar({ busqueda: 'Juan', pagina: 1, limite: 50 }) await listar({ busqueda: 'Juan', pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith( expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ where: {
OR: expect.arrayContaining([ AND: [
{ nombre: { contains: 'Juan' } }, expect.objectContaining({
]), OR: expect.arrayContaining([{ nombre: { contains: 'Juan' } }]),
}), }),
],
},
}), }),
) )
}) })
it('aplica filtro de desarrollo', async () => { it('aplica filtro de desarrollo (valor exacto o con sufijo " - ...")', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([]) prismaMock.clienteMaestro.findMany.mockResolvedValue([])
prismaMock.clienteMaestro.count.mockResolvedValue(0) prismaMock.clienteMaestro.count.mockResolvedValue(0)
await listar({ desarrollo: 'SENDA', pagina: 1, limite: 50 }) await listar({ desarrollo: 'SENDA', pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith( expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ desarrollo: 'SENDA' }), where: {
AND: [
{
OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }],
},
],
},
}), }),
) )
}) })
@@ -77,10 +86,48 @@ describe('listar', () => {
await listar({ estatus: 'VENDIDO', pagina: 1, limite: 50 }) await listar({ estatus: 'VENDIDO', pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith( expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ estatus: 'VENDIDO' }), where: { AND: [{ estatus: 'VENDIDO' }] },
}), }),
) )
}) })
it('combina búsqueda, desarrollo y estatus en la misma consulta', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
prismaMock.clienteMaestro.count.mockResolvedValue(0)
await listar({ busqueda: 'Ana', desarrollo: 'SOLE', estatus: 'ENTREGA', pagina: 1, limite: 50 })
const llamada = prismaMock.clienteMaestro.findMany.mock.calls[0]![0] as { where: { AND: unknown[] } }
expect(llamada.where.AND).toHaveLength(3)
})
it('sin filtros no arma condiciones', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
prismaMock.clienteMaestro.count.mockResolvedValue(0)
await listar({ pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: {} }),
)
})
})
// ── catalogos ────────────────────────────────────────────────────────
describe('catalogos', () => {
it('devuelve los desarrollos y estatus reales, sin vacíos ni nulos', async () => {
prismaMock.clienteMaestro.findMany
.mockResolvedValueOnce([{ desarrollo: 'MUTUO - EDIFICACION' }, { desarrollo: 'SOLE' }, { desarrollo: null }, { desarrollo: ' ' }])
.mockResolvedValueOnce([{ estatus: 'ENTREGA' }, { estatus: 'VENDIDO' }, { estatus: null }])
const result = await catalogos()
expect(result.desarrollos).toEqual(['MUTUO - EDIFICACION', 'SOLE'])
expect(result.estatuses).toEqual(['ENTREGA', 'VENDIDO'])
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith({
distinct: ['desarrollo'],
select: { desarrollo: true },
orderBy: { desarrollo: 'asc' },
})
})
}) })
// ── obtener ────────────────────────────────────────────────────────── // ── obtener ──────────────────────────────────────────────────────────
@@ -113,7 +160,11 @@ describe('contar', () => {
prismaMock.clienteMaestro.count.mockResolvedValue(5) prismaMock.clienteMaestro.count.mockResolvedValue(5)
expect(await contar({ desarrollo: 'SENDA' })).toBe(5) expect(await contar({ desarrollo: 'SENDA' })).toBe(5)
expect(prismaMock.clienteMaestro.count).toHaveBeenCalledWith( expect(prismaMock.clienteMaestro.count).toHaveBeenCalledWith(
expect.objectContaining({ where: { desarrollo: 'SENDA' } }), {
where: {
AND: [{ OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }] }],
},
},
) )
}) })
}) })

View File

@@ -44,26 +44,37 @@ export type ClienteMaestroDTO = {
// ── Helpers ───────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────
function construirWhere(filtros: FiltrosClientesMaestro) { function construirWhere(filtros: FiltrosClientesMaestro) {
const where: Record<string, unknown> = {} const condiciones: Record<string, unknown>[] = []
if (filtros.desarrollo) { const desarrollo = filtros.desarrollo?.trim()
where.desarrollo = filtros.desarrollo if (desarrollo) {
// El mismo desarrollo convive con sufijos en la base ("ATELIER" y
// "ATELIER - EDIFICACION"): se acepta el valor exacto o su prefijo, así el
// filtro funciona tanto con el valor crudo como con el agrupado.
condiciones.push({
OR: [{ desarrollo }, { desarrollo: { startsWith: `${desarrollo} -` } }],
})
} }
if (filtros.estatus) {
where.estatus = filtros.estatus const estatus = filtros.estatus?.trim()
if (estatus) {
condiciones.push({ estatus })
} }
if (filtros.busqueda) { if (filtros.busqueda) {
const term = filtros.busqueda.trim() const term = filtros.busqueda.trim()
where.OR = [ condiciones.push({
OR: [
{ nombre: { contains: term } }, { nombre: { contains: term } },
{ apellidoPaterno: { contains: term } }, { apellidoPaterno: { contains: term } },
{ apellidoMaterno: { contains: term } }, { apellidoMaterno: { contains: term } },
{ telefono: { contains: term } }, { telefono: { contains: term } },
{ numCliente: { contains: term } }, { numCliente: { contains: term } },
] ],
})
} }
return where return condiciones.length > 0 ? { AND: condiciones } : {}
} }
// ── Service ───────────────────────────────────────────────────────── // ── Service ─────────────────────────────────────────────────────────
@@ -102,6 +113,33 @@ export async function obtener(numCliente: string): Promise<ClienteMaestroDTO | n
return prisma.clienteMaestro.findUnique({ where: { numCliente } }) return prisma.clienteMaestro.findUnique({ where: { numCliente } })
} }
/**
* Catálogos reales de los filtros: los desarrollos y estatus que existen hoy en
* la base (no una lista fija). El front arma los selectores con esto.
*/
export async function catalogos(): Promise<{ desarrollos: string[]; estatuses: string[] }> {
const [desarrollos, estatuses] = await Promise.all([
prisma.clienteMaestro.findMany({
distinct: ['desarrollo'],
select: { desarrollo: true },
orderBy: { desarrollo: 'asc' },
}),
prisma.clienteMaestro.findMany({
distinct: ['estatus'],
select: { estatus: true },
orderBy: { estatus: 'asc' },
}),
])
const limpiar = (valores: Array<string | null>): string[] =>
valores.filter((v): v is string => v !== null && v.trim() !== '')
return {
desarrollos: limpiar(desarrollos.map((d) => d.desarrollo)),
estatuses: limpiar(estatuses.map((e) => e.estatus)),
}
}
/** /**
* Cuenta clientes con filtros. * Cuenta clientes con filtros.
*/ */

View File

@@ -8,6 +8,7 @@ import {
generarCalendario, generarCalendario,
moraDePago, moraDePago,
pagadoTotal, pagadoTotal,
pagoAtrasado,
proximoVencimiento, proximoVencimiento,
round2, round2,
saldoPlan, saldoPlan,
@@ -203,6 +204,23 @@ describe('saldo, avance y status del plan', () => {
expect(statusPlan(plan)).toBe('CANCELADO') expect(statusPlan(plan)).toBe('CANCELADO')
}) })
it('sin calendario capturado → SIN_PLAN, no LIQUIDADO', () => {
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 }
expect(statusPlan(sinPagos)).toBe('SIN_PLAN')
// con pagos pero todos en cero (captura incompleta): tampoco es liquidado
const enCeros: PlanPago = {
...planDePrueba(),
engancheTotal: 0,
pagos: planDePrueba().pagos.map((p) => ({ ...p, monto: 0 })),
}
expect(statusPlan(enCeros)).toBe('SIN_PLAN')
})
it('un plan SIN_PLAN no entra en la cobranza del día', () => {
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 }
expect(cobranzaDelDia([sinPagos], new Date('2026-02-01T00:00:00'))).toHaveLength(0)
})
it('alCorriente es falso cuando hay pagos vencidos', () => { it('alCorriente es falso cuando hay pagos vencidos', () => {
expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false) expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false)
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true) expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
@@ -233,10 +251,13 @@ describe('proximoVencimiento y diasAtraso', () => {
expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0) expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0)
}) })
it('un pago PARCIAL vencido no cuenta como atraso', () => { it('un pago PARCIAL vencido SÍ cuenta como atraso (corrección: antes se ignoraba)', () => {
let plan = planDePrueba() let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' }) plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0) // pago 1 vence 10-ene y sigue sin cubrir (abono de 100) → 22 días al 1-feb
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(22)
expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(false)
expect(pagoAtrasado(plan.pagos[0]!, new Date('2026-02-01T00:00:00'))).toBe(true)
}) })
}) })

View File

@@ -7,7 +7,8 @@ import { randomUUID } from 'node:crypto'
*/ */
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL' export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' /** SIN_PLAN = no hay calendario capturado (no debe confundirse con LIQUIDADO) */
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' | 'SIN_PLAN'
export interface AplicacionPago { export interface AplicacionPago {
id: string id: string
@@ -137,7 +138,8 @@ export function generarCalendario(
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos: * PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE. * VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo * Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`. * PARCIAL (tiene abono aplicado); el atraso se señala con `pagoAtrasado`,
* `diasAtraso` y `alCorriente`, que sí lo cuentan como vencido.
*/ */
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago { export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO' if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
@@ -184,10 +186,10 @@ export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date(
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000) return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
} }
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */ /** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin cubrir) */
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number { export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
const vencidos = plan.pagos const vencidos = plan.pagos
.filter((p) => statusPago(p, hoy) === 'VENCIDO') .filter((p) => pagoAtrasado(p, hoy))
.sort( .sort(
(a, b) => (a, b) =>
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(), parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
@@ -197,15 +199,39 @@ export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000) return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
} }
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */ /**
* ¿El pago está atrasado? Un pago sin cubrir cuya fecha de vencimiento ya
* pasó: incluye el PARCIAL vencido (tiene abono pero no cubre el monto), que
* es un atraso real aunque `statusPago` lo siga etiquetando PARCIAL.
*/
export function pagoAtrasado(pago: Pago, hoy: Date = new Date()): boolean {
if (estaCubierto(pago)) return false
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0
}
/**
* Status derivado del plan:
* - CANCELADO: solo manual
* - SIN_PLAN: no hay calendario capturado (0 pagos o montos en 0). Distinto de
* LIQUIDADO: el adeudo real es desconocido, no saldado; queda fuera de la
* cobranza del día hasta que se capture el plan.
* - LIQUIDADO: saldo == 0
* - ACTIVO: tiene saldo pendiente
*/
export function statusPlan(plan: PlanPago): StatusPlan { export function statusPlan(plan: PlanPago): StatusPlan {
if (plan.status === 'CANCELADO') return 'CANCELADO' if (plan.status === 'CANCELADO') return 'CANCELADO'
if (!tieneCalendario(plan)) return 'SIN_PLAN'
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO' return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
} }
/** Al corriente = sin pagos VENCIDOS */ /** ¿El plan tiene calendario capturado? (al menos un pago con monto > 0) */
export function tieneCalendario(plan: PlanPago): boolean {
return plan.pagos.some((p) => p.monto > EPS)
}
/** Al corriente = sin pagos atrasados (un PARCIAL vencido también es atraso) */
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean { export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO') return !plan.pagos.some((p) => pagoAtrasado(p, hoy))
} }
/** /**
@@ -236,10 +262,11 @@ export interface CobranzaItem {
} }
/** /**
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS: * Lista plana de "cobranza del día" a través de todos los planes ACTIVOS
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos * (los SIN_PLAN quedan fuera: sin calendario capturado no hay nada que
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual * cobrar): pagos vencidos (incluye PARCIAL vencido) más los que vencen en los
* atraso, el vencimiento más próximo primero. * próximos `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a
* igual atraso, el vencimiento más próximo primero.
*/ */
export function cobranzaDelDia( export function cobranzaDelDia(
planes: PlanPago[], planes: PlanPago[],

View File

@@ -48,6 +48,56 @@ export function claveTelefono(telefono: string): string {
return digitos.length >= 10 ? digitos.slice(-10) : '' return digitos.length >= 10 ? digitos.slice(-10) : ''
} }
/**
* Clave de nombre para deduplicar/relacionar contactos que no traen teléfono ni
* email (las altas fallidas dejan un registro hueco con el mismo nombre).
* Sin acentos, sin mayúsculas y con los espacios colapsados.
*/
export function claveNombre(nombre: string): string {
return nombre
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ')
}
/**
* Aplana el JSON de perfil a un mapa campo→valor no vacío: sirve tanto para el
* `payloadJson` del alta de cliente (anidado por secciones) como para el
* `datosJson` del perfilamiento de una captura de afluencia.
*
* Mismo criterio que el reporte de clientes: gana el primer valor no vacío y se
* ignoran las secciones que repiten los mismos campos vacíos (coacreditado) o
* que no son de perfil. Así una sección posterior no pisa un dato real.
*/
export function aplanarPerfil(json: string | null | undefined): Record<string, string> {
if (!json) return {}
try {
const datos = JSON.parse(json) as Record<string, unknown>
const plano: Record<string, string> = {}
const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias'])
const recorrer = (obj: Record<string, unknown>, raiz: boolean): void => {
for (const [k, v] of Object.entries(obj)) {
if (v === null || v === undefined) continue
if (typeof v === 'object' && !Array.isArray(v)) {
if (raiz && SECCIONES_IGNORADAS.has(k)) continue
recorrer(v as Record<string, unknown>, false)
continue
}
if (typeof v === 'object') continue
const texto = String(v)
if (texto.trim() === '') continue
if (plano[k] === undefined) plano[k] = texto
}
}
recorrer(datos, true)
return plano
} catch {
return {}
}
}
// ── Perfil Completo del Contacto (modo local) ───────────────────────── // ── Perfil Completo del Contacto (modo local) ─────────────────────────
export interface PerfilCompletoContacto { export interface PerfilCompletoContacto {

View File

@@ -118,6 +118,99 @@ describe('listarContactosLocal', () => {
expect(lista[0]?.id).toBe('c1') expect(lista[0]?.id).toBe('c1')
}) })
it('deduplica por nombre a los registros sin teléfono ni email (gana la ficha con más datos)', async () => {
// Alta fallida: Cliente hueco (payload vacío, sin teléfono ni email) con el
// mismo nombre que su ficha real
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({
id: 'hueco',
nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA',
payloadJson: '{}',
createdAt: new Date('2026-09-11T10:00:00'),
}),
clienteFake({
id: 'completo',
nombreCompleto: 'Luis Enrique Salgado Zavaleta',
celular: '+52 5586880444',
email: 'luisenriquesalgadozavaleta@gmail.com',
curp: 'SAZL990504HDFLVS06',
rfc: 'SAZL990504KY0',
proyecto: 'SOLANA',
unidadCodigo: '9',
payloadJson: '{"datosGenerales":{"estadoCivil":"SOLTERO"}}',
}),
])
const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(1)
expect(lista[0]).toMatchObject({
id: 'completo',
nombre: 'Luis Enrique Salgado Zavaleta',
telefono: '+52 5586880444',
email: 'luisenriquesalgadozavaleta@gmail.com',
estado: 'Cliente',
})
})
it('fusiona el registro hueco aunque llegue después de la ficha completa', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({
id: 'completo',
nombreCompleto: 'GRACIELA BAUTISTA VELASCO',
celular: '+52 6641159092',
email: 'gbautista1304@gmail.com',
}),
clienteFake({ id: 'hueco', nombreCompleto: 'Graciela Bautista Velasco', payloadJson: '{}' }),
])
const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(1)
expect(lista[0]?.id).toBe('completo')
})
it('no fusiona a dos personas distintas con el mismo nombre si tienen teléfonos distintos', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567' }),
clienteFake({ id: 'c2', nombreCompleto: 'JUAN PÉREZ', celular: '6649998877' }),
])
const lista = (await listarContactosLocal()).contactos
expect(lista.map((c) => c.id).sort()).toEqual(['c1', 'c2'])
})
it('un Cliente hueco se fusiona con la captura del mismo nombre (gana la que trae más datos)', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({
id: 'hueco',
nombreCompleto: 'Guadalupe',
curp: '',
rfc: '',
payloadJson: '{}',
createdAt: new Date('2026-09-15T10:00:00'),
}),
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({
leadId: 'loc_g',
nombreLead: 'Guadalupe',
telefono: '+52 664 222 6090',
email: 'lupisintj1@hotmail.com',
etapa: 'Contactado',
}),
])
const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(1)
expect(lista[0]).toMatchObject({
id: 'loc_g',
telefono: '+52 664 222 6090',
email: 'lupisintj1@hotmail.com',
estado: 'Contactado',
})
})
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => { it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
// 101 clientes: la página 1 llena 100 y detecta que hay más // 101 clientes: la página 1 llena 100 y detecta que hay más
prismaMock.cliente.findMany.mockResolvedValue( prismaMock.cliente.findMany.mockResolvedValue(
@@ -233,4 +326,110 @@ describe('detalleContactoLocal', () => {
expect(detalle.relaciones.oportunidades).toHaveLength(1) expect(detalle.relaciones.oportunidades).toHaveLength(1)
expect(detalle.historial.length).toBeGreaterThan(0) expect(detalle.historial.length).toBeGreaterThan(0)
}) })
it('completa el perfil con el payloadJson del alta (estado civil, nacimiento, actividad, dirección)', async () => {
prismaMock.cliente.findFirst.mockResolvedValue(
clienteFake({
id: 'c1',
nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA',
celular: '5586880444',
email: 'luis@correo.mx',
curp: '',
rfc: '',
payloadJson: JSON.stringify({
datosGenerales: {
estadoCivil: 'SOLTERO',
fechaNacimiento: '1999-05-04',
actividadOcupacion: 'PILOTO AVIADOR',
direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA',
},
empleo: { compania: 'FRASA', direccion: 'AV COLINA DE SAN PABLO' },
// El co-acreditado repite los campos vacíos: no debe pisar los reales
coacreditado: { tieneCoacreditado: false, generales: { estadoCivil: '', fechaNacimiento: '', actividadOcupacion: '', direccion: '' } },
}),
}),
)
const detalle = await detalleContactoLocal('c1')
expect(detalle?.contacto).toMatchObject({
estadoCivil: 'SOLTERO',
fechaNacimiento: '1999-05-04',
actividadEconomica: 'PILOTO AVIADOR',
direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA',
})
})
it('toma el perfilamiento del datosJson de la captura cuando no hay Cliente', async () => {
const captura = capturaFake({
leadId: 'loc_1',
nombreLead: 'RAMON MORALES',
telefono: '6643535353',
email: 'ramon.morales@gmail.com',
datosJson: JSON.stringify({
edad: 41,
estadoCivil: 'Casado(a)',
ocupacion: 'empleado',
rangoSalarial: '50000',
}),
})
prismaMock.leadCaptura.findUnique.mockResolvedValue(captura)
prismaMock.leadCaptura.findMany.mockResolvedValue([captura])
const detalle = await detalleContactoLocal('loc_1')
expect(detalle?.contacto).toMatchObject({
nombre: 'RAMON MORALES',
estadoCivil: 'Casado(a)',
actividadEconomica: 'empleado',
})
// No se inventa fecha de nacimiento a partir de la edad
expect(detalle?.contacto['fechaNacimiento']).toBe('')
expect(detalle?.contacto['direccion']).toBe('')
})
it('incluye los documentos del registro hueco de la misma persona (no se pierden al fusionar)', async () => {
prismaMock.cliente.findFirst.mockResolvedValue(
clienteFake({
id: 'c1',
nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA',
celular: '6643998451',
email: 'daleman@frasa.com.mx',
}),
)
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'hueco', nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA', celular: null, email: null },
{ id: 'otro', nombreCompleto: 'OTRA PERSONA', celular: null, email: null },
])
prismaMock.documento.findMany.mockResolvedValue([
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
])
const detalle = await detalleContactoLocal('c1')
expect(prismaMock.documento.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { clienteId: { in: ['c1', 'hueco'] } } }),
)
expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true)
})
it('si la ficha visible es la captura, también se muestran los documentos de su Cliente hueco', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(
capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }),
)
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }),
])
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'hueco', nombreCompleto: 'Guadalupe', celular: null, email: null },
])
prismaMock.documento.findMany.mockResolvedValue([
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
])
const detalle = await detalleContactoLocal('loc_g')
expect(prismaMock.documento.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { clienteId: { in: ['hueco'] } } }),
)
expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true)
})
}) })

View File

@@ -1,4 +1,11 @@
import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js' import {
aplanarPerfil,
claveNombre,
claveTelefono,
type DetalleContacto,
type EventoHistorialContacto,
type RelacionCrm,
} from './contactos-detalle.service.js'
import { prisma } from './prisma.js' import { prisma } from './prisma.js'
/** /**
@@ -20,10 +27,10 @@ export interface ContactoListaLocal {
/** /**
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards * Lista local: Clientes (alta de clientes) + capturas del embudo en boards
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la * OPORTUNIDADES/APARTADO, deduplicados (gana el Cliente: tiene la ficha
* ficha completa), más reciente primero. Paginada de 100 en 100 (el front * completa), más reciente primero. Paginada de 100 en 100 (el front pagina con
* pagina con `?pagina=N` y muestra el límite). Filtra por usuario actual: * `?pagina=N` y muestra el límite). Filtra por usuario actual: clientes
* clientes capturados por él + leads asignados a él. * capturados por él + leads asignados a él.
*/ */
export const CONTACTOS_POR_PAGINA = 100 export const CONTACTOS_POR_PAGINA = 100
@@ -50,6 +57,37 @@ export async function esContactoDelUsuario(contactoId: string, usuarioId: string
return cliente !== null || captura !== null return cliente !== null || captura !== null
} }
/** Candidato de la lista antes de deduplicar */
interface Candidato {
contacto: ContactoListaLocal
/** La ficha de Cliente (alta completa) gana a la captura al fusionar */
esCliente: boolean
/** Cuántos datos trae el registro: desempata la fusión de duplicados */
peso: number
}
/** Cuenta los valores no vacíos de la ficha (para saber cuál está más completa) */
function pesoDeFicha(valores: readonly (string | null | undefined)[], conPerfil: boolean): number {
const llenos = valores.filter((v) => v !== null && v !== undefined && v.trim() !== '').length
return llenos + (conPerfil ? 1 : 0)
}
/** true si el JSON trae algo más que un objeto/contenedor vacío */
function tieneDatos(json: string | null | undefined): boolean {
if (!json) return false
return Object.keys(aplanarPerfil(json)).length > 0
}
/**
* Lista de contactos locales deduplicada en dos pasos:
* 1. por teléfono (últimos 10 dígitos), como siempre;
* 2. por nombre para los registros SIN teléfono ni email — las altas fallidas
* dejan un Cliente hueco con el mismo nombre que su ficha real, y antes se
* repetía siempre porque la clave de teléfono era ''.
* Al fusionar se conserva el registro con más datos y se completan sus vacíos
* con los del otro (por teléfono gana el Cliente: es la ficha del alta), sin
* perder documentos ni relaciones: el detalle une también las dos fichas.
*/
export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> { export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
const porPagina = CONTACTOS_POR_PAGINA const porPagina = CONTACTOS_POR_PAGINA
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1 const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
@@ -71,22 +109,76 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
take: porPagina + 1, take: porPagina + 1,
}) })
const vistos = new Set<string>() const lista: Candidato[] = []
const lista: ContactoListaLocal[] = [] const porTelefono = new Map<string, number>()
const agregar = (entrada: ContactoListaLocal): void => { const porNombre = new Map<string, number>()
// Clave por teléfono; si no hay, por nombre + email (antes los contactos sin
// teléfono se duplicaban siempre) /**
const tel = claveTelefono(entrada.telefono) * Deja en la posición `i` la ficha ganadora, completando sus vacíos con los
const clave = tel !== '' * del otro registro. Por teléfono gana el Cliente (mismo teléfono = misma
? tel * persona, y su alta es la ficha completa); por nombre gana el que tiene más
: `nombre:${entrada.nombre.trim().toLowerCase()}|${(entrada.email ?? '').trim().toLowerCase()}` * datos, que es el criterio de la fusión de duplicados huecos.
if (vistos.has(clave)) return */
vistos.add(clave) const fusionar = (i: number, nueva: Candidato, tel: string, nombre: string, porNombreClave: boolean): void => {
lista.push(entrada) const previa = lista[i]!
const ganaNueva = porNombreClave
? nueva.peso !== previa.peso
? nueva.peso > previa.peso
: nueva.esCliente
: nueva.esCliente !== previa.esCliente
? nueva.esCliente
: nueva.peso > previa.peso
const [ganadora, perdedora] = ganaNueva ? [nueva, previa] : [previa, nueva]
lista[i] = {
esCliente: ganadora.esCliente,
peso: ganadora.peso,
contacto: {
...ganadora.contacto,
nombre: ganadora.contacto.nombre.trim() !== '' ? ganadora.contacto.nombre : perdedora.contacto.nombre,
telefono: ganadora.contacto.telefono !== '' ? ganadora.contacto.telefono : perdedora.contacto.telefono,
email: ganadora.contacto.email !== '' ? ganadora.contacto.email : perdedora.contacto.email,
estado: ganadora.contacto.estado !== '' ? ganadora.contacto.estado : perdedora.contacto.estado,
},
}
if (tel !== '') porTelefono.set(tel, i)
porNombre.set(nombre, i)
}
/** ¿El registro ya agregado en esa posición no tiene teléfono ni email? */
const esHueco = (i: number): boolean => {
const c = lista[i]!.contacto
return c.telefono.trim() === '' && c.email.trim() === ''
}
const agregar = (nueva: Candidato): void => {
const tel = claveTelefono(nueva.contacto.telefono)
const nombre = claveNombre(nueva.contacto.nombre)
const email = nueva.contacto.email.trim()
const iNombre = porNombre.get(nombre)
// Sin teléfono ni email el nombre es lo único que identifica a la persona.
// Con teléfono o email sólo se une a un registro hueco del mismo nombre (la
// misma persona antes de completar su ficha), nunca a otro teléfono.
const iSinContacto =
tel === '' && email === ''
? iNombre
: iNombre !== undefined && esHueco(iNombre)
? iNombre
: undefined
const iTelefono = tel !== '' ? porTelefono.get(tel) : undefined
const i = iTelefono ?? iSinContacto
if (i !== undefined) {
fusionar(i, nueva, tel, nombre, iTelefono === undefined)
return
}
lista.push(nueva)
const indice = lista.length - 1
if (tel !== '') porTelefono.set(tel, indice)
porNombre.set(nombre, indice)
} }
for (const c of clientes) { for (const c of clientes) {
agregar({ agregar({
contacto: {
id: c.id, id: c.id,
nombre: c.nombreCompleto, nombre: c.nombreCompleto,
telefono: c.celular ?? '', telefono: c.celular ?? '',
@@ -94,10 +186,14 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
estado: c.proyecto.trim() !== '' ? 'Cliente' : '', estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
updatedAt: c.createdAt.toISOString(), updatedAt: c.createdAt.toISOString(),
fuente: 'local', fuente: 'local',
},
esCliente: true,
peso: pesoDeFicha([c.celular, c.email, c.curp, c.rfc, c.proyecto, c.unidadCodigo], tieneDatos(c.payloadJson)),
}) })
} }
for (const c of capturas) { for (const c of capturas) {
agregar({ agregar({
contacto: {
id: c.leadId, id: c.leadId,
nombre: c.nombreLead, nombre: c.nombreLead,
telefono: c.telefono ?? '', telefono: c.telefono ?? '',
@@ -105,9 +201,12 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
estado: c.etapa, estado: c.etapa,
updatedAt: c.updatedAt.toISOString(), updatedAt: c.updatedAt.toISOString(),
fuente: 'local', fuente: 'local',
},
esCliente: false,
peso: pesoDeFicha([c.telefono, c.email, c.desarrollo, c.modelo], tieneDatos(c.datosJson)),
}) })
} }
const ordenada = lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
const hayMas = ordenada.length > porPagina const hayMas = ordenada.length > porPagina
return { return {
contactos: ordenada.slice(0, porPagina), contactos: ordenada.slice(0, porPagina),
@@ -117,11 +216,34 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
} }
} }
/**
* Clientes huecos con el mismo nombre que el contacto: un alta fallida deja un
* registro sin teléfono ni email (payload vacío) que la lista fusiona con su
* ficha real. Sólo se miran los huecos — nombre + un teléfono/email propios no
* identifican a la misma persona. `excluirId` omite el propio contacto.
*/
async function huecosConMismoNombre(nombre: string, excluirId: string | null): Promise<string[]> {
const huecos = await prisma.cliente.findMany({
where: {
AND: [
...(excluirId !== null ? [{ id: { not: excluirId } }] : []),
{ OR: [{ celular: null }, { celular: '' }] },
{ OR: [{ email: null }, { email: '' }] },
],
},
select: { id: true, nombreCompleto: true },
})
const clave = claveNombre(nombre)
return huecos.filter((c) => claveNombre(c.nombreCompleto) === clave).map((c) => c.id)
}
/** /**
* Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id * Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id
* heredado) o mondayContactoId (compatibilidad de datos). Las relaciones se * heredado) o mondayContactoId (compatibilidad de datos). Las relaciones se
* arman por teléfono (últimos 10 dígitos) o email sobre LeadCaptura; el * arman por teléfono (últimos 10 dígitos) o email sobre LeadCaptura; el
* historial se deriva de capturas + alta + documentos + cotizaciones locales. * historial se deriva de capturas + alta + documentos + cotizaciones locales.
* El perfil (estado civil, fecha de nacimiento, actividad económica, dirección)
* se completa con el payloadJson del alta y el datosJson de las capturas.
* null si no existe en ninguna tabla. * null si no existe en ninguna tabla.
*/ */
export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> { export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> {
@@ -160,8 +282,22 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
: [] : []
const documentos = cliente // Duplicados huecos de la misma persona (altas fallidas: mismo nombre, sin
? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } }) // teléfono ni email). La lista los fusiona en una sola ficha, así que el
// detalle muestra también sus documentos para no perderlos, tanto si el
// contacto visible es el Cliente como si es la captura.
const huecos =
cliente !== null
? await huecosConMismoNombre(cliente.nombreCompleto, cliente.id)
: capturaBase !== null
? await huecosConMismoNombre(capturaBase.nombreLead, null)
: []
const idsConDocumentos = [...(cliente !== null ? [cliente.id] : []), ...huecos]
const documentos = idsConDocumentos.length
? await prisma.documento.findMany({
where: { clienteId: { in: idsConDocumentos } },
orderBy: { createdAt: 'desc' },
})
: [] : []
// Nombres de capturistas/vendedores (ids locales → nombre para mostrar) // Nombres de capturistas/vendedores (ids locales → nombre para mostrar)
@@ -245,6 +381,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
})), })),
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO)) ].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
// Perfil del alta (payloadJson del Cliente) y de las capturas de afluencia
// (datosJson): de ahí salen estado civil, fecha de nacimiento, actividad
// económica y dirección, que no son columnas de la BD. Gana el primer valor
// no vacío, así una fuente posterior no pisa un dato real con un vacío.
const perfiles = [
aplanarPerfil(cliente?.payloadJson),
...[capturaBase, ...relacionadas].map((c) => aplanarPerfil(c?.datosJson)),
]
const dePerfil = (...claves: string[]): string => {
for (const clave of claves) {
for (const perfil of perfiles) {
const valor = (perfil[clave] ?? '').trim()
if (valor !== '') return valor
}
}
return ''
}
const contacto: Record<string, string> = const contacto: Record<string, string> =
cliente !== null cliente !== null
? { ? {
@@ -252,14 +406,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
telefono: cliente.celular ?? '', telefono: cliente.celular ?? '',
email: cliente.email ?? '', email: cliente.email ?? '',
estado: 'Cliente', estado: 'Cliente',
curp: cliente.curp, curp: cliente.curp || dePerfil('curp'),
rfc: cliente.rfc, rfc: cliente.rfc || dePerfil('rfc'),
estadoCivil: dePerfil('estadoCivil'),
fechaNacimiento: dePerfil('fechaNacimiento'),
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
direccion: dePerfil('direccion'),
} }
: { : {
nombre: capturaBase!.nombreLead, nombre: capturaBase!.nombreLead,
telefono: capturaBase!.telefono ?? '', telefono: capturaBase!.telefono ?? '',
email: capturaBase!.email ?? '', email: capturaBase!.email ?? '',
estado: capturaBase!.etapa, estado: capturaBase!.etapa,
curp: dePerfil('curp'),
rfc: dePerfil('rfc'),
estadoCivil: dePerfil('estadoCivil'),
fechaNacimiento: dePerfil('fechaNacimiento'),
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
direccion: dePerfil('direccion'),
} }
return { contacto, propiedad, relaciones, historial } return { contacto, propiedad, relaciones, historial }
@@ -297,12 +461,17 @@ export async function exportarCSV(
} }
const filas: FilaCSV[] = [] const filas: FilaCSV[] = []
// Dedupe en dos pasos (igual que el listado): por teléfono y, cuando no hay
// teléfono, por nombre normalizado — antes un contacto sin teléfono salía
// duplicado en el CSV.
const vistos = new Set<string>() const vistos = new Set<string>()
const claveNombre = (nombre: string): string => nombre.trim().toLowerCase().replace(/\s+/g, ' ')
for (const c of clientes) { for (const c of clientes) {
const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10) const claveTelefono = (c.celular ?? '').replace(/\D/g, '').slice(-10)
if (clave !== '' && vistos.has(clave)) continue const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(c.nombreCompleto)}`
if (clave !== '') vistos.add(clave) if (vistos.has(clave)) continue
vistos.add(clave)
filas.push({ filas.push({
Nombre: c.nombreCompleto, Nombre: c.nombreCompleto,
Teléfono: c.celular ?? '', Teléfono: c.celular ?? '',
@@ -314,9 +483,10 @@ export async function exportarCSV(
} }
for (const cap of capturas) { for (const cap of capturas) {
const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10) const claveTelefono = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
if (clave !== '' && vistos.has(clave)) continue const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(cap.nombreLead)}`
if (clave !== '') vistos.add(clave) if (vistos.has(clave)) continue
vistos.add(clave)
filas.push({ filas.push({
Nombre: cap.nombreLead, Nombre: cap.nombreLead,
Teléfono: cap.telefono ?? '', Teléfono: cap.telefono ?? '',

View File

@@ -122,7 +122,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0, N = 4)', () => {
}) })
describe('SENDA — unidad sin fecha de entrega', () => { describe('SENDA — unidad sin fecha de entrega', () => {
it('N = null, mensualidades "—" y total pagado null', () => { it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => {
const sinFecha = unidad({ const sinFecha = unidad({
codigo: 'SENDA-411-25-A101', codigo: 'SENDA-411-25-A101',
desarrollo: 'SENDA', desarrollo: 'SENDA',
@@ -131,10 +131,12 @@ describe('SENDA — unidad sin fecha de entrega', () => {
precioConAcabados: 1948000, precioConAcabados: 1948000,
}) })
const p = calcularPropuesta(sinFecha, 'BANCARIO', false, APARTADO_DEFAULT) const p = calcularPropuesta(sinFecha, 'BANCARIO', false, APARTADO_DEFAULT)
expect(p.numMensualidades).toBeNull() // Antes salía numMensualidades/mensualidad null y "— (sin fecha de entrega)"
expect(p.mensualidad).toBeNull() expect(p.numMensualidades).toBe(6) // nDefault de SENDA
expect(p.notaMensualidades).toBe('sin fecha de entrega') expect(p.notaMensualidades).toBeUndefined()
expect(totalPagado(p, APARTADO_DEFAULT)).toBeNull() expect(p.mensualidadesTotal).toBeCloseTo(97400, 2)
expect(p.mensualidad).toBeCloseTo(97400 / 6, 4)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2)
expect(p.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2) expect(p.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
expect(p.pagoFinal).toBeCloseTo(0.9 * 1948000, 2) expect(p.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
}) })
@@ -149,12 +151,13 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => {
mesesParaFirma: 1, mesesParaFirma: 1,
}) })
it('INFONAVIT con enganche 0% (guarda: no resta el apartado)', () => { it('INFONAVIT con enganche 0%: el apartado se descuenta (no se cobra encima del precio)', () => {
const p = calcularPropuesta(casa6, 'INFONAVIT', false, APARTADO_DEFAULT) const p = calcularPropuesta(casa6, 'INFONAVIT', false, APARTADO_DEFAULT)
expect(p.enganche).toBe(0) expect(p.enganche).toBe(0)
expect(p.numMensualidades).toBe(1) expect(p.numMensualidades).toBe(1)
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2) expect(p.mensualidadesTotal).toBeCloseTo(108000, 2)
expect(p.pagoFinal).toBeCloseTo(2432000, 2) expect(p.pagoFinal).toBeCloseTo(2432000, 2)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2560000, 2)
}) })
it('BANCARIO 5% sobre final', () => { it('BANCARIO 5% sobre final', () => {
@@ -164,3 +167,82 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => {
expect(p.pagoFinal).toBeCloseTo(2304000, 2) expect(p.pagoFinal).toBeCloseTo(2304000, 2)
}) })
}) })
describe('cuadre del total: apartado y enganche personalizado', () => {
const TIPOS = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO'] as const
const CASOS = [
{ desarrollo: 'DIAGONAL', precio: 4_415_000 },
{ desarrollo: 'ATELIER', precio: 3_997_500 },
{ desarrollo: 'ESTANZA', precio: 2_437_250 },
{ desarrollo: 'SENDA', precio: 2_451_750 },
{ desarrollo: 'SOLANA', precio: 2_560_000 },
]
const unidadDe = (desarrollo: string, precio: number) =>
unidad({
codigo: `${desarrollo}-1`,
desarrollo,
precioSinAcabados: precio,
precioConAcabados: precio,
})
it('las 4 propuestas de cada desarrollo cuadran con su precio final', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
const p = calcularPropuesta(unidadDe(caso.desarrollo, caso.precio), tipo, false, APARTADO_DEFAULT)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(p.precioFinal, 2)
}
}
})
it('nunca hay bloques negativos y el total es el precio (apartados y enganches extremos)', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
for (const apartado of [0, APARTADO_DEFAULT, 200_000]) {
for (const enganchePct of [undefined, 0.05, 0.2, 0.5, 1]) {
const p = calcularPropuesta(
unidadDe(caso.desarrollo, caso.precio),
tipo,
false,
apartado,
undefined,
enganchePct,
)
expect(p.enganche).toBeGreaterThanOrEqual(0)
expect(p.mensualidadesTotal ?? 0).toBeGreaterThanOrEqual(0)
expect(p.pagoFinal).toBeGreaterThanOrEqual(0)
expect(totalPagado(p, apartado)).toBeCloseTo(p.precioFinal, 2)
}
}
}
}
})
it('enganche personalizado alto (20%) no infla el total: sigue siendo el precio', () => {
const u101 = unidad({
codigo: 'U-101',
desarrollo: 'DIAGONAL',
precioSinAcabados: 4_415_000,
precioConAcabados: 4_579_000,
})
const p = calcularPropuesta(u101, 'BANCARIO', false, APARTADO_DEFAULT, undefined, 0.2)
// Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2)
expect(p.mensualidadesTotal).toBe(0)
expect(p.pagoFinal).toBeCloseTo(4_415_000 - APARTADO_DEFAULT - p.enganche, 2)
})
it('apartado mayor al enganche: el enganche queda en 0 (nunca negativo) y el total cuadra', () => {
const u101 = unidad({
codigo: 'U-101',
desarrollo: 'DIAGONAL',
precioSinAcabados: 4_415_000,
precioConAcabados: 4_579_000,
})
// 2.5% de 4,415,000 = 110,375; con apartado 200,000 el neto sería −89,625
const p = calcularPropuesta(u101, 'BANCARIO', false, 200_000)
expect(p.enganche).toBe(0)
// El apartado de más se acredita en las mensualidades (remanente real)
expect(p.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - p.pagoFinal, 2)
expect(totalPagado(p, 200_000)).toBeCloseTo(4_415_000, 2)
})
})

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from 'vitest'
import {
APARTADO_DEFAULT,
DESARROLLOS as DESARROLLOS_LOCALES,
calcularPropuesta,
calcularPropuestas,
totalPagado,
type DescuentosPropuestas,
type ParametrosDesarrollo,
type TipoPropuestaApi,
type UnidadCotizable,
} from './cotizacion.service.js'
import { normalizarReglas } from './reglas-desarrollo.service.js'
/**
* Compatibilidad de las reglas configurables: cotizar con las reglas que llegan
* de la BD (mismo JSON que guarda `ReglaCotizadorDesarrollo`, validado por
* `normalizarReglas`) debe dar EXACTAMENTE los mismos números que con las
* constantes históricas. Unidades: 3 reales por desarrollo del inventario.
*/
function unidad(parcial: Partial<UnidadCotizable> & Pick<UnidadCotizable, 'codigo' | 'desarrollo'>): UnidadCotizable {
return {
modelo: 'X',
fechaEntrega: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: 0,
precioConAcabados: 0,
status: 'DISPONIBLE',
...parcial,
}
}
const UNIDADES: UnidadCotizable[] = [
// DIAGONAL (precio varía con acabados)
unidad({ codigo: 'U-101', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 4415000, precioConAcabados: 4579000 }),
unidad({ codigo: 'U-103', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 3107108.39, precioConAcabados: 3269000 }),
unidad({ codigo: 'U-201', desarrollo: 'DIAGONAL', fechaEntrega: null, precioSinAcabados: 3990000, precioConAcabados: 4150000 }),
// ATELIER (precio único, N fijo = 1)
unidad({ codigo: 'ATELIER-10', desarrollo: 'ATELIER', precioSinAcabados: 3997500, precioConAcabados: 3997500 }),
unidad({ codigo: 'ATELIER-17', desarrollo: 'ATELIER', precioSinAcabados: 3554291, precioConAcabados: 3554291 }),
unidad({ codigo: 'ATELIER-27', desarrollo: 'ATELIER', precioSinAcabados: 2304339, precioConAcabados: 2304339 }),
// ESTANZA (pago final sobre precio de LISTA: el "quirk" del Excel)
unidad({ codigo: 'ESTANZA-17', desarrollo: 'ESTANZA', precioSinAcabados: 2617632, precioConAcabados: 2617632 }),
unidad({ codigo: 'ESTANZA-19', desarrollo: 'ESTANZA', precioSinAcabados: 2340562, precioConAcabados: 2340562 }),
unidad({ codigo: 'ESTANZA-25', desarrollo: 'ESTANZA', precioSinAcabados: 2501683, precioConAcabados: 2501683 }),
// SENDA (N por fecha de entrega; hay piezas sin fecha → nDefault)
unidad({ codigo: 'SENDA-409-1-2', desarrollo: 'SENDA', fechaEntrega: '2026-12-19', precioSinAcabados: 2700000, precioConAcabados: 2700000 }),
unidad({ codigo: 'SENDA-411-1-7', desarrollo: 'SENDA', fechaEntrega: null, precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
unidad({ codigo: 'SENDA-409-1-4', desarrollo: 'SENDA', fechaEntrega: '2026-11-30', precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
// SOLANA (N por unidad: meses para firma; en el espejo viene null → nDefault = 1)
unidad({ codigo: 'SOLANA-6', desarrollo: 'SOLANA', precioSinAcabados: 2560000, precioConAcabados: 2560000 }),
unidad({ codigo: 'SOLANA-7', desarrollo: 'SOLANA', precioSinAcabados: 2730000, precioConAcabados: 2730000 }),
unidad({ codigo: 'SOLANA-9', desarrollo: 'SOLANA', precioSinAcabados: 2990445, precioConAcabados: 2990445 }),
]
const TIPOS: TipoPropuestaApi[] = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']
const DESCUENTOS: DescuentosPropuestas = {
creditoBancario: 0.01,
creditoInfonavit: 0.005,
contado: 0.03,
semiContado: 0.015,
}
/**
* Reglas "de BD": el JSON serializado tal como lo guarda el admin, con los
* campos extra que agrega el módulo de administración (nombre, activo, flags).
* Pasa por `normalizarReglas`, el mismo camino que usa el API al leerlas.
*/
function reglasDeBd(desarrollo: string): ParametrosDesarrollo {
const constantes = DESARROLLOS_LOCALES[desarrollo]!
const json = JSON.parse(
JSON.stringify({
...constantes,
nombre: desarrollo,
activoEnCotizador: true,
propuestas: constantes.propuestas.map((p) => ({ ...p, activa: true })),
}),
) as unknown
const reglas = normalizarReglas(desarrollo, json)
if (!reglas) throw new Error(`Las reglas de BD de ${desarrollo} deberían ser válidas`)
return reglas
}
describe('reglas de BD vs constantes: mismos números', () => {
it('las 4 propuestas de cada unidad dan resultados idénticos', () => {
for (const u of UNIDADES) {
const reglas = reglasDeBd(u.desarrollo)
for (const acabados of [false, true]) {
const conConstantes = calcularPropuestas({
unidad: u,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
})
const conBd = calcularPropuestas({
unidad: u,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
reglas,
})
expect(conBd).toHaveLength(conConstantes.length)
expect(conBd).toEqual(conConstantes)
for (const p of conBd) {
expect(totalPagado(p, APARTADO_DEFAULT)).not.toBeNull()
}
}
}
})
it('la cotización de una propuesta (con descuento, enganche y N del vendedor) es idéntica', () => {
for (const u of UNIDADES) {
const reglas = reglasDeBd(u.desarrollo)
for (const tipo of TIPOS) {
for (const apartado of [0, APARTADO_DEFAULT, 150_000]) {
const conConstantes = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12)
const conBd = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12, reglas)
expect(conBd).toEqual(conConstantes)
}
}
}
})
it('sin reglas en el parámetro se comporta como hoy (respaldo en constantes)', () => {
const u = UNIDADES[0]!
expect(calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, null)).toEqual(
calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT),
)
})
})
describe('reglas del admin que cambian el comportamiento', () => {
const u101 = UNIDADES[0]!
it('un descuento distinto en la BD se usa en el cálculo', () => {
const base = DESARROLLOS_LOCALES['DIAGONAL']!
const reglas = normalizarReglas('DIAGONAL', {
...base,
propuestas: base.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.05 } : p,
),
})!
const [bancario, , contado] = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.05, semiContado: 0 },
reglas,
})
expect(bancario?.precioFinal).toBeCloseTo(4_415_000, 2)
expect(contado?.descuento).toBe(0.05)
expect(contado?.precioFinal).toBeCloseTo(4_415_000 * 0.95, 2)
})
it('una propuesta desactivada no se calcula ni se puede elegir', () => {
const base = DESARROLLOS_LOCALES['DIAGONAL']!
const reglas = normalizarReglas('DIAGONAL', {
...base,
propuestas: base.propuestas.map((p) => ({ ...p, activa: p.tipo !== 'CONTADO' })),
})!
const propuestas = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
reglas,
})
expect(propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
expect(() =>
calcularPropuesta(u101, 'CONTADO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas),
).toThrow(/desactivada/)
})
it('un cambio de parámetros del Excel se refleja (pctEnganche / base del pago final)', () => {
const base = DESARROLLOS_LOCALES['ESTANZA']!
const reglas = normalizarReglas('ESTANZA', {
...base,
propuestas: base.propuestas.map((p) =>
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.1, pagoFinalBase: 'final' } : p,
),
})!
const casa = UNIDADES.find((u) => u.codigo === 'ESTANZA-17')!
const p = calcularPropuesta(casa, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas)
// 10% de enganche sobre precio final, pago final 90% sobre final
expect(p.enganche).toBeCloseTo(0.1 * 2617632 - APARTADO_DEFAULT, 2)
expect(p.pagoFinal).toBeCloseTo(0.9 * 2617632, 2)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2617632, 2)
})
})

View File

@@ -48,10 +48,10 @@ export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
// ---------- Parametrización por desarrollo ---------- // ---------- Parametrización por desarrollo ----------
type BasePrecio = 'lista' | 'final' export type BasePrecio = 'lista' | 'final'
type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje' export type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
interface PropuestaConfig { export interface PropuestaConfig {
tipo: TipoPropuesta tipo: TipoPropuesta
claveDescuento: keyof DescuentosPropuestas claveDescuento: keyof DescuentosPropuestas
titulo: string titulo: string
@@ -62,13 +62,35 @@ interface PropuestaConfig {
pagoFinalBase: BasePrecio pagoFinalBase: BasePrecio
modoMensualidades: ModoMensualidades modoMensualidades: ModoMensualidades
engancheCeroSinApartado?: boolean engancheCeroSinApartado?: boolean
/**
* Regla del admin: false => la propuesta no se ofrece ni se puede elegir en
* el cotizador. Ausente/true => vigente (comportamiento histórico).
*/
activa?: boolean
} }
interface ParametrosDesarrollo { export interface ParametrosDesarrollo {
id: string id: string
/** Nombre para mostrar (solo lo traen las reglas guardadas por el admin) */
nombre?: string
precioUnico: boolean precioUnico: boolean
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad' fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
nFijo?: number nFijo?: number
/**
* N por defecto del desarrollo: se usa cuando el inventario no trae fecha de
* entrega ni meses para firma. Garantiza que la mensualidad y el total
* siempre se calculen (espejo de apps/cotizador/src/data/desarrollos.ts).
*/
nDefault?: number
/** Cuota mensual de mantenimiento (MXN); null => pendiente */
cuotaMantenimiento?: number | null
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
prefijoUnidadPdf?: string
/**
* Regla del admin: false => el desarrollo no se ofrece en el cotizador.
* Ausente/true => disponible (comportamiento histórico).
*/
activoEnCotizador?: boolean
propuestas: PropuestaConfig[] propuestas: PropuestaConfig[]
} }
@@ -81,6 +103,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
id: 'DIAGONAL', id: 'DIAGONAL',
precioUnico: false, precioUnico: false,
fuenteDeN: 'fechaEntrega', fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega (mediana del
// inventario disponible 2026-09: 13 meses hasta la terminación de obra)
nDefault: 13,
propuestas: [ propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' }, { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'remanenteLista' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'remanenteLista' },
@@ -116,6 +141,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
id: 'SENDA', id: 'SENDA',
precioUnico: true, precioUnico: true,
fuenteDeN: 'fechaEntrega', fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega (mediana del
// inventario disponible 2026-09: 6 mensualidades)
nDefault: 6,
propuestas: [ propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
@@ -127,6 +155,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
id: 'SOLANA', id: 'SOLANA',
precioUnico: true, precioUnico: true,
fuenteDeN: 'porUnidad', fuenteDeN: 'porUnidad',
// Último recurso si la unidad no trae "meses para firma" (las casas
// vendibles del inventario Excel están en 1 mes)
nDefault: 1,
propuestas: [ propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' }, { tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje', engancheCeroSinApartado: true }, { tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje', engancheCeroSinApartado: true },
@@ -138,10 +169,23 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
const DESARROLLO_DEFAULT = 'DIAGONAL' const DESARROLLO_DEFAULT = 'DIAGONAL'
function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo { /**
* Reglas comerciales del desarrollo: las configuradas por el admin (BD) ganan
* cuando corresponden al id pedido; si no, se usan las constantes del módulo.
*/
function obtenerDesarrollo(
id?: string | null,
reglas?: ParametrosDesarrollo | null,
): ParametrosDesarrollo {
if (reglas && (id === undefined || id === null || id === '' || reglas.id === id)) return reglas
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]! return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]!
} }
/** Propuestas vigentes: el admin puede desactivar una propuesta por desarrollo */
export function propuestasVigentes(params: ParametrosDesarrollo): PropuestaConfig[] {
return params.propuestas.filter((p) => p.activa !== false)
}
export interface PropuestaResultado { export interface PropuestaResultado {
tipo: TipoPropuesta tipo: TipoPropuesta
titulo: string titulo: string
@@ -162,6 +206,11 @@ export interface CotizacionInput {
apartado: number apartado: number
descuentos: DescuentosPropuestas descuentos: DescuentosPropuestas
desarrollo?: string desarrollo?: string
/**
* Reglas comerciales efectivas del desarrollo (las configuradas por el admin
* o su respaldo en constantes). Si no vienen, se usan las constantes.
*/
reglas?: ParametrosDesarrollo
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */ /** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
numMeses?: number numMeses?: number
} }
@@ -174,7 +223,9 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES)) return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
} }
/** Resuelve N según la fuente del desarrollo (null si no se puede conocer) */ /** Resuelve N según la fuente del desarrollo; cae al plazo por defecto del
* desarrollo (`nDefault`) cuando el inventario no trae el dato, para que la
* mensualidad y el total SIEMPRE se calculen. */
export function resolverNumMensualidades( export function resolverNumMensualidades(
unidad: UnidadCotizable, unidad: UnidadCotizable,
params: ParametrosDesarrollo, params: ParametrosDesarrollo,
@@ -183,9 +234,11 @@ export function resolverNumMensualidades(
case 'fijo': case 'fijo':
return params.nFijo ?? 1 return params.nFijo ?? 1
case 'porUnidad': case 'porUnidad':
return unidad.mesesParaFirma ?? null return unidad.mesesParaFirma ?? params.nDefault ?? null
case 'fechaEntrega': case 'fechaEntrega':
return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null return unidad.fechaEntrega
? calcularNumMensualidades(unidad.fechaEntrega)
: (params.nDefault ?? null)
} }
} }
@@ -196,6 +249,42 @@ function baseComun(precioLista: number, descuento: number) {
return { montoDescuento, precioFinal } return { montoDescuento, precioFinal }
} }
/** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */
interface BloquesPago {
enganche: number
mensualidades: number | null
pagoFinal: number
}
/**
* Cuadra los bloques para que apartado + enganche + mensualidades + pago final
* sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la
* propuesta), aunque el apartado o un enganche personalizado descuadren las
* fórmulas:
* - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando
* el apartado supera el enganche, ni mensualidades negativas con enganche
* alto);
* - el excedente se recorta de las mensualidades (bloque flexible del
* remanente) y, si no alcanza, del pago final. Nunca se suma por encima del
* precio.
*/
function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago {
const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal
const enganche = Math.max(0, bloques.enganche)
let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades)
let pagoFinal = Math.max(0, bloques.pagoFinal)
let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total
if (excedente > 0 && mensualidades !== null) {
const recorte = Math.min(mensualidades, excedente)
mensualidades -= recorte
excedente -= recorte
}
if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente)
return { enganche, mensualidades, pagoFinal }
}
/** Calcula una propuesta comercial con los parámetros dados */ /** Calcula una propuesta comercial con los parámetros dados */
function calcularPropuestaConfig( function calcularPropuestaConfig(
cfg: PropuestaConfig, cfg: PropuestaConfig,
@@ -217,17 +306,21 @@ function calcularPropuestaConfig(
cfg.pctEnganche <= 0.05 cfg.pctEnganche <= 0.05
const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche
// Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de
// SOLANA lo deja en 0 cuando el % es 0
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
const enganche = const engancheBruto =
cfg.engancheCeroSinApartado && pctEnganche === 0 cfg.engancheCeroSinApartado && pctEnganche === 0
? 0 ? 0
: pctEnganche * baseEnganche - apartado : pctEnganche * baseEnganche
// Fórmula del Excel: enganche neto del apartado (puede salir negativo si el
// apartado supera el enganche; cuadrarBloques lo acota a 0)
const engancheExcel = engancheBruto - apartado
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
const pagoFinal = cfg.pctPagoFinal * basePagoFinal const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal
let mensualidadesTotal: number | null = null let mensualidadesExcel: number | null = null
let mensualidad: number | null = null
let notaMensualidades: string | undefined let notaMensualidades: string | undefined
if (cfg.modoMensualidades !== 'ninguna') { if (cfg.modoMensualidades !== 'ninguna') {
if (n === null) { if (n === null) {
@@ -235,22 +328,26 @@ function calcularPropuestaConfig(
} else { } else {
switch (cfg.modoMensualidades) { switch (cfg.modoMensualidades) {
case 'remanente': case 'remanente':
mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel
break break
case 'remanenteLista': case 'remanenteLista':
// Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal // Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel
break break
case 'porcentaje': case 'porcentaje':
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal) mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
break break
} }
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
mensualidadesTotal = Math.max(0, mensualidadesTotal)
mensualidad = mensualidadesTotal / n
} }
} }
const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, {
enganche: engancheExcel,
mensualidades: mensualidadesExcel,
pagoFinal: pagoFinalExcel,
})
const mensualidad = mensualidades === null || n === null ? null : mensualidades / n
return { return {
tipo: cfg.tipo, tipo: cfg.tipo,
titulo: cfg.titulo, titulo: cfg.titulo,
@@ -260,7 +357,7 @@ function calcularPropuestaConfig(
enganche, enganche,
numMensualidades: n, numMensualidades: n,
mensualidad, mensualidad,
mensualidadesTotal, mensualidadesTotal: mensualidades,
pagoFinal, pagoFinal,
...(notaMensualidades !== undefined ? { notaMensualidades } : {}), ...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
} }
@@ -293,13 +390,16 @@ export function indiceMenorTotal(totales: number[]): number {
return indice return indice
} }
/** Calcula las propuestas comerciales del desarrollo de la unidad */ /** Calcula las propuestas comerciales vigentes del desarrollo de la unidad */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] { export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input const { unidad, acabados, apartado, descuentos } = input
const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT) const params = obtenerDesarrollo(
input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT,
input.reglas ?? null,
)
const precioLista = obtenerPrecioLista(unidad, acabados) const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? resolverNumMensualidades(unidad, params) const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
return params.propuestas.map((cfg) => return propuestasVigentes(params).map((cfg) =>
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n), calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n),
) )
} }
@@ -321,6 +421,10 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
* `descuento` opcional (fracción); si no viene, usa el default de la propuesta. * `descuento` opcional (fracción); si no viene, usa el default de la propuesta.
* `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con * `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con
* crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo. * crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo.
* `numMeses` opcional: número de mensualidades elegido por el vendedor en el
* cotizador; si no viene se resuelve con la fuente del desarrollo.
* `reglas` opcional: reglas comerciales efectivas del desarrollo de la unidad
* (BD del admin con respaldo en constantes); sin ellas se usan las constantes.
*/ */
export function calcularPropuesta( export function calcularPropuesta(
unidad: UnidadCotizable, unidad: UnidadCotizable,
@@ -329,15 +433,22 @@ export function calcularPropuesta(
apartado: number = APARTADO_DEFAULT, apartado: number = APARTADO_DEFAULT,
descuento?: number, descuento?: number,
enganchePct?: number, enganchePct?: number,
numMeses?: number,
reglas?: ParametrosDesarrollo | null,
): PropuestaResultado { ): PropuestaResultado {
const params = obtenerDesarrollo(unidad.desarrollo) const params = obtenerDesarrollo(unidad.desarrollo, reglas ?? null)
const tipoInterno = TIPO_PROPUESTA_API[tipo] const tipoInterno = TIPO_PROPUESTA_API[tipo]
const cfg = params.propuestas.find((p) => p.tipo === tipoInterno) const cfg = propuestasVigentes(params).find((p) => p.tipo === tipoInterno)
if (!cfg) { if (!cfg) {
throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`) const desactivada = params.propuestas.some((p) => p.tipo === tipoInterno)
throw new Error(
desactivada
? `La propuesta ${tipo} está desactivada para el desarrollo ${params.id}`
: `La propuesta ${tipo} no aplica para el desarrollo ${params.id}`,
)
} }
const precioLista = obtenerPrecioLista(unidad, acabados) const precioLista = obtenerPrecioLista(unidad, acabados)
const n = resolverNumMensualidades(unidad, params) const n = numMeses ?? resolverNumMensualidades(unidad, params)
const d = descuento ?? cfg.descuentoDefault const d = descuento ?? cfg.descuentoDefault
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null) return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null)
} }

View File

@@ -344,8 +344,16 @@ export class CrmLocalService {
* oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de * oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de
* board. Idempotente con el mismo dedup `apartado-creado:<opId>` de las * board. Idempotente con el mismo dedup `apartado-creado:<opId>` de las
* automatizaciones. * automatizaciones.
*
* `opciones.unidadCodigo` deja la unidad apartada en el `datosJson` del
* apartado (la cotización del vendedor o la unidad de interés): es lo que
* después leen el bloqueo de inventario y Mi día.
*/ */
async apartar(oportunidadIdLocal: string, usuarioId?: string) { async apartar(
oportunidadIdLocal: string,
usuarioId?: string,
opciones: { unidadCodigo?: string | undefined } = {},
) {
// Sombra para ids heredados sin fila local // Sombra para ids heredados sin fila local
const oportunidad = await this.filaLocal( const oportunidad = await this.filaLocal(
oportunidadIdLocal, oportunidadIdLocal,
@@ -362,6 +370,7 @@ export class CrmLocalService {
} }
const apartadoId = idLocal() const apartadoId = idLocal()
const datosJson = conUnidadCodigo(oportunidad.datosJson, opciones.unidadCodigo)
const resultados = await prisma.$transaction([ const resultados = await prisma.$transaction([
prisma.leadCaptura.update({ prisma.leadCaptura.update({
where: { leadId: oportunidadIdLocal }, where: { leadId: oportunidadIdLocal },
@@ -379,11 +388,11 @@ export class CrmLocalService {
modelo: oportunidad.modelo, modelo: oportunidad.modelo,
board: 'APARTADO', board: 'APARTADO',
etapa: 'Pago Apartado', etapa: 'Pago Apartado',
datosJson: oportunidad.datosJson, datosJson,
// Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados // Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados
// por vencer", el cron de avisos y la lista de Mi día (antes sólo se // por vencer", el cron de avisos y la lista de Mi día (antes sólo se
// calculaba en el JSON del alta y la columna quedaba vacía). // calculaba en el JSON del alta y la columna quedaba vacía).
fechaLimite: fechaLimiteApartado(oportunidad.datosJson), fechaLimite: fechaLimiteApartado(datosJson),
pendienteSync: true, pendienteSync: true,
}, },
}), }),
@@ -472,6 +481,25 @@ function soloDigitos(telefono: string): string {
return telefono.replace(/\D/g, '') return telefono.replace(/\D/g, '')
} }
/**
* `datosJson` con la unidad apartada dentro (mezcla, no reemplaza: conserva
* fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo
* devuelve el JSON original tal cual.
*/
function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): string | null {
const unidad = unidadCodigo?.trim()
if (!unidad) return datosJson
let datos: Record<string, unknown> = {}
if (datosJson) {
try {
datos = JSON.parse(datosJson) as Record<string, unknown>
} catch {
datos = {}
}
}
return JSON.stringify({ ...datos, unidadCodigo: unidad })
}
/** /**
* Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero. * Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero.
* Así el mismo número no se duplica entre Leads, Oportunidades y Apartados. * Así el mismo número no se duplica entre Leads, Oportunidades y Apartados.

View File

@@ -0,0 +1,496 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
desarrollo: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
modeloDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
reglaCotizadorDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
upsert: vi.fn(),
count: vi.fn(),
},
unidad: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
cotizacion: { count: vi.fn() },
leadCaptura: { count: vi.fn() },
cliente: { count: vi.fn() },
planPago: { count: vi.fn() },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import {
actualizarDesarrollo,
agregarModelo,
crearDesarrollo,
crearUnidades,
crearUnidadesSchema,
eliminarDesarrollo,
eliminarModelo,
eliminarUnidad,
guardarReglas,
listarDesarrollos,
listarDesarrollosPublicos,
obtenerDesarrolloPublico,
parametrosDesarrolloSchema,
} from './desarrollos.service.js'
/** Fila de Desarrollo tal como la devuelve Prisma (con relaciones) */
function filaDesarrollo(over: Record<string, unknown> = {}) {
return {
id: 'd-1',
clave: 'SENDA',
nombre: 'Senda',
estado: 'ACTIVO',
tipo: 'VERTICAL',
constructora: null,
direccion: null,
zona: 'Zapopan',
descripcion: null,
amenidades: 'alberca, gym',
fechaInicioObra: null,
fechaEntregaProgramada: new Date('2026-12-01T00:00:00.000Z'),
fechaEntregaReprogramada: null,
activoEnCotizador: true,
activoEnAlta: true,
prefijoUnidadPdf: 'CASA',
imagenUrl: null,
logoUrl: null,
createdAt: new Date('2026-09-21T00:00:00.000Z'),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
modelos: [],
reglas: null,
...over,
}
}
function filaUnidad(over: Record<string, unknown> = {}) {
return {
id: 'u-1',
codigo: 'SENDA-1',
desarrollo: 'SENDA',
modelo: 'HIMALAYA',
manzana: '',
lote: '',
nivel: '',
supConstruccion: 120,
supExcedente: 0,
supEstacionamiento: 1,
precioLista: 3000000,
precioAcabados: 3200000,
status: 'DISPONIBLE',
etapa: '',
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...over,
}
}
const REGLAS_VALIDAS = {
precioUnico: true,
fuenteDeN: 'fechaEntrega' as const,
nDefault: 6,
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [
{
tipo: 'CONTADO' as const,
claveDescuento: 'contado' as const,
titulo: 'Contado',
descuentoDefault: 0.02,
pctEnganche: 0.9,
engancheBase: 'final' as const,
pctPagoFinal: 0.1,
pagoFinalBase: 'final' as const,
modoMensualidades: 'ninguna' as const,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.count.mockResolvedValue(0)
prismaMock.cotizacion.count.mockResolvedValue(0)
prismaMock.leadCaptura.count.mockResolvedValue(0)
prismaMock.cliente.count.mockResolvedValue(0)
prismaMock.planPago.count.mockResolvedValue(0)
// create devuelve lo que recibe (más id/updatedAt), como Prisma
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: `id-${String(data['codigo'])}`,
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...data,
}))
})
// ── Fichas ────────────────────────────────────────────────────────────
describe('crearDesarrollo', () => {
it('rechaza una clave duplicada con 409', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
await expect(crearDesarrollo({ clave: 'SENDA', nombre: 'Senda' })).rejects.toMatchObject({
status: 409,
message: 'Ya existe un desarrollo con la clave SENDA',
})
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
})
it('normaliza la clave a mayúsculas y aplica los defaults', async () => {
prismaMock.desarrollo.findUnique
.mockResolvedValueOnce(null) // no existe la clave
.mockResolvedValueOnce(filaDesarrollo()) // lectura de la ficha creada
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
const ficha = await crearDesarrollo({ clave: ' senda ', nombre: 'Senda' })
expect(prismaMock.desarrollo.create).toHaveBeenCalledWith({
data: expect.objectContaining({
clave: 'SENDA',
nombre: 'Senda',
estado: 'ACTIVO',
tipo: 'VERTICAL',
activoEnCotizador: true,
activoEnAlta: true,
}),
})
expect(ficha.clave).toBe('SENDA')
})
})
describe('listarDesarrollos', () => {
it('cuenta unidades totales/disponibles y expone tieneReglas', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({ reglas: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedAt: new Date(), updatedPorId: null } }),
filaDesarrollo({ id: 'd-2', clave: 'SOLE', nombre: 'Sole' }),
])
prismaMock.unidad.findMany.mockResolvedValue([
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
{ desarrollo: 'SENDA', status: 'VENDIDO' },
{ desarrollo: 'SOLE', status: 'disponible' },
])
const { desarrollos } = await listarDesarrollos()
expect(desarrollos[0]).toMatchObject({ clave: 'SENDA', unidadesTotales: 2, unidadesDisponibles: 1, tieneReglas: true })
expect(desarrollos[1]).toMatchObject({ clave: 'SOLE', unidadesTotales: 1, unidadesDisponibles: 1, tieneReglas: false })
})
})
describe('actualizarDesarrollo', () => {
it('404 si el desarrollo no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(actualizarDesarrollo('nope', { nombre: 'X' })).rejects.toMatchObject({ status: 404 })
})
it('no deja cambiar la clave si tiene unidades', async () => {
prismaMock.desarrollo.findUnique
.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' })
.mockResolvedValueOnce(null) // clave destino libre
prismaMock.unidad.count.mockResolvedValue(4)
await expect(actualizarDesarrollo('d-1', { clave: 'SENDA2' })).rejects.toMatchObject({ status: 409 })
expect(prismaMock.desarrollo.update).not.toHaveBeenCalled()
})
})
// ── Borrado bloqueado ─────────────────────────────────────────────────
describe('eliminarDesarrollo', () => {
it('409 con el mensaje acordado si tiene inventario y operación', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
prismaMock.cotizacion.count.mockResolvedValue(7)
await expect(eliminarDesarrollo('d-1')).rejects.toMatchObject({
status: 409,
message: 'El desarrollo tiene 2 unidades y 7 registros de operación; desactívalo en vez de borrarlo',
})
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
})
it('borra si no hay inventario ni operación', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
await eliminarDesarrollo('d-1')
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
})
})
// ── Inventario ────────────────────────────────────────────────────────
describe('crearUnidades — generador', () => {
it('genera una unidad por número del rango con {clave} y {n}', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const resultado = await crearUnidades('d-1', {
generar: { patron: '{clave}-401-{n}', desde: 1, hasta: 3, precioLista: 2500000, supConstruccion: 88 },
})
expect(resultado.creadas).toBe(3)
expect(resultado.unidades.map((u) => u.codigo)).toEqual(['SENDA-401-1', 'SENDA-401-2', 'SENDA-401-3'])
expect(prismaMock.unidad.create).toHaveBeenCalledTimes(3)
expect(prismaMock.unidad.create).toHaveBeenCalledWith({
data: expect.objectContaining({
codigo: 'SENDA-401-1',
desarrollo: 'SENDA',
status: 'DISPONIBLE',
precioLista: 2500000,
supConstruccion: 88,
precioAcabados: 0,
}),
})
})
it('el esquema rechaza un patrón sin {n} y un body con ambos modos', () => {
expect(() => crearUnidadesSchema.parse({ generar: { patron: 'SENDA-401', desde: 1, hasta: 3, precioLista: 1 } })).toThrow()
expect(() =>
crearUnidadesSchema.parse({ unidades: [{ precioLista: 1 }], generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }),
).toThrow()
expect(() => crearUnidadesSchema.parse({})).toThrow()
})
it('rechaza un rango invertido', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
await expect(
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 5, hasta: 1, precioLista: 1 } }),
).rejects.toMatchObject({ status: 400 })
})
it('resuelve el modelo por id validando que sea del desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA' })
const resultado = await crearUnidades('d-1', {
generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-1', precioLista: 1 },
})
expect(resultado.unidades[0]?.modelo).toBe('HIMALAYA')
})
it('rechaza un modelo de otro desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-9', desarrolloId: 'd-9', nombre: 'AJENO' })
await expect(
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-9', precioLista: 1 } }),
).rejects.toMatchObject({ status: 400 })
})
})
describe('crearUnidades — alta manual y duplicados', () => {
it('arma el código con manzana y lote cuando no se envía', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const resultado = await crearUnidades('d-1', {
unidades: [{ manzana: '4', lote: '12', precioLista: 100, precioAcabados: 120 }],
})
expect(resultado.unidades[0]?.codigo).toBe('SENDA-4-12')
})
it('409 y no crea nada si un código ya existe en la BD', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany
.mockResolvedValueOnce([]) // codigos del desarrollo (correlativo)
.mockResolvedValueOnce([{ codigo: 'SENDA-9' }]) // ya existe
await expect(
crearUnidades('d-1', { unidades: [{ codigo: 'SENDA-9', precioLista: 1 }] }),
).rejects.toMatchObject({ status: 409, message: 'Códigos de unidad duplicados: SENDA-9' })
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
expect(prismaMock.$transaction).not.toHaveBeenCalled()
})
it('409 si el lote repetido aparece dos veces en la misma alta', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([])
await expect(
crearUnidades('d-1', {
unidades: [
{ codigo: 'SENDA-7', precioLista: 1 },
{ codigo: 'SENDA-7', precioLista: 2 },
],
}),
).rejects.toMatchObject({ status: 409 })
})
})
describe('eliminarUnidad', () => {
it('409 si la unidad tiene cotizaciones o ventas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
prismaMock.cotizacion.count.mockResolvedValue(2)
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 409 })
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
})
it('404 si la unidad no pertenece al desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad({ desarrollo: 'SOLANA' }))
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 404 })
})
it('borra una unidad libre', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
await eliminarUnidad('d-1', 'SENDA-1')
expect(prismaMock.unidad.delete).toHaveBeenCalledWith({ where: { codigo: 'SENDA-1' } })
})
})
// ── Modelos ───────────────────────────────────────────────────────────
describe('eliminarModelo', () => {
it('409 si el modelo tiene unidades', async () => {
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
id: 'm-1',
nombre: 'HIMALAYA',
desarrollo: { clave: 'SENDA' },
})
prismaMock.unidad.count.mockResolvedValue(79)
await expect(eliminarModelo('m-1')).rejects.toMatchObject({ status: 409 })
expect(prismaMock.modeloDesarrollo.delete).not.toHaveBeenCalled()
})
it('borra el modelo sin unidades', async () => {
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
id: 'm-1',
nombre: 'NUEVO',
desarrollo: { clave: 'SENDA' },
})
await eliminarModelo('m-1')
expect(prismaMock.modeloDesarrollo.delete).toHaveBeenCalledWith({ where: { id: 'm-1' } })
})
})
describe('agregarModelo', () => {
it('409 si el nombre ya existe en el desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1' })
await expect(agregarModelo('d-1', { nombre: 'HIMALAYA' })).rejects.toMatchObject({ status: 409 })
})
})
// ── Reglas del cotizador ──────────────────────────────────────────────
describe('parametrosDesarrolloSchema', () => {
it('acepta la forma real del cotizador', () => {
expect(parametrosDesarrolloSchema.parse(REGLAS_VALIDAS)).toMatchObject({ precioUnico: true })
})
it('rechaza porcentajes fuera de 0..1', () => {
const invalido = {
...REGLAS_VALIDAS,
propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], pctEnganche: 1.5 }],
}
expect(() => parametrosDesarrolloSchema.parse(invalido)).toThrow()
})
it('rechaza enums desconocidos y claves de más', () => {
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, fuenteDeN: 'otra' })).toThrow()
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], modoMensualidades: 'raro' }] })).toThrow()
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, extra: 1 })).toThrow()
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], descuentoDefault: -1 }] })).toThrow()
})
})
describe('guardarReglas', () => {
it('hace upsert del JSON validado y devuelve el objeto parseado', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
desarrolloId: 'd-1',
parametrosJson: JSON.stringify(REGLAS_VALIDAS),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
updatedPorId: 'u-admin',
})
const guardadas = await guardarReglas('d-1', REGLAS_VALIDAS, 'u-admin')
expect(prismaMock.reglaCotizadorDesarrollo.upsert).toHaveBeenCalledWith({
where: { desarrolloId: 'd-1' },
update: { parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
create: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
})
expect(guardadas.parametros.precioUnico).toBe(true)
expect(guardadas.updatedPorId).toBe('u-admin')
})
it('404 si el desarrollo no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(guardarReglas('nope', REGLAS_VALIDAS, null)).rejects.toMatchObject({ status: 404 })
})
})
// ── Público ───────────────────────────────────────────────────────────
describe('listarDesarrollosPublicos', () => {
it('solo publica activos no cerrados, con inventario disponible y sin datos personales', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({
modelos: [
{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
],
}),
])
prismaMock.unidad.findMany.mockResolvedValue([
filaUnidad({ codigo: 'SENDA-1', nivel: '4' }),
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 999 }),
])
const { desarrollos } = await listarDesarrollosPublicos()
const d = desarrollos[0]
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith({
where: { activoEnCotizador: true, estado: { not: 'CERRADO' } },
include: expect.anything(),
orderBy: { clave: 'asc' },
})
expect(d?.unidadesDisponibles).toEqual([
{
codigo: 'SENDA-1',
modelo: 'HIMALAYA',
nivel: '4',
precioLista: 3000000,
precioAcabados: 3200000,
supConstruccion: 120,
},
])
expect(d?.modelos[0]).toMatchObject({ nombre: 'HIMALAYA', disponibles: 1, precioDesde: 3000000 })
// Nunca consulta tablas con datos de clientes y la ficha no trae esos campos
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
const json = JSON.stringify(desarrollos)
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado']) {
expect(json.toLowerCase()).not.toContain(prohibido)
}
})
it('404 si la clave no existe o no es pública', async () => {
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
await expect(obtenerDesarrolloPublico('NOEXISTE')).rejects.toMatchObject({ status: 404 })
})
})

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

View File

@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado (los tests no tocan BD) ----------
// `reglaCotizadorDesarrollo` y `desarrollo` se activan/desactivan por test:
// simulan el cliente Prisma con los modelos generados o sin ellos (lectura
// cruda de las tablas).
const prismaMock = vi.hoisted(() => ({
$queryRawUnsafe: vi.fn(),
reglaCotizadorDesarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
desarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import { DESARROLLOS, type ParametrosDesarrollo } from './cotizacion.service.js'
import {
getReglasDesarrollo,
getReglasEfectivas,
getReglasGuardadas,
normalizarReglas,
} from './reglas-desarrollo.service.js'
/** Reglas con la forma exacta que guarda la BD (JSON serializado) */
function reglasJson(params: ParametrosDesarrollo, extras: Record<string, unknown> = {}): string {
return JSON.stringify({
...params,
...extras,
propuestas: params.propuestas.map((p) => ({ ...p, activa: true })),
})
}
function usarModeloPrisma(): ReturnType<typeof vi.fn> {
const findMany = vi.fn()
prismaMock.reglaCotizadorDesarrollo = { findMany }
return findMany
}
/** Ficha del desarrollo (tabla Desarrollo) para el catálogo del servicio */
function ficha(id: string, clave: string, extras: Record<string, unknown> = {}) {
return {
id,
clave,
nombre: clave.charAt(0) + clave.slice(1).toLowerCase(),
activoEnCotizador: true,
prefijoUnidadPdf: 'CASA',
...extras,
}
}
function usarCatalogo(filas: unknown[]): void {
prismaMock.desarrollo = { findMany: vi.fn().mockResolvedValue(filas) }
}
/** Propuesta del fixture (JSON crudo) por tipo */
function brinda(propuestas: Record<string, unknown>[], tipo: string): Record<string, unknown> {
const p = propuestas.find((x) => x['tipo'] === tipo)
if (!p) throw new Error(`La propuesta ${tipo} no está en el fixture`)
return p
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.reglaCotizadorDesarrollo = undefined
prismaMock.desarrollo = undefined
prismaMock.$queryRawUnsafe.mockReset()
prismaMock.$queryRawUnsafe.mockResolvedValue([])
})
describe('normalizarReglas (JSON de la BD)', () => {
it('acepta el JSON con la forma de las constantes y conserva los valores', () => {
const reglas = normalizarReglas('DIAGONAL', JSON.parse(reglasJson(DESARROLLOS['DIAGONAL']!)))
expect(reglas).not.toBeNull()
expect(reglas?.id).toBe('DIAGONAL')
expect(reglas?.propuestas).toHaveLength(4)
expect(reglas?.propuestas[2]?.descuentoDefault).toBe(0.02)
})
it('marca la propuesta desactivada por el admin (activa/activo)', () => {
const bruto = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as {
propuestas: Record<string, unknown>[]
}
brinda(bruto.propuestas, 'CONTADO').activa = false
const reglas = normalizarReglas('SENDA', bruto)
expect(reglas?.propuestas.find((p) => p.tipo === 'CONTADO')?.activa).toBe(false)
})
it('rechaza JSON sin la forma esperada (precioUnico, propuestas, claves)', () => {
expect(normalizarReglas('DIAGONAL', null)).toBeNull()
expect(normalizarReglas('DIAGONAL', 'DIAGONAL')).toBeNull()
expect(normalizarReglas('DIAGONAL', { precioUnico: true })).toBeNull()
expect(
normalizarReglas('DIAGONAL', {
precioUnico: true,
fuenteDeN: 'fijo',
propuestas: [{ tipo: 'CONTADO' }],
}),
).toBeNull()
})
})
describe('getReglasGuardadas / getReglasEfectivas', () => {
it('sin filas guardadas devuelve las constantes actuales (compatibilidad)', async () => {
usarModeloPrisma().mockResolvedValue([])
const efectivas = await getReglasEfectivas()
expect(Object.keys(efectivas).sort()).toEqual(Object.keys(DESARROLLOS).sort())
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
expect(efectivas[id]).toEqual(constantes)
}
expect(await getReglasDesarrollo('SENDA')).toEqual(DESARROLLOS['SENDA'])
})
it('las reglas de la BD ganan a las constantes del desarrollo', async () => {
const conDescuento = {
...DESARROLLOS['DIAGONAL']!,
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.07 } : p,
),
}
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: reglasJson(conDescuento) },
])
const reglas = await getReglasDesarrollo('DIAGONAL')
const contado = reglas.propuestas.find((p) => p.tipo === 'CONTADO')
expect(contado?.descuentoDefault).toBe(0.07)
expect(DESARROLLOS['DIAGONAL']!.propuestas[2]?.descuentoDefault).toBe(0.02)
// Los demás desarrollos siguen en constantes
expect((await getReglasDesarrollo('SOLANA')).propuestas[1]?.pctEnganche).toBe(0)
})
it('usa la columna activoEnCotizador y también el campo dentro del JSON', async () => {
usarModeloPrisma().mockResolvedValue([
{
desarrolloId: 'SOLANA',
parametrosJson: reglasJson(DESARROLLOS['SOLANA']!),
activoEnCotizador: false,
},
{
desarrolloId: 'ESTANZA',
parametrosJson: reglasJson(DESARROLLOS['ESTANZA']!, { activoEnCotizador: false }),
},
])
expect((await getReglasDesarrollo('SOLANA')).activoEnCotizador).toBe(false)
expect((await getReglasDesarrollo('ESTANZA')).activoEnCotizador).toBe(false)
expect((await getReglasDesarrollo('DIAGONAL')).activoEnCotizador).toBeUndefined()
})
it('resuelve la clave del desarrollo por su id (las reglas apuntan al uuid) y lee su ficha', async () => {
usarModeloPrisma().mockResolvedValue([
{
desarrolloId: 'uuid-diagonal',
parametrosJson: reglasJson({ ...DESARROLLOS['DIAGONAL']!, nombre: undefined as never }),
},
])
usarCatalogo([
ficha('uuid-diagonal', 'DIAGONAL', { activoEnCotizador: false, nombre: 'Diagonal Residencial' }),
ficha('uuid-senda', 'SENDA'),
])
const reglas = await getReglasDesarrollo('DIAGONAL')
// La clave sale del catálogo (id → clave), no del uuid
expect(reglas.propuestas).toHaveLength(4)
// El nombre y la bandera salen de la ficha del desarrollo
expect(reglas.nombre).toBe('Diagonal Residencial')
expect(reglas.activoEnCotizador).toBe(false)
// Las reglas efectivas y el desarrollo activo siguen saliendo de constantes
const efectivas = await getReglasEfectivas()
expect(efectivas['SENDA']).toEqual(DESARROLLOS['SENDA'])
expect(efectivas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
})
it('ignora filas sin desarrollo identificable o sin parámetros', async () => {
const sinClave = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as Record<string, unknown>
delete sinClave['id']
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: '', parametrosJson: JSON.stringify(sinClave) },
{ desarrolloId: 'SENDA', parametrosJson: null },
null,
])
expect((await getReglasGuardadas()).size).toBe(0)
})
it('lee la tabla por SQL crudo cuando el cliente Prisma no tiene el modelo', async () => {
prismaMock.$queryRawUnsafe.mockImplementation(async (sql: string) => {
if (sql.includes('"ReglaCotizadorDesarrollo"')) {
return [
{ desarrolloId: 'uuid-senda', parametrosJson: reglasJson(DESARROLLOS['SENDA']!) },
{ desarrollo: 'SOLANA', parametros: reglasJson(DESARROLLOS['SOLANA']!) },
]
}
return [ficha('uuid-senda', 'SENDA'), ficha('uuid-solana', 'SOLANA')]
})
const reglas = await getReglasEfectivas()
expect(prismaMock.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('"ReglaCotizadorDesarrollo"'),
)
expect(reglas['SENDA']?.propuestas).toHaveLength(4)
expect(reglas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
})
})
describe('respaldo en constantes (nunca falla la cotización)', () => {
it('JSON corrupto: respaldo en las constantes con warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: '{"propuestas": [' },
])
const reglas = await getReglasDesarrollo('DIAGONAL')
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
warn.mockRestore()
})
it('JSON válido pero con forma inesperada: respaldo en las constantes con warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: JSON.stringify({ precioUnico: true }) },
])
const reglas = await getReglasDesarrollo('DIAGONAL')
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('forma inesperada'))
warn.mockRestore()
})
it('tabla inexistente: respaldo en las constantes, sin lanzar y con un solo warning', async () => {
vi.resetModules()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
prismaMock.$queryRawUnsafe.mockRejectedValue(new Error('no such table: ReglaCotizadorDesarrollo'))
const servicio = await import('./reglas-desarrollo.service.js')
const reglas = await servicio.getReglasDesarrollo('SENDA')
const efectivas = await servicio.getReglasEfectivas()
expect(reglas).toEqual(DESARROLLOS['SENDA'])
expect(Object.keys(efectivas)).toHaveLength(Object.keys(DESARROLLOS).length)
expect(warn).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('constantes'), expect.any(Error))
warn.mockRestore()
})
})

View File

@@ -0,0 +1,417 @@
/**
* Reglas comerciales del cotizador por desarrollo.
*
* Las reglas las configura el admin y viven en la tabla `ReglaCotizadorDesarrollo`
* (una fila por desarrollo, con el JSON de parámetros). Este servicio las lee y
* las valida; **nunca** puede tumbar una cotización: si el desarrollo no tiene
* reglas guardadas, la tabla no existe todavía o el JSON viene corrupto, se cae
* a las constantes de `cotizacion.service.ts` (los valores históricos) y se
* registra un warning.
*/
import { prisma } from './prisma.js'
import {
DESARROLLOS,
type BasePrecio,
type DescuentosPropuestas,
type ModoMensualidades,
type ParametrosDesarrollo,
type PropuestaConfig,
type TipoPropuesta,
} from './cotizacion.service.js'
/** Reglas efectivas indexadas por id de desarrollo ('DIAGONAL', 'SENDA', …) */
export type ReglasDesarrollo = Record<string, ParametrosDesarrollo>
const DESARROLLO_DEFAULT = 'DIAGONAL'
const TIPOS: ReadonlyArray<TipoPropuesta> = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
]
const CLAVES_DESCUENTO: ReadonlyArray<keyof DescuentosPropuestas> = [
'creditoBancario',
'creditoInfonavit',
'contado',
'semiContado',
]
const BASES: ReadonlyArray<BasePrecio> = ['lista', 'final']
const MODOS: ReadonlyArray<ModoMensualidades> = [
'ninguna',
'remanente',
'remanenteLista',
'porcentaje',
]
function esTexto(valor: unknown): valor is string {
return typeof valor === 'string' && valor.trim() !== ''
}
function esNumero(valor: unknown): valor is number {
return typeof valor === 'number' && Number.isFinite(valor)
}
function esBase(valor: unknown): valor is BasePrecio {
return typeof valor === 'string' && (BASES as ReadonlyArray<string>).includes(valor)
}
function esModo(valor: unknown): valor is ModoMensualidades {
return typeof valor === 'string' && (MODOS as ReadonlyArray<string>).includes(valor)
}
/** Normaliza una propuesta del JSON de la BD; null si está corrupta */
function normalizarPropuesta(bruto: unknown): PropuestaConfig | null {
if (typeof bruto !== 'object' || bruto === null) return null
const p = bruto as Record<string, unknown>
const tipo = p['tipo']
const clave = p['claveDescuento']
if (typeof tipo !== 'string' || !(TIPOS as ReadonlyArray<string>).includes(tipo)) return null
if (typeof clave !== 'string' || !(CLAVES_DESCUENTO as ReadonlyArray<string>).includes(clave))
return null
if (!esTexto(p['titulo'])) return null
if (!esNumero(p['descuentoDefault']) || !esNumero(p['pctEnganche']) || !esNumero(p['pctPagoFinal']))
return null
if (!esBase(p['engancheBase']) || !esBase(p['pagoFinalBase'])) return null
if (!esModo(p['modoMensualidades'])) return null
const propuesta: PropuestaConfig = {
tipo: tipo as TipoPropuesta,
claveDescuento: clave as keyof DescuentosPropuestas,
titulo: p['titulo'].trim(),
descuentoDefault: p['descuentoDefault'],
pctEnganche: p['pctEnganche'],
engancheBase: p['engancheBase'],
pctPagoFinal: p['pctPagoFinal'],
pagoFinalBase: p['pagoFinalBase'],
modoMensualidades: p['modoMensualidades'],
}
if (p['engancheCeroSinApartado'] === true) propuesta.engancheCeroSinApartado = true
const activa = p['activa'] ?? p['activo']
if (typeof activa === 'boolean') propuesta.activa = activa
return propuesta
}
/**
* Valida y normaliza el JSON de reglas de un desarrollo. Devuelve null (para
* que el llamador caiga a las constantes) si el JSON no tiene la forma esperada.
*/
export function normalizarReglas(
desarrolloId: string,
bruto: unknown,
): ParametrosDesarrollo | null {
if (typeof bruto !== 'object' || bruto === null || Array.isArray(bruto)) return null
const r = bruto as Record<string, unknown>
if (typeof r['precioUnico'] !== 'boolean') return null
const fuente = r['fuenteDeN']
if (fuente !== 'fechaEntrega' && fuente !== 'fijo' && fuente !== 'porUnidad') return null
const propuestasBrutas = r['propuestas']
if (!Array.isArray(propuestasBrutas) || propuestasBrutas.length === 0) return null
const propuestas: PropuestaConfig[] = []
for (const propuestaBruta of propuestasBrutas) {
const propuesta = normalizarPropuesta(propuestaBruta)
if (!propuesta) return null // una propuesta corrupta invalida el JSON completo
propuestas.push(propuesta)
}
const id = desarrolloId.trim().toUpperCase()
const params: ParametrosDesarrollo = {
id,
precioUnico: r['precioUnico'],
fuenteDeN: fuente,
propuestas,
}
if (esTexto(r['nombre'])) params.nombre = r['nombre'].trim()
if (esNumero(r['nFijo'])) params.nFijo = r['nFijo']
if (esNumero(r['nDefault'])) params.nDefault = r['nDefault']
if (r['cuotaMantenimiento'] === null) params.cuotaMantenimiento = null
else if (esNumero(r['cuotaMantenimiento'])) params.cuotaMantenimiento = r['cuotaMantenimiento']
if (esTexto(r['prefijoUnidadPdf'])) params.prefijoUnidadPdf = r['prefijoUnidadPdf'].trim()
const activo = r['activoEnCotizador'] ?? r['activo']
if (typeof activo === 'boolean') params.activoEnCotizador = activo
return params
}
/** Fila de reglas ya mapeada a nombres de campo conocidos */
interface FilaReglas {
desarrolloId: string
parametros: unknown
activoEnCotizador: boolean | undefined
}
/** Ficha mínima del desarrollo (tabla `Desarrollo`): la clave y sus banderas */
interface FichaDesarrollo {
clave: string
activoEnCotizador: boolean | undefined
nombre: string | undefined
prefijoUnidadPdf: string | undefined
}
interface CatalogoDesarrollos {
porClave: Map<string, FichaDesarrollo>
/** Desarrollo.id → clave ('DIAGONAL', 'SENDA', …) */
clavePorId: Map<string, string>
}
/** Primer valor presente de `claves` en la fila que cumpla el filtro */
function primerValor(
fila: Record<string, unknown>,
claves: ReadonlyArray<string>,
acepta: (valor: unknown) => boolean,
): unknown {
for (const clave of claves) {
const valor = fila[clave]
if (valor !== undefined && valor !== null && acepta(valor)) return valor
}
return undefined
}
/** Booleanos de SQLite crudo: 1/0 además de true/false */
function esBandera(valor: unknown): valor is boolean | 0 | 1 {
return typeof valor === 'boolean' || valor === 0 || valor === 1
}
function aBandera(valor: unknown): boolean | undefined {
if (valor === true || valor === 1) return true
if (valor === false || valor === 0) return false
return undefined
}
/**
* Mapea una fila cruda (Prisma o SQL) a `FilaReglas`. Los nombres de columna se
* resuelven por heurística para tolerar cambios del modelo mientras otro agente
* construye el módulo de administración: `desarrolloId`/`desarrollo` y
* `parametrosJson`/`parametros`/`reglas`.
*/
function mapearFila(fila: unknown): FilaReglas | null {
if (typeof fila !== 'object' || fila === null) return null
const f = fila as Record<string, unknown>
const desarrollo = primerValor(
f,
['desarrolloId', 'desarrollo', 'desarrollo_id', 'proyecto', 'id'],
esTexto,
)
const parametros = primerValor(
f,
['parametrosJson', 'parametros_json', 'parametros', 'reglas'],
(v) => typeof v === 'string' || typeof v === 'object',
)
if (typeof desarrollo !== 'string') return null
const activo = primerValor(f, ['activoEnCotizador', 'activo'], esBandera)
return {
desarrolloId: desarrollo,
parametros,
activoEnCotizador: aBandera(activo),
}
}
/** Cliente Prisma con el modelo de reglas, si ya está generado */
function modeloReglas(): { findMany: (args?: unknown) => Promise<unknown[]> } | null {
const cliente = prisma as unknown as Record<string, unknown>
for (const nombre of ['reglaCotizadorDesarrollo', 'reglaCotizador', 'reglasCotizador']) {
const modelo = cliente[nombre] as { findMany?: unknown } | undefined
if (modelo && typeof modelo.findMany === 'function') {
return modelo as { findMany: (args?: unknown) => Promise<unknown[]> }
}
}
return null
}
/**
* Filas de reglas guardadas. Primero intenta el modelo de Prisma; si el cliente
* todavía no lo tiene generado, lee la tabla por SQL crudo. Cualquier fallo se
* traduce en "no hay reglas guardadas" (nunca en un error de cotización).
*/
async function leerFilasReglas(): Promise<FilaReglas[]> {
const modelo = modeloReglas()
if (modelo) {
const filas = await modelo.findMany()
return filas.map(mapearFila).filter((f): f is FilaReglas => f !== null)
}
const crudo = (prisma as unknown as { $queryRawUnsafe?: unknown }).$queryRawUnsafe
if (typeof crudo !== 'function') return []
const filas = await prisma.$queryRawUnsafe<unknown[]>(
'SELECT * FROM "ReglaCotizadorDesarrollo"',
)
return (Array.isArray(filas) ? filas : []).map(mapearFila).filter((f): f is FilaReglas => f !== null)
}
let avisoCatalogoEmitido = false
let avisoLecturaEmitido = false
/**
* Catálogo de desarrollos (tabla `Desarrollo`): de ahí sale la clave real de
* cada fila de reglas (las reglas apuntan al id del desarrollo) y la bandera
* `activoEnCotizador`. Tolerante: si la tabla no existe todavía, el catálogo
* queda vacío y las reglas se resuelven por la clave que traiga el JSON.
*/
async function leerCatalogo(): Promise<CatalogoDesarrollos> {
const porClave = new Map<string, FichaDesarrollo>()
const clavePorId = new Map<string, string>()
let leidas: unknown[]
try {
const modelo = (prisma as unknown as Record<string, unknown>)['desarrollo'] as
| { findMany?: unknown }
| undefined
if (modelo && typeof modelo.findMany === 'function') {
leidas = await (modelo.findMany as (args?: unknown) => Promise<unknown[]>)({
select: { id: true, clave: true, nombre: true, activoEnCotizador: true, prefijoUnidadPdf: true },
})
} else if (typeof prisma.$queryRawUnsafe === 'function') {
leidas = await prisma.$queryRawUnsafe<unknown[]>('SELECT * FROM "Desarrollo"')
} else {
return { porClave, clavePorId }
}
} catch (err) {
if (!avisoCatalogoEmitido) {
avisoCatalogoEmitido = true
// eslint-disable-next-line no-console
console.warn(
'[reglas-cotizador] No se pudo leer el catálogo de desarrollos; se usan las constantes.',
err,
)
}
return { porClave, clavePorId }
}
for (const fila of Array.isArray(leidas) ? leidas : []) {
if (typeof fila !== 'object' || fila === null) continue
const f = fila as Record<string, unknown>
const clave = primerValor(f, ['clave', 'desarrolloId', 'desarrollo'], esTexto)
if (typeof clave !== 'string') continue
const nombre = primerValor(f, ['nombre'], esTexto)
const prefijo = primerValor(f, ['prefijoUnidadPdf'], esTexto)
const ficha: FichaDesarrollo = {
clave: clave.trim().toUpperCase(),
activoEnCotizador: aBandera(primerValor(f, ['activoEnCotizador', 'activo'], esBandera)),
nombre: typeof nombre === 'string' ? nombre : undefined,
prefijoUnidadPdf: typeof prefijo === 'string' ? prefijo : undefined,
}
porClave.set(ficha.clave, ficha)
const id = primerValor(f, ['id'], esTexto)
if (typeof id === 'string') clavePorId.set(id, ficha.clave)
}
return { porClave, clavePorId }
}
/**
* Clave del desarrollo de una fila de reglas: primero el id guardado (uuid) que
* se resuelve con el catálogo, luego el `id` del propio JSON y, si no, la clave
* que venga en la columna.
*/
function claveDeFila(
fila: FilaReglas,
bruto: unknown,
catalogo: CatalogoDesarrollos,
): string | null {
const crudo = fila.desarrolloId.trim()
const delCatalogo = catalogo.clavePorId.get(crudo)
if (delCatalogo !== undefined) return delCatalogo
if (typeof bruto === 'object' && bruto !== null) {
const idJson = (bruto as Record<string, unknown>)['id']
if (esTexto(idJson)) return idJson.trim().toUpperCase()
}
return crudo === '' ? null : crudo.toUpperCase()
}
/**
* Reglas configuradas por el admin, indexadas por clave de desarrollo. Se
* descartan (con warning) las filas con JSON corrupto: esos desarrollos usan sus
* constantes.
*/
export async function getReglasGuardadas(): Promise<Map<string, ParametrosDesarrollo>> {
const guardadas = new Map<string, ParametrosDesarrollo>()
let filas: FilaReglas[]
try {
filas = await leerFilasReglas()
} catch (err) {
if (!avisoLecturaEmitido) {
avisoLecturaEmitido = true
// eslint-disable-next-line no-console
console.warn(
'[reglas-cotizador] No se pudieron leer las reglas guardadas (¿tabla ReglaCotizadorDesarrollo sin crear?); se usan las constantes.',
err,
)
}
return guardadas
}
if (filas.length === 0) return guardadas
const catalogo = await leerCatalogo()
for (const fila of filas) {
if (fila.parametros === undefined) continue
let bruto = fila.parametros
if (typeof bruto === 'string') {
try {
bruto = JSON.parse(bruto)
} catch {
// eslint-disable-next-line no-console
console.warn(
`[reglas-cotizador] JSON inválido para el desarrollo ${fila.desarrolloId}; se usan las constantes.`,
)
continue
}
}
const clave = claveDeFila(fila, bruto, catalogo)
if (clave === null) {
// eslint-disable-next-line no-console
console.warn('[reglas-cotizador] Fila de reglas sin desarrollo; se ignora.')
continue
}
const reglas = normalizarReglas(clave, bruto)
if (!reglas) {
// eslint-disable-next-line no-console
console.warn(
`[reglas-cotizador] Reglas con forma inesperada para el desarrollo ${clave}; se usan las constantes.`,
)
continue
}
// La ficha del desarrollo completa lo que el JSON no traiga
const ficha = catalogo.porClave.get(clave)
if (reglas.nombre === undefined && ficha?.nombre !== undefined) reglas.nombre = ficha.nombre
if (reglas.prefijoUnidadPdf === undefined && ficha?.prefijoUnidadPdf !== undefined) {
reglas.prefijoUnidadPdf = ficha.prefijoUnidadPdf
}
// El desarrollo está activo solo si TODAS las fuentes que hablan de él lo
// dejan activo (la bandera vive en la ficha del desarrollo)
const banderas = [fila.activoEnCotizador, reglas.activoEnCotizador, ficha?.activoEnCotizador].filter(
(valor): valor is boolean => typeof valor === 'boolean',
)
if (banderas.length > 0) reglas.activoEnCotizador = banderas.every((valor) => valor)
guardadas.set(reglas.id, reglas)
}
return guardadas
}
/**
* Reglas efectivas de TODOS los desarrollos: lo guardado por el admin gana; los
* desarrollos sin reglas (o con JSON corrupto) caen a las constantes actuales.
*/
export async function getReglasEfectivas(): Promise<ReglasDesarrollo> {
const guardadas = await getReglasGuardadas()
const efectivas: ReglasDesarrollo = {}
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
efectivas[id] = guardadas.get(id) ?? constantes
}
for (const [id, reglas] of guardadas) {
if (efectivas[id] === undefined) efectivas[id] = reglas
}
return efectivas
}
/** Reglas efectivas de un desarrollo; cae a las constantes (DIAGONAL si es desconocido) */
export async function getReglasDesarrollo(
desarrolloId: string | null | undefined,
): Promise<ParametrosDesarrollo> {
const id = (desarrolloId ?? DESARROLLO_DEFAULT).trim().toUpperCase() || DESARROLLO_DEFAULT
const guardadas = await getReglasGuardadas()
return guardadas.get(id) ?? DESARROLLOS[id] ?? DESARROLLOS[DESARROLLO_DEFAULT]!
}

View File

@@ -11,6 +11,8 @@ function captura(
origen: string | null origen: string | null
desarrollo: string | null desarrollo: string | null
createdAt: Date createdAt: Date
updatedAt: Date
datosJson: string | null
}> = {}, }> = {},
) { ) {
return { return {
@@ -20,6 +22,8 @@ function captura(
origen: overrides.origen ?? 'Facebook', origen: overrides.origen ?? 'Facebook',
desarrollo: overrides.desarrollo ?? 'DIAGONAL', desarrollo: overrides.desarrollo ?? 'DIAGONAL',
createdAt: overrides.createdAt ?? new Date('2026-09-05T10:00:00'), createdAt: overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
updatedAt: overrides.updatedAt ?? overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
datosJson: overrides.datosJson ?? null,
} }
} }
@@ -85,15 +89,41 @@ describe('construirMiTablero', () => {
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
}) })
it('calcula ventas del mes correctamente', () => { it('calcula ventas del mes con ventas CERRADAS (no cotizaciones)', () => {
// Las cotizaciones ya no cuentan como ventas: se cuentan los apartados con
// firma de cliente o posteriores, sin repetir la misma unidad.
const cots = [ const cots = [
cotizacion({ precioFinal: 850000, createdAt: new Date('2026-09-01T12:00:00') }), cotizacion({ precioFinal: 850000, createdAt: new Date('2026-09-01T12:00:00') }),
cotizacion({ precioFinal: 750000, createdAt: new Date('2026-09-05T12:00:00') }),
cotizacion({ precioFinal: 900000, createdAt: new Date('2026-08-15T12:00:00') }), // mes anterior
] ]
const tablero = construirMiTablero([], cots, [], AHORA) const capturas = [
captura({
leadId: 'loc_apt_1',
board: 'APARTADO',
etapa: 'Firma Cliente',
updatedAt: new Date('2026-09-10T12:00:00'),
datosJson: JSON.stringify({ precioFinal: 1_500_000 }),
}),
captura({
leadId: 'loc_apt_2',
board: 'APARTADO',
etapa: 'Venta',
updatedAt: new Date('2026-09-12T12:00:00'),
datosJson: JSON.stringify({ precioFinal: 2_000_000 }),
}),
// Mismo apartado recotizado: no debe duplicarse (mismo leadId)
captura({
leadId: 'loc_apt_1',
board: 'APARTADO',
etapa: 'Firma Cliente',
updatedAt: new Date('2026-09-15T12:00:00'),
datosJson: JSON.stringify({ precioFinal: 1_500_000 }),
}),
captura({ board: 'APARTADO', etapa: 'Pago Apartado', updatedAt: new Date('2026-09-11T12:00:00') }),
captura({ board: 'APARTADO', etapa: 'Cancelacion Apartado', updatedAt: new Date('2026-09-11T12:00:00') }),
]
const tablero = construirMiTablero(capturas, cots, [], AHORA)
expect(tablero.ventasMes.unidades).toBe(2) expect(tablero.ventasMes.unidades).toBe(2)
expect(tablero.ventasMes.monto).toBe(1600000) // 850000 + 750000 expect(tablero.ventasMes.monto).toBe(3_500_000)
}) })
it('agrupa por origen con conteo de convertidos', () => { it('agrupa por origen con conteo de convertidos', () => {

View File

@@ -80,6 +80,9 @@ export function construirMiTablero(
origen: string | null origen: string | null
desarrollo: string | null desarrollo: string | null
createdAt: Date createdAt: Date
/** Necesarios para las ventas cerradas del mes (fecha del cambio y precio) */
updatedAt?: Date
datosJson?: string | null
}[], }[],
cotizaciones: { cotizaciones: {
precioFinal: number precioFinal: number
@@ -130,13 +133,35 @@ export function construirMiTablero(
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
// -- Ventas del mes -- // -- Ventas del mes --
// Antes sumaba COTIZACIONES (un inmueble recotizado contaba varias veces y una
// cotización no es una venta). Ahora cuenta ventas cerradas del mes: apartados
// con firma de cliente o posteriores (Venta, Escriturado, Titulado), sin
// repetir la misma unidad.
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1) const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
const cotizacionesMes = cotizaciones.filter( const esVentaCerrada = (etapa: string): boolean => {
(ct) => ct.createdAt >= inicioMes, const e = etapa.toUpperCase()
return (
e.includes('FIRMA') || e.includes('VENTA') || e.includes('ESCRITURADO') || e.includes('TITULADO')
) && !e.includes('CANCEL')
}
const ventasDelMes = capturas.filter(
(c) => c.board === 'APARTADO' && esVentaCerrada(c.etapa) && (c.updatedAt ?? c.createdAt) >= inicioMes,
) )
const unidadesVendidas = new Map<string, number>()
for (const v of ventasDelMes) {
let monto = 0
try {
const datos = JSON.parse(v.datosJson ?? '{}') as Record<string, unknown>
const precio = Number(datos['precioFinal'] ?? datos['precioVenta'] ?? 0)
monto = Number.isFinite(precio) ? precio : 0
} catch {
monto = 0
}
unidadesVendidas.set(v.leadId, monto)
}
const ventasMes = { const ventasMes = {
monto: Math.round(cotizacionesMes.reduce((acc, ct) => acc + ct.precioFinal, 0) * 100) / 100, monto: Math.round([...unidadesVendidas.values()].reduce((a, b) => a + b, 0) * 100) / 100,
unidades: cotizacionesMes.length, unidades: unidadesVendidas.size,
} }
// -- Por origen -- // -- Por origen --
@@ -212,6 +237,8 @@ export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
origen: true, origen: true,
desarrollo: true, desarrollo: true,
createdAt: true, createdAt: true,
updatedAt: true,
datosJson: true,
}, },
}) })

View File

@@ -217,7 +217,7 @@ describe('construirResumen', () => {
plan('a', { plan('a', {
pagos: [ pagos: [
pago('p1', '2026-08-01', 50000), // vencido sin abono pago('p1', '2026-08-01', 50000), // vencido sin abono
pago('p2', '2026-08-10', 50000, 20000), // parcial (no cuenta como VENCIDO) pago('p2', '2026-08-10', 50000, 20000), // parcial VENCIDO: sí cuenta como atraso
pago('p3', '2026-09-01', 50000), // pendiente futuro pago('p3', '2026-09-01', 50000), // pendiente futuro
], ],
}), }),
@@ -228,9 +228,10 @@ describe('construirResumen', () => {
}, },
AHORA, AHORA,
) )
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1 }) expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1, sinPlan: 0 })
expect(r.cobranza.pagosVencidos).toBe(2) // p1 (vencido sin abono) + p2 (PARCIAL vencido, antes se ignoraba) + p4
expect(r.cobranza.montoVencido).toBe(80000) expect(r.cobranza.pagosVencidos).toBe(3)
expect(r.cobranza.montoVencido).toBe(110000) // 50,000 + 30,000 restantes + 30,000
expect(r.cobranza.planesAlCorriente).toBe(0) expect(r.cobranza.planesAlCorriente).toBe(0)
expect(r.cobranza.planesConVencidos).toBe(2) expect(r.cobranza.planesConVencidos).toBe(2)
}) })

View File

@@ -1,4 +1,4 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js' import { pagoAtrasado, statusPlan, type PlanPago } from './cobranza.service.js'
import type { UnidadCotizable } from './cotizacion.service.js' import type { UnidadCotizable } from './cotizacion.service.js'
import { ETAPAS_PIPELINE } from './etapas.js' import { ETAPAS_PIPELINE } from './etapas.js'
import { prisma } from './prisma.js' import { prisma } from './prisma.js'
@@ -266,7 +266,9 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number
.map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })), .map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })),
] ]
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0 } // `sinPlan` = plan sin calendario capturado (no es lo mismo que cancelado:
// 3 planes del maestro llegaron sin pagos y antes se contaban como cancelados)
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0, sinPlan: 0 }
let planesAlCorriente = 0 let planesAlCorriente = 0
let planesConVencidos = 0 let planesConVencidos = 0
let pagosVencidos = 0 let pagosVencidos = 0
@@ -275,9 +277,12 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number
const status = statusPlan(plan) const status = statusPlan(plan)
if (status === 'ACTIVO') planesPorStatus.activos++ if (status === 'ACTIVO') planesPorStatus.activos++
else if (status === 'LIQUIDADO') planesPorStatus.liquidados++ else if (status === 'LIQUIDADO') planesPorStatus.liquidados++
else if (status === 'SIN_PLAN') planesPorStatus.sinPlan++
else planesPorStatus.cancelados++ else planesPorStatus.cancelados++
if (status !== 'ACTIVO') continue if (status !== 'ACTIVO') continue
const vencidos = plan.pagos.filter((p) => statusPago(p, ahora) === 'VENCIDO') // Un pago PARCIAL vencido también es atraso (antes no se contaba y había
// planes "al corriente" con meses de retraso)
const vencidos = plan.pagos.filter((p) => pagoAtrasado(p, ahora))
if (vencidos.length === 0) planesAlCorriente++ if (vencidos.length === 0) planesAlCorriente++
else planesConVencidos++ else planesConVencidos++
pagosVencidos += vencidos.length pagosVencidos += vencidos.length
@@ -435,7 +440,7 @@ export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes>
// Contar actividades abiertas // Contar actividades abiertas
const actividadesAbiertas = await prisma.actividadLocal.count({ where: { estado: 'ABIERTA' } }) const actividadesAbiertas = await prisma.actividadLocal.count({ where: { estado: 'ABIERTA' } })
return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto), actividadesAbiertas } return { leads, oportunidades, unidades: unidadesRows.map((u) => unidadLocalADto(u)), actividadesAbiertas }
} }
export class ReportesService { export class ReportesService {

View File

@@ -7,11 +7,15 @@ const prismaMock = vi.hoisted(() => ({
findMany: vi.fn(), findMany: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
capturaDepartamento: {
findMany: vi.fn(),
},
})) }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { import {
CAMPOS_FECHA_ENTREGA,
getUnidadesLocal, getUnidadesLocal,
getUnidadLocal, getUnidadLocal,
unidadLocalADto, unidadLocalADto,
@@ -37,10 +41,11 @@ beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([]) prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.findUnique.mockResolvedValue(null) prismaMock.unidad.findUnique.mockResolvedValue(null)
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
}) })
describe('unidadLocalADto', () => { describe('unidadLocalADto', () => {
it('mapea la fila local al DTO Unidad', () => { it('mapea la fila local al DTO Unidad (sin fecha de entrega por defecto)', () => {
const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' })) const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' }))
expect(dto).toEqual({ expect(dto).toEqual({
codigo: 'SENDA-101', codigo: 'SENDA-101',
@@ -58,6 +63,10 @@ describe('unidadLocalADto', () => {
}) })
}) })
it('con fecha de entrega (captura de departamentos) la deja en el DTO', () => {
expect(unidadLocalADto(fila({ codigo: 'U-101' }), '2027-10-08').fechaEntrega).toBe('2027-10-08')
})
it('etapa vacía → null', () => { it('etapa vacía → null', () => {
expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull() expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull()
}) })
@@ -80,11 +89,41 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3']) expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
}) })
it('getUnidadLocal: null si no existe', async () => { it('completa fechaEntrega desde la captura: entrega programada > habitabilidad > inicio de obra', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'S-1', desarrollo: 'SENDA' }),
fila({ codigo: 'S-2', desarrollo: 'SENDA' }),
])
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' },
{ unidadCodigo: 'U-1', campo: 'habitabilidad', valor: '2027-10-08' },
{ unidadCodigo: 'U-1', campo: 'entrega_programada_fecha', valor: '2027-11-20' },
{ unidadCodigo: 'S-1', campo: 'habitabilidad', valor: '2027-05-05' },
// Valor vacío / no-fecha: no cuenta como dato
{ unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' },
{ unidadCodigo: 'S-2', campo: 'entrega_programada_fecha', valor: 'pendiente' },
])
const todas = await getUnidadesLocal()
expect(todas.map((u) => u.fechaEntrega)).toEqual(['2027-11-20', null, '2027-05-05', null])
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }),
)
})
it('getUnidadLocal: null si no existe; trae la fecha de su captura', async () => {
prismaMock.unidad.findUnique.mockImplementation(async ({ where }) => prismaMock.unidad.findUnique.mockImplementation(async ({ where }) =>
where.codigo === 'U-9' ? fila({ codigo: 'U-9' }) : null, where.codigo === 'U-9' ? fila({ codigo: 'U-9' }) : null,
) )
expect((await getUnidadLocal('U-9'))?.codigo).toBe('U-9') prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-9', campo: 'habitabilidad', valor: '2027-10-08' },
])
const dto = await getUnidadLocal('U-9')
expect(dto?.codigo).toBe('U-9')
expect(dto?.fechaEntrega).toBe('2027-10-08')
expect(await getUnidadLocal('U-X')).toBeNull() expect(await getUnidadLocal('U-X')).toBeNull()
}) })
}) })

View File

@@ -6,10 +6,52 @@ import type { UnidadCotizable } from './cotizacion.service.js'
* scripts/seed-unidades-locales.js). Es la única fuente de lectura del * scripts/seed-unidades-locales.js). Es la única fuente de lectura del
* inventario: misma forma de DTO que consumen controllers y fronts. * inventario: misma forma de DTO que consumen controllers y fronts.
* *
* La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el * La tabla `Unidad` no guarda la fecha de entrega: se completa desde la captura
* seed curado): salen en null, que el cotizador ya tolera (plazo desconocido). * de obra/escrituración (`CapturaDepartamento`), que es la que llenan los
* departamentos. Sin ese dato el cotizador asume el plazo por defecto del
* desarrollo (ver DESARROLLOS en cotizacion.service.ts).
*/ */
/**
* Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda,
* en orden de prioridad:
* 1. `entrega_programada_fecha` — Fecha Programada Entrega Vivienda (ESCRITURACION)
* 2. `habitabilidad` — fecha de habitabilidad (OBRA), la de DIAGONAL
* 3. `inicio_obra_fecha` — último recurso (ESCRITURACION)
*/
export const CAMPOS_FECHA_ENTREGA = [
'entrega_programada_fecha',
'habitabilidad',
'inicio_obra_fecha',
] as const
/** Normaliza 'YYYY-MM-DD' (o ISO completo); null si el valor no es una fecha usable */
function fechaIso(valor: string | null | undefined): string | null {
if (typeof valor !== 'string') return null
const fecha = valor.trim().slice(0, 10)
return /^\d{4}-\d{2}-\d{2}$/.test(fecha) ? fecha : null
}
/** Fecha de entrega (ISO) por código de unidad, según la captura de los departamentos */
async function fechasEntregaPorUnidad(codigos?: string[]): Promise<Map<string, string>> {
const filas = await prisma.capturaDepartamento.findMany({
where: {
campo: { in: [...CAMPOS_FECHA_ENTREGA] },
...(codigos ? { unidadCodigo: { in: codigos } } : {}),
},
select: { unidadCodigo: true, campo: true, valor: true },
})
const porUnidad = new Map<string, string>()
for (const campo of CAMPOS_FECHA_ENTREGA) {
for (const fila of filas) {
if (fila.campo !== campo || porUnidad.has(fila.unidadCodigo)) continue
const fecha = fechaIso(fila.valor)
if (fecha !== null) porUnidad.set(fila.unidadCodigo, fecha)
}
}
return porUnidad
}
/** Fila de la tabla Unidad tal como la devuelve Prisma (estructural) */ /** Fila de la tabla Unidad tal como la devuelve Prisma (estructural) */
export interface FilaUnidad { export interface FilaUnidad {
codigo: string codigo: string
@@ -25,11 +67,11 @@ export interface FilaUnidad {
} }
/** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */ /** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */
export function unidadLocalADto(u: FilaUnidad): UnidadCotizable { export function unidadLocalADto(u: FilaUnidad, fechaEntrega: string | null = null): UnidadCotizable {
return { return {
codigo: u.codigo, codigo: u.codigo,
modelo: u.modelo, modelo: u.modelo,
fechaEntrega: null, // no está en el espejo local: plazo desconocido fechaEntrega,
supEstacionamiento: u.supEstacionamiento, supEstacionamiento: u.supEstacionamiento,
supConstruccion: u.supConstruccion, supConstruccion: u.supConstruccion,
supExcedente: u.supExcedente, supExcedente: u.supExcedente,
@@ -47,7 +89,8 @@ export async function getUnidadesLocal(
filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {}, filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {},
): Promise<UnidadCotizable[]> { ): Promise<UnidadCotizable[]> {
const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } }) const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } })
let lista = rows.map(unidadLocalADto) const fechas = await fechasEntregaPorUnidad()
let lista = rows.map((u) => unidadLocalADto(u, fechas.get(u.codigo) ?? null))
if (filtro.soloDisponibles === true) { if (filtro.soloDisponibles === true) {
lista = lista.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE') lista = lista.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE')
} }
@@ -61,5 +104,7 @@ export async function getUnidadesLocal(
/** Una unidad local por código; null si no existe en el espejo */ /** Una unidad local por código; null si no existe en el espejo */
export async function getUnidadLocal(codigo: string): Promise<UnidadCotizable | null> { export async function getUnidadLocal(codigo: string): Promise<UnidadCotizable | null> {
const row = await prisma.unidad.findUnique({ where: { codigo } }) const row = await prisma.unidad.findUnique({ where: { codigo } })
return row === null ? null : unidadLocalADto(row) if (row === null) return null
const fechas = await fechasEntregaPorUnidad([codigo])
return unidadLocalADto(row, fechas.get(codigo) ?? null)
} }

View File

@@ -1,8 +1,12 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Link } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import DarkModeToggle from './DarkModeToggle' import DarkModeToggle from './DarkModeToggle'
export default function Layout({ children }: { children: ReactNode }) { export default function Layout({ children }: { children: ReactNode }) {
// El toggle "Mis planes / Todos" vive en ?vista=: se conserva al navegar
// entre Cobranza y Cobranza del día para que el alcance no se pierda.
const { search } = useLocation()
return ( return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]"> <div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95"> <header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
@@ -23,13 +27,13 @@ export default function Layout({ children }: { children: ReactNode }) {
<nav className="flex items-center gap-3 text-sm sm:gap-6"> <nav className="flex items-center gap-3 text-sm sm:gap-6">
<DarkModeToggle /> <DarkModeToggle />
<Link <Link
to="/" to={`/${search}`}
className="text-[#64748B] dark:text-[#94A3B8] transition-colors duration-150 hover:text-[#111827] dark:text-[#E2E8F0]" className="text-[#64748B] dark:text-[#94A3B8] transition-colors duration-150 hover:text-[#111827] dark:text-[#E2E8F0]"
> >
Cobranza Cobranza
</Link> </Link>
<Link <Link
to="/hoy" to={`/hoy${search}`}
className="rounded-lg border border-accent px-3 py-2 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white sm:px-5 sm:py-2.5" className="rounded-lg border border-accent px-3 py-2 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white sm:px-5 sm:py-2.5"
> >
Cobranza del día Cobranza del día

View File

@@ -1,13 +1,14 @@
import type { PlanPago } from '../types' import type { PlanPago } from '../types'
import { diasParaProximoVencimiento, statusPago, statusPlan } from '../lib/cobranza' import { diasParaProximoVencimiento, pagoAtrasado, statusPago, statusPlan } from '../lib/cobranza'
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS' export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS' | 'SIN_PLAN'
const ETIQUETA: Record<Semaforo, string> = { const ETIQUETA: Record<Semaforo, string> = {
VERDE: 'Al corriente', VERDE: 'Al corriente',
AMBAR: 'Por vencer / parcial', AMBAR: 'Por vencer / parcial',
ROJO: 'Vencido', ROJO: 'Vencido',
GRIS: 'Cerrado', GRIS: 'Cerrado',
SIN_PLAN: 'Sin plan capturado',
} }
const CLASE_DOT: Record<Semaforo, string> = { const CLASE_DOT: Record<Semaforo, string> = {
@@ -15,20 +16,23 @@ const CLASE_DOT: Record<Semaforo, string> = {
AMBAR: 'bg-amber-50 text-amber-700', AMBAR: 'bg-amber-50 text-amber-700',
ROJO: 'bg-red-50 text-red-700', ROJO: 'bg-red-50 text-red-700',
GRIS: 'bg-gray-100 text-gray-600', GRIS: 'bg-gray-100 text-gray-600',
SIN_PLAN: 'bg-orange-50 text-orange-700',
} }
/** /**
* Semáforo del plan: * Semáforo del plan:
* - "sin plan capturado": no hay calendario de pagos (no es lo mismo que liquidado)
* - gris: LIQUIDADO o CANCELADO * - gris: LIQUIDADO o CANCELADO
* - rojo: tiene pagos VENCIDOS * - rojo: tiene pagos VENCIDOS o PARCIALES vencidos
* - ámbar: tiene un pago PARCIAL o el próximo vencimiento es en 7 días o menos * - ámbar: tiene un pago PARCIAL o el próximo vencimiento es en 7 días o menos
* - verde: al corriente * - verde: al corriente
*/ */
export function semaforoPlan(plan: PlanPago, hoy: Date = new Date()): Semaforo { export function semaforoPlan(plan: PlanPago, hoy: Date = new Date()): Semaforo {
if (statusPlan(plan) !== 'ACTIVO') return 'GRIS' const status = statusPlan(plan)
const estados = plan.pagos.map((p) => statusPago(p, hoy)) if (status === 'SIN_PLAN') return 'SIN_PLAN'
if (estados.includes('VENCIDO')) return 'ROJO' if (status !== 'ACTIVO') return 'GRIS'
if (estados.includes('PARCIAL')) return 'AMBAR' if (plan.pagos.some((p) => pagoAtrasado(p, hoy))) return 'ROJO'
if (plan.pagos.some((p) => statusPago(p, hoy) === 'PARCIAL')) return 'AMBAR'
const dias = diasParaProximoVencimiento(plan, hoy) const dias = diasParaProximoVencimiento(plan, hoy)
if (dias !== null && dias <= 7) return 'AMBAR' if (dias !== null && dias <= 7) return 'AMBAR'
return 'VERDE' return 'VERDE'

View File

@@ -0,0 +1,46 @@
import type { VistaPlanes } from '../lib/usePlanesCobranza'
interface Props {
mios: boolean
/** null vuelve al valor por defecto del rol */
onCambiar: (vista: VistaPlanes | null) => void
}
/**
* Toggle "Mis planes / Todos". La elección se guarda en ?vista= de la URL, de
* modo que se comparte entre el dashboard y "Cobranza del día".
*/
export default function ToggleVista({ mios, onCambiar }: Props) {
const claseActiva = 'bg-accent text-white shadow-sm'
const claseInactiva =
'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:hover:text-[#E2E8F0] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
return (
<div
className="inline-flex rounded-lg border border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]"
role="group"
aria-label="Alcance de planes"
>
<button
type="button"
onClick={() => onCambiar(mios ? null : 'mios')}
aria-pressed={mios}
className={`rounded-l-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? claseActiva : claseInactiva
}`}
>
Mis planes
</button>
<button
type="button"
onClick={() => onCambiar(mios ? 'todos' : null)}
aria-pressed={!mios}
className={`rounded-r-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? claseInactiva : claseActiva
}`}
>
Todos
</button>
</div>
)
}

View File

@@ -186,6 +186,29 @@ describe('saldo, avance y status del plan', () => {
expect(statusPlan(plan)).toBe('CANCELADO') expect(statusPlan(plan)).toBe('CANCELADO')
}) })
it('sin calendario capturado → SIN_PLAN, no LIQUIDADO', () => {
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0, numPagos: 0 }
expect(statusPlan(sinPagos)).toBe('SIN_PLAN')
// con pagos pero en cero (plan capturado a medias): tampoco es liquidado
const enCeros: PlanPago = {
...planDePrueba(),
engancheTotal: 0,
pagos: planDePrueba().pagos.map((p) => ({ ...p, monto: 0 })),
}
expect(statusPlan(enCeros)).toBe('SIN_PLAN')
})
it('un plan SIN_PLAN no entra en la cobranza del día', () => {
const sinPagos: PlanPago = {
...planDePrueba(),
pagos: [],
engancheTotal: 0,
numPagos: 0,
fechaInicio: '2026-01-01',
}
expect(cobranzaDelDia([sinPagos], new Date('2026-02-01T00:00:00'))).toHaveLength(0)
})
it('alCorriente es falso cuando hay pagos vencidos', () => { it('alCorriente es falso cuando hay pagos vencidos', () => {
const hoy = new Date('2026-02-15T00:00:00') // pago 1 (10-ene) ya venció const hoy = new Date('2026-02-15T00:00:00') // pago 1 (10-ene) ya venció
expect(alCorriente(planDePrueba(), hoy)).toBe(false) expect(alCorriente(planDePrueba(), hoy)).toBe(false)
@@ -225,10 +248,19 @@ describe('proximoVencimiento y diasAtraso', () => {
expect(diasAtraso(plan, new Date('2026-01-10T00:00:00'))).toBe(0) expect(diasAtraso(plan, new Date('2026-01-10T00:00:00'))).toBe(0)
}) })
it('un pago PARCIAL vencido no cuenta como atraso', () => { it('un pago PARCIAL vencido SÍ cuenta como atraso (corrección: antes se ignoraba)', () => {
let plan = planDePrueba() let plan = planDePrueba()
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' }) plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
// pago 1 es PARCIAL (vencido pero con abono); pago 2 vence 10-feb // pago 1 vence 10-ene y sigue sin cubrir (abono de 100) → 22 días al 1-feb
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(22)
expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(false)
})
it('alCorriente ignora los vencidos ya cubiertos', () => {
let plan = planDePrueba()
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A' })
// con el pago 1 cubierto y el 2 (10-feb) aún vigente, está al corriente
expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(true)
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0) expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
}) })
}) })

View File

@@ -85,7 +85,8 @@ export function generarCalendario(
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos: * PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE. * VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo * Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`. * PARCIAL (tiene abono aplicado); el atraso se señala con `pagoAtrasado`,
* `diasAtraso` y `alCorriente`, que sí lo cuentan como vencido.
*/ */
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago { export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO' if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
@@ -132,10 +133,20 @@ export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date(
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000) return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
} }
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */ /**
* ¿El pago está atrasado? Un pago sin cubrir cuya fecha de vencimiento ya
* pasó: incluye el PARCIAL vencido (tiene abono pero no cubre el monto), que
* es un atraso real aunque `statusPago` lo siga etiquetando PARCIAL.
*/
export function pagoAtrasado(pago: Pago, hoy: Date = new Date()): boolean {
if (estaCubierto(pago)) return false
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0
}
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin cubrir) */
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number { export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
const vencidos = plan.pagos const vencidos = plan.pagos
.filter((p) => statusPago(p, hoy) === 'VENCIDO') .filter((p) => pagoAtrasado(p, hoy))
.sort( .sort(
(a, b) => (a, b) =>
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(), parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
@@ -145,15 +156,29 @@ export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000) return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
} }
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */ /**
* Status derivado del plan:
* - CANCELADO: solo manual
* - SIN_PLAN: no hay calendario capturado (0 pagos o montos en 0). Distinto de
* LIQUIDADO: el adeudo real es desconocido, no saldado; queda fuera de la
* cobranza del día hasta que se capture el plan.
* - LIQUIDADO: saldo == 0
* - ACTIVO: tiene saldo pendiente
*/
export function statusPlan(plan: PlanPago): StatusPlan { export function statusPlan(plan: PlanPago): StatusPlan {
if (plan.status === 'CANCELADO') return 'CANCELADO' if (plan.status === 'CANCELADO') return 'CANCELADO'
if (!tieneCalendario(plan)) return 'SIN_PLAN'
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO' return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
} }
/** Al corriente = sin pagos VENCIDOS */ /** ¿El plan tiene calendario capturado? (al menos un pago con monto > 0) */
export function tieneCalendario(plan: PlanPago): boolean {
return plan.pagos.some((p) => p.monto > EPS)
}
/** Al corriente = sin pagos atrasados (un PARCIAL vencido también es atraso) */
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean { export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO') return !plan.pagos.some((p) => pagoAtrasado(p, hoy))
} }
/** /**
@@ -194,10 +219,11 @@ export function telefonoParaEnlace(telefono: string): string {
} }
/** /**
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS: * Lista plana de "cobranza del día" a través de todos los planes ACTIVOS
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos * (los SIN_PLAN quedan fuera: sin calendario capturado no hay nada que
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual * cobrar): pagos vencidos (incluye PARCIAL vencido) más los que vencen en los
* atraso, el vencimiento más próximo primero. * próximos `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a
* igual atraso, el vencimiento más próximo primero.
*/ */
export function cobranzaDelDia( export function cobranzaDelDia(
planes: PlanPago[], planes: PlanPago[],

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { PlanPago } from '../../types' import type { PlanPago } from '../../types'
import { generarCalendario, registrarAbono } from '../cobranza' import { generarCalendario, registrarAbono, saldoPlan } from '../cobranza'
import { crearDocumento, folioRecibo } from './reciboAbono' import { crearDocumento, folioRecibo, saldosDelRecibo } from './reciboAbono'
function planConAbono(): PlanPago { function planConAbono(): PlanPago {
const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3) const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3)
@@ -47,4 +47,56 @@ describe('reciboAbono PDF', () => {
// smoke: el contenido interno es un PDF válido // smoke: el contenido interno es un PDF válido
expect(doc.output('datauristring')).toContain('application/pdf') expect(doc.output('datauristring')).toContain('application/pdf')
}) })
it('saldosDelRecibo usa los totales del plan, no solo los abonos registrados', () => {
const plan = planConAbono()
const pago = plan.pagos[0]
const { restantePago, saldoPlan: saldo } = saldosDelRecibo(plan, pago)
expect(restantePago).toBe(28333.33)
expect(saldo).toBe(saldoPlan(plan))
expect(saldo).toBe(95000)
})
it('el saldo incluye el apartado/enganche histórico sin fila de AplicacionPago', () => {
// Caso real del maestro importado: el pago inicial (numPago 0) trae
// montoPagado == monto pero sin aplicaciones. Antes el recibo restaba solo
// aplicaciones y mostraba un saldo inflado (o negativo).
const base = planConAbono()
const pagoInicial = {
id: 'pago-0',
numPago: 0,
fechaVencimiento: '2026-01-01',
monto: 20000,
montoPagado: 20000,
aplicaciones: [],
}
const plan: PlanPago = {
...base,
pagos: [pagoInicial, ...base.pagos],
engancheTotal: base.engancheTotal,
}
const pago = plan.pagos[1]
const aplicacion = pago.aplicaciones[0]
const { saldoPlan: saldo, restantePago } = saldosDelRecibo(plan, pago)
// saldo real = Σ monto − Σ montoPagado = 120000 − 25000
expect(saldo).toBe(95000)
expect(restantePago).toBe(28333.33)
expect(crearDocumento(plan, pago, aplicacion).doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
})
it('un abono mayor al monto de su pago no produce saldo negativo (dato importado)', () => {
// El importador viejo guardaba el valor crudo en AplicacionPago.monto
// aunque montoPagado se topara con Math.min: el recibo restaba de más.
const base = planConAbono()
const pago = base.pagos[0]
const inflado: PlanPago = {
...base,
pagos: base.pagos.map((p) =>
p.id === pago.id
? { ...p, aplicaciones: [{ ...p.aplicaciones[0], monto: 500000 }] }
: p,
),
}
expect(saldosDelRecibo(inflado, inflado.pagos[0]).saldoPlan).toBe(95000)
})
}) })

View File

@@ -1,7 +1,7 @@
import { jsPDF } from 'jspdf' import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable' import autoTable from 'jspdf-autotable'
import type { AplicacionPago, Pago, PlanPago } from '../../types' import type { AplicacionPago, Pago, PlanPago } from '../../types'
import { round2 } from '../cobranza' import { round2, saldoPlan } from '../cobranza'
import { formatoFechaCorta, formatoFechaLarga, formatoMoneda } from '../formatters' import { formatoFechaCorta, formatoFechaLarga, formatoMoneda } from '../formatters'
import type { DocumentoPdf } from './estadoCuenta' import type { DocumentoPdf } from './estadoCuenta'
@@ -12,11 +12,25 @@ export function folioRecibo(aplicacion: AplicacionPago): string {
return aplicacion.id.slice(0, 8).toUpperCase() return aplicacion.id.slice(0, 8).toUpperCase()
} }
/**
* Saldos que imprime el recibo, tomados de los TOTALES del plan y del pago
* (suma de montos − suma de pagado), no del historial de `AplicacionPago`:
* el enganche/apartado histórico vive en `Pago.montoPagado` sin fila de
* aplicación, así que acumular solo aplicaciones daba saldos falsos
* (incluso negativos). El saldo nunca puede ser negativo porque un abono
* jamás excede el restante de su pago.
*/
export function saldosDelRecibo(plan: PlanPago, pago: Pago): { restantePago: number; saldoPlan: number } {
return {
restantePago: round2(pago.monto - pago.montoPagado),
saldoPlan: saldoPlan(plan),
}
}
/** /**
* Construye el recibo de pago de una aplicación (abono) ya registrada. * Construye el recibo de pago de una aplicación (abono) ya registrada.
* El restante del pago y el saldo del plan se calculan acumulando las * El restante del pago y el saldo del plan son los totales vigentes del plan
* aplicaciones registradas hasta esa aplicación (inclusive), de modo que el * (ver `saldosDelRecibo`).
* recibo refleja los montos vigentes justo tras el abono.
*/ */
export function crearDocumento( export function crearDocumento(
plan: PlanPago, plan: PlanPago,
@@ -28,23 +42,7 @@ export function crearDocumento(
const doc = new jsPDF() const doc = new jsPDF()
let y = MARGEN let y = MARGEN
// Aplicado acumulado (plan y pago) hasta la aplicación indicada, en orden de registro const { restantePago, saldoPlan: saldo } = saldosDelRecibo(plan, pago)
let aplicadoPlan = 0
let aplicadoPago = 0
let encontrada = false
for (const p of plan.pagos) {
for (const a of p.aplicaciones) {
aplicadoPlan += a.monto
if (p.id === pago.id) aplicadoPago += a.monto
if (a.id === aplicacion.id) {
encontrada = true
break
}
}
if (encontrada) break
}
const saldoTrasAbono = round2(plan.engancheTotal - aplicadoPlan)
const restanteTrasAbono = round2(pago.monto - aplicadoPago)
// 1. Encabezado // 1. Encabezado
doc.setFontSize(14) doc.setFontSize(14)
@@ -72,8 +70,8 @@ export function crearDocumento(
['FECHA DE PAGO', formatoFechaCorta(aplicacion.fechaPago)], ['FECHA DE PAGO', formatoFechaCorta(aplicacion.fechaPago)],
['MONTO DEL ABONO', formatoMoneda(aplicacion.monto)], ['MONTO DEL ABONO', formatoMoneda(aplicacion.monto)],
['REFERENCIA', aplicacion.referencia || '—'], ['REFERENCIA', aplicacion.referencia || '—'],
['RESTANTE DEL PAGO', formatoMoneda(restanteTrasAbono)], ['RESTANTE DEL PAGO', formatoMoneda(restantePago)],
['SALDO DEL PLAN TRAS EL ABONO', formatoMoneda(saldoTrasAbono)], ['SALDO DEL PLAN (a la fecha de emisión)', formatoMoneda(saldo)],
], ],
theme: 'plain', theme: 'plain',
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { halign: 'left' } }, columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { halign: 'left' } },

View File

@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { getSesion } from '../services/auth'
import {
listarPlanes,
miosPorDefecto,
sinPlanesAsignados,
type ResultadoPlanes,
} from '../services/repository'
/** Alcance del listado: solo los planes del usuario o todos */
export type VistaPlanes = 'mios' | 'todos'
export interface EstadoPlanes {
mios: boolean
/** Cambia el toggle; null borra el parámetro y vuelve al valor por defecto del rol */
definirVista: (vista: VistaPlanes | null) => void
resultado: ResultadoPlanes | null
errorCarga: string | null
/**
* true cuando "Mis planes" vino vacío (el usuario no tiene planes asignados:
* los históricos tienen vendedorId nulo) y se está mostrando el listado
* completo para no dejar la pantalla en blanco.
*/
sinAsignados: boolean
}
/**
* Carga de planes compartida por el dashboard y "Cobranza del día": el toggle
* "Mis planes / Todos" vive en la URL (?vista=mios|todos) para que ambas
* pantallas respeten la misma elección; sin parámetro arranca según el rol.
*/
export function usePlanesCobranza(): EstadoPlanes {
const [searchParams, setSearchParams] = useSearchParams()
const paramVista = searchParams.get('vista')
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
const [sinAsignados, setSinAsignados] = useState(false)
useEffect(() => {
let activo = true
setResultado(null)
setErrorCarga(null)
void (async () => {
try {
const r = await listarPlanes(undefined, mios)
if (!activo) return
if (sinPlanesAsignados(r, mios)) {
const todos = await listarPlanes(undefined, false)
if (!activo) return
setSinAsignados(true)
setResultado(todos)
return
}
setSinAsignados(false)
setResultado(r)
} catch (e) {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
}
})()
return () => {
activo = false
}
}, [mios])
const definirVista = useCallback(
(vista: VistaPlanes | null) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev)
if (vista === null) next.delete('vista')
else next.set('vista', vista)
return next
},
{ replace: true },
)
},
[setSearchParams],
)
return { mios, definirVista, resultado, errorCarga, sinAsignados }
}

View File

@@ -1,5 +1,5 @@
import { SkeletonList } from "../components/Skeleton" import { SkeletonList } from "../components/Skeleton"
import { useEffect, useMemo, useState } from 'react' import { useMemo } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { import {
Bar, Bar,
@@ -15,8 +15,7 @@ import {
YAxis, YAxis,
} from 'recharts' } from 'recharts'
import type { StatusPlan } from '../types' import type { StatusPlan } from '../types'
import { listarPlanes, miosPorDefecto, type ResultadoPlanes } from '../services/repository' import { usePlanesCobranza } from '../lib/usePlanesCobranza'
import { getSesion } from '../services/auth'
import { import {
alCorriente, alCorriente,
avancePlan, avancePlan,
@@ -33,6 +32,7 @@ import { useHoy } from '../lib/useHoy'
import MoraDashboard from "../components/MoraDashboard" import MoraDashboard from "../components/MoraDashboard"
import KpiCard from '../components/KpiCard' import KpiCard from '../components/KpiCard'
import SemaforoDot, { semaforoPlan } from '../components/Semaforo' import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
import ToggleVista from '../components/ToggleVista'
const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'] const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']
const COLORES_PIE = ['#2563EB', '#CBD5E1'] const COLORES_PIE = ['#2563EB', '#CBD5E1']
@@ -49,8 +49,6 @@ function claveMes(fecha: Date): string {
} }
export default function DashboardPage() { export default function DashboardPage() {
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null)
const [errorCarga, setErrorCarga] = useState<string | null>(null)
// Filtros y ordenamiento persistidos en la URL para que sean compartibles // Filtros y ordenamiento persistidos en la URL para que sean compartibles
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const busqueda = searchParams.get('q') ?? '' const busqueda = searchParams.get('q') ?? ''
@@ -59,30 +57,13 @@ export default function DashboardPage() {
const paramOrden = searchParams.get('sort') const paramOrden = searchParams.get('sort')
const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc' const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
// Toggle "Mis planes / Todos": persiste en la URL (?vista=mios|todos); sin // Toggle "Mis planes / Todos" (persiste en ?vista=): lo comparte con
// param, los vendedores arrancan en "Mis planes" y el resto en "Todos" // "Cobranza del día" y avisa si el usuario no tiene planes asignados
const paramVista = searchParams.get('vista') const { mios, definirVista, resultado, errorCarga, sinAsignados } = usePlanesCobranza()
const mios = paramVista !== null ? paramVista === 'mios' : miosPorDefecto(getSesion()?.usuario.rol)
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la // "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
// pestaña queda abierta de un día para otro) // pestaña queda abierta de un día para otro)
const hoy = useHoy() const hoy = useHoy()
useEffect(() => {
let activo = true
setResultado(null)
setErrorCarga(null)
listarPlanes(undefined, mios)
.then((r) => {
if (activo) setResultado(r)
})
.catch((e: unknown) => {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
})
return () => {
activo = false
}
}, [mios])
const planes = useMemo(() => resultado?.planes ?? [], [resultado]) const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const idsPlan = useMemo(() => new Set(planes.map((p) => p.id)), [planes]) const idsPlan = useMemo(() => new Set(planes.map((p) => p.id)), [planes])
@@ -239,6 +220,11 @@ export default function DashboardPage() {
: 'Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.'} : 'Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.'}
</p> </p>
)} )}
{sinAsignados && (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
No tienes planes asignados a tu usuario; se muestra el listado de <strong>todos</strong> los planes.
</p>
)}
{/* KPIs */} {/* KPIs */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-3 md:gap-6 lg:grid-cols-4">
@@ -302,29 +288,8 @@ export default function DashboardPage() {
<div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"> <div className="overflow-hidden rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
{/* Filtros */} {/* Filtros */}
<div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5"> <div className="flex flex-wrap items-center gap-3 border-b border-[#E5E7EB] p-4 md:gap-4 md:px-5">
{/* Toggle Mis planes / Todos (?mios=1 filtra en el API por el vendedor del token) */} {/* Toggle Mis planes / Todos (?vista= filtra en el API por el vendedor del token) */}
<div className="flex rounded-lg border border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]" role="group" aria-label="Alcance de planes"> <ToggleVista mios={mios} onCambiar={definirVista} />
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? null : 'mios' })}
aria-pressed={mios}
className={`rounded-l-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? 'bg-accent text-white shadow-sm' : 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:hover:text-[#E2E8F0] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
}`}
>
Mis planes
</button>
<button
type="button"
onClick={() => actualizarParams({ vista: mios ? 'todos' : null })}
aria-pressed={!mios}
className={`rounded-r-lg px-4 py-2.5 text-sm font-medium transition-all duration-100 ease-out ${
mios ? 'bg-white text-[#64748B] dark:text-[#94A3B8] hover:text-[#111827] dark:hover:text-[#E2E8F0] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]' : 'bg-accent text-white shadow-sm'
}`}
>
Todos
</button>
</div>
<label htmlFor="filtro-busqueda" className="sr-only"> <label htmlFor="filtro-busqueda" className="sr-only">
Buscar por cliente o unidad Buscar por cliente o unidad
</label> </label>
@@ -348,6 +313,7 @@ export default function DashboardPage() {
<option value="TODOS">Todos los status</option> <option value="TODOS">Todos los status</option>
<option value="ACTIVO">ACTIVO</option> <option value="ACTIVO">ACTIVO</option>
<option value="LIQUIDADO">LIQUIDADO</option> <option value="LIQUIDADO">LIQUIDADO</option>
<option value="SIN_PLAN">SIN PLAN CAPTURADO</option>
<option value="CANCELADO">CANCELADO</option> <option value="CANCELADO">CANCELADO</option>
</select> </select>
<label htmlFor="filtro-proyecto" className="sr-only"> <label htmlFor="filtro-proyecto" className="sr-only">

View File

@@ -1,34 +1,24 @@
import { SkeletonList } from "../components/Skeleton" import { SkeletonList } from "../components/Skeleton"
import { useEffect, useMemo, useState } from 'react' import { useMemo } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { listarPlanes, type ResultadoPlanes } from '../services/repository'
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza' import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters' import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
import { useHoy } from '../lib/useHoy' import { useHoy } from '../lib/useHoy'
import { usePlanesCobranza } from '../lib/usePlanesCobranza'
import KpiCard from '../components/KpiCard' import KpiCard from '../components/KpiCard'
import ToggleVista from '../components/ToggleVista'
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */ /**
* Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de los
* planes activos del alcance elegido (el mismo toggle "Mis planes / Todos" del
* dashboard, compartido por la URL).
*/
export default function HoyPage() { export default function HoyPage() {
const [resultado, setResultado] = useState<ResultadoPlanes | null>(null) const { mios, definirVista, resultado, errorCarga, sinAsignados } = usePlanesCobranza()
const [errorCarga, setErrorCarga] = useState<string | null>(null)
// "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la // "Hoy" se recalcula al enfocar la ventana (evita atrasos congelados si la
// pestaña queda abierta de un día para otro) // pestaña queda abierta de un día para otro)
const hoy = useHoy() const hoy = useHoy()
useEffect(() => {
let activo = true
listarPlanes()
.then((r) => {
if (activo) setResultado(r)
})
.catch((e: unknown) => {
if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes')
})
return () => {
activo = false
}
}, [])
const planes = useMemo(() => resultado?.planes ?? [], [resultado]) const planes = useMemo(() => resultado?.planes ?? [], [resultado])
const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy]) const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy])
@@ -51,8 +41,18 @@ export default function HoyPage() {
<div> <div>
<h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Cobranza del día</h1> <h1 className="text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">Cobranza del día</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]"> <p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos. Pagos vencidos y vencimientos de los próximos 7 días, en los planes activos
{mios ? ' de tu alcance' : ' de todos los proyectos'}.
</p> </p>
<div className="mt-3">
{/* Mismo toggle que el dashboard: la elección viaja en ?vista= */}
<ToggleVista mios={mios} onCambiar={(vista) => definirVista(vista)} />
</div>
{sinAsignados && (
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
No tienes planes asignados a tu usuario; se muestra la cobranza de <strong>todos</strong> los planes.
</p>
)}
{resultado.origen === 'local' && ( {resultado.origen === 'local' && (
<p className="mt-2 text-xs text-amber-700"> <p className="mt-2 text-xs text-amber-700">
Modo offline: sin conexión al API; mostrando datos guardados en este navegador. Modo offline: sin conexión al API; mostrando datos guardados en este navegador.
@@ -123,7 +123,8 @@ export default function HoyPage() {
{items.length === 0 && ( {items.length === 0 && (
<tr> <tr>
<td colSpan={9} className="py-8 text-center text-neutral-500"> <td colSpan={9} className="py-8 text-center text-neutral-500">
No hay pagos vencidos ni vencimientos en los próximos 7 días. No hay pagos vencidos ni vencimientos en los próximos 7 días
{mios ? ' en tus planes.' : '.'}
</td> </td>
</tr> </tr>
)} )}

View File

@@ -2,11 +2,14 @@ import { Fragment, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import type { Pago, PlanPago } from '../types' import type { Pago, PlanPago } from '../types'
import { import {
actualizarPlanAsync,
agregarNotaAsync, agregarNotaAsync,
cancelarPlanAsync, cancelarPlanAsync,
obtenerPlanPorId, obtenerPlanPorId,
puedeEditarMora,
registrarAbonoAsync, registrarAbonoAsync,
} from '../services/repository' } from '../services/repository'
import { getSesion } from '../services/auth'
import { import {
avancePlan, avancePlan,
diasAtraso, diasAtraso,
@@ -43,6 +46,12 @@ export default function PlanDetallePage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [notaTexto, setNotaTexto] = useState('') const [notaTexto, setNotaTexto] = useState('')
const [errorNota, setErrorNota] = useState<string | null>(null) const [errorNota, setErrorNota] = useState<string | null>(null)
// Captura de la tasa de mora (vive en el plan, editable por admin/manager/dirección)
const [tasaInput, setTasaInput] = useState('0')
const [guardandoMora, setGuardandoMora] = useState(false)
const [errorMora, setErrorMora] = useState<string | null>(null)
const [avisoMora, setAvisoMora] = useState<string | null>(null)
const puedeEditarTasa = puedeEditarMora(getSesion()?.usuario.rol)
// "Hoy" se recalcula al enfocar la ventana (evita atrasos/mora congelados si // "Hoy" se recalcula al enfocar la ventana (evita atrasos/mora congelados si
// la pestaña queda abierta de un día para otro) // la pestaña queda abierta de un día para otro)
const hoy = useHoy() const hoy = useHoy()
@@ -70,6 +79,12 @@ export default function PlanDetallePage() {
} }
}, [id]) }, [id])
// La caja de la tasa se prellena al cargar el plan (o 0 si no tiene). No se
// reinicia con cada recarga del plan para no borrar lo que el usuario escribe.
useEffect(() => {
if (plan) setTasaInput(String(plan.tasaMoraMensual ?? 0))
}, [plan?.id])
if (cargando) { if (cargando) {
return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando...</p> return <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando...</p>
} }
@@ -94,6 +109,11 @@ export default function PlanDetallePage() {
const atraso = diasAtraso(plan, hoy) const atraso = diasAtraso(plan, hoy)
const tasaMora = plan.tasaMoraMensual ?? 0 const tasaMora = plan.tasaMoraMensual ?? 0
const mora = totalMora(plan, hoy) const mora = totalMora(plan, hoy)
// Mora que resultaría con lo capturado en la caja (vista previa antes de guardar)
const tasaCapturada = Number(tasaInput)
const tasaCapturadaValida = Number.isFinite(tasaCapturada) && tasaCapturada >= 0 && tasaCapturada <= 100
const moraConTasaCapturada = tasaCapturadaValida ? totalMora({ ...plan, tasaMoraMensual: tasaCapturada }, hoy) : 0
const tasaSinCambios = tasaCapturadaValida && round2(tasaCapturada) === round2(tasaMora)
const toggleExpandido = (pagoId: string) => { const toggleExpandido = (pagoId: string) => {
setExpandidos((prev) => { setExpandidos((prev) => {
@@ -154,6 +174,31 @@ export default function PlanDetallePage() {
} }
} }
/** Guarda la tasa de mora mensual del plan (PATCH /planes/:id) */
const guardarTasaMora = async () => {
if (!tasaCapturadaValida) {
setErrorMora('Captura una tasa mensual entre 0 y 100 (%)')
return
}
setGuardandoMora(true)
setErrorMora(null)
setAvisoMora(null)
try {
const r = await actualizarPlanAsync(plan.id, { tasaMoraMensual: round2(tasaCapturada) })
setPlan(r.plan)
setOffline(r.origen === 'local')
setAvisoMora(
r.origen === 'local'
? 'Sin conexión al API: la tasa se guardó solo en este navegador.'
: 'Tasa de mora actualizada.',
)
} catch (e) {
setErrorMora(e instanceof Error ? e.message : 'Error al guardar la tasa de mora')
} finally {
setGuardandoMora(false)
}
}
const avance = avancePlan(plan) const avance = avancePlan(plan)
/** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */ /** Siguiente pago sin cubrir (futuro próximo o vencido más antiguo) */
@@ -175,7 +220,9 @@ export default function PlanDetallePage() {
<h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1> <h1 className="mt-2 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{plan.clienteNombre}</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]"> <p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '} {plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{status}</span> <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
{status === 'SIN_PLAN' ? 'SIN PLAN CAPTURADO' : status}
</span>
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>} {atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
</p> </p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]"> <p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
@@ -201,6 +248,14 @@ export default function PlanDetallePage() {
</p> </p>
)} )}
{status === 'SIN_PLAN' && (
<div className="rounded-lg border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
<strong>Sin plan capturado:</strong> este plan no tiene calendario de pagos (enganche y
mensualidades en cero), así que no aparece en “Cobranza del día” ni en el reporte de mora. No es
un plan liquidado: hay que capturar su calendario para poder cobrarlo.
</div>
)}
{error && ( {error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)} )}
@@ -421,6 +476,75 @@ export default function PlanDetallePage() {
</div> </div>
</section> </section>
{/* Mora del plan: la tasa vive en el plan y la editan admin/manager/dirección */}
<section
className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm"
aria-labelledby="titulo-mora"
>
<h2 id="titulo-mora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Mora por atraso
</h2>
{puedeEditarTasa ? (
<form
className="mt-4"
onSubmit={(e) => {
e.preventDefault()
void guardarTasaMora()
}}
>
<label htmlFor="tasa-mora" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Tasa de mora mensual (%)
</label>
<input
id="tasa-mora"
type="number"
min={0}
max={100}
step="0.01"
inputMode="decimal"
value={tasaInput}
onChange={(e) => {
setTasaInput(e.target.value)
setErrorMora(null)
setAvisoMora(null)
}}
className="mt-1 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
/>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
La tasa es <strong>mensual en %</strong> y se prorratea por día (tasa ÷ 30) sobre el restante
de cada pago vencido, incluidos los parciales vencidos. 0 = sin mora.
</p>
<p className="mt-2 text-xs text-[#64748B] dark:text-[#94A3B8]">
Mora acumulada con esta tasa:{' '}
<span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(moraConTasaCapturada)}
</span>
</p>
{errorMora && <p className="mt-1 text-xs text-red-700">{errorMora}</p>}
{avisoMora && <p className="mt-1 text-xs text-green-700">{avisoMora}</p>}
<div className="mt-4 flex justify-end">
<button
type="submit"
disabled={guardandoMora || !tasaCapturadaValida || tasaSinCambios}
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-40"
>
{guardandoMora ? 'Guardando…' : 'Guardar tasa'}
</button>
</div>
</form>
) : (
<div className="mt-4 space-y-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
<p>
Tasa: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{tasaMora}% mensual</span>
</p>
<p>
Mora acumulada: <span className="font-semibold text-[#111827] dark:text-[#E2E8F0]">{formatoMoneda(mora)}</span>
</p>
<p className="text-xs">Solo admin, manager y dirección pueden modificar la tasa.</p>
</div>
)}
</section>
{/* Bitácora del plan */} {/* Bitácora del plan */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-bitacora"> <section className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm" aria-labelledby="titulo-bitacora">
<h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"> <h2 id="titulo-bitacora" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">

View File

@@ -379,3 +379,68 @@ describe('mis cobranzas (toggle Mis planes / Todos)', () => {
expect(r.planes.length).toBeGreaterThan(0) expect(r.planes.length).toBeGreaterThan(0)
}) })
}) })
describe('edición del plan y alcance sin planes asignados', () => {
it('sinPlanesAsignados: solo cuando "míos" vino vacío desde el API', async () => {
const { sinPlanesAsignados } = await import('./repository')
expect(sinPlanesAsignados({ planes: [], origen: 'api', planesSoloLocales: false }, true)).toBe(true)
// con resultados, con vista "todos" o en modo offline no aplica
expect(sinPlanesAsignados({ planes: [planLocal()], origen: 'api', planesSoloLocales: false }, true)).toBe(false)
expect(sinPlanesAsignados({ planes: [], origen: 'api', planesSoloLocales: false }, false)).toBe(false)
expect(sinPlanesAsignados({ planes: [], origen: 'local', planesSoloLocales: false }, true)).toBe(false)
})
it('puedeEditarMora: admin/manager/dirección sí; vendedor y roles de operación no', async () => {
const { puedeEditarMora } = await import('./repository')
expect(puedeEditarMora('admin')).toBe(true)
expect(puedeEditarMora('manager')).toBe(true)
expect(puedeEditarMora('direccion_general')).toBe(true)
expect(puedeEditarMora('director_ventas')).toBe(true)
expect(puedeEditarMora('coordinador')).toBe(true) // legacy de dirección
expect(puedeEditarMora('vendedor')).toBe(false)
expect(puedeEditarMora('administracion_venta')).toBe(false)
expect(puedeEditarMora('escrituracion')).toBe(false)
expect(puedeEditarMora(null)).toBe(false)
expect(puedeEditarMora(undefined)).toBe(false)
})
it('actualizarPlanAsync hace PATCH /planes/:id con la tasa de mora', async () => {
const { actualizarPlanAsync } = await import('./repository')
const fetchMock = fetchQue(async () => respuestaJson(planApi({ tasaMoraMensual: 2.5 })))
vi.stubGlobal('fetch', fetchMock)
const r = await actualizarPlanAsync('api-1', { tasaMoraMensual: 2.5 })
expect(r.origen).toBe('api')
expect(r.plan.tasaMoraMensual).toBe(2.5)
expect(fetchMock).toHaveBeenCalledWith(
`${API_URL}/planes/api-1`,
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ tasaMoraMensual: 2.5 }) }),
)
})
it('actualizarPlanAsync propaga el 403 del API (rol sin permiso) sin tocar localStorage', async () => {
const { actualizarPlanAsync } = await import('./repository')
vi.stubGlobal(
'fetch',
fetchQue(async () => respuestaJson({ error: 'Solo admin, manager o dirección pueden modificar el plan' }, 403)),
)
await expect(actualizarPlanAsync('api-1', { tasaMoraMensual: 2 }, crearStorageStub())).rejects.toBeInstanceOf(ErrorApi)
})
it('actualizarPlanAsync cae a localStorage cuando falla la red', async () => {
const { actualizarPlanAsync } = await import('./repository')
vi.stubGlobal('fetch', fetchSinRed())
vi.spyOn(console, 'warn').mockImplementation(() => {})
const storage = crearStorageStub()
storage.setItem(CLAVE_STORAGE, JSON.stringify([planLocal()]))
const r = await actualizarPlanAsync('local-1', { tasaMoraMensual: 3 }, storage)
expect(r.origen).toBe('local')
expect(r.plan.tasaMoraMensual).toBe(3)
const guardado = JSON.parse(storage.getItem(CLAVE_STORAGE) ?? '[]') as PlanPago[]
expect(guardado[0].tasaMoraMensual).toBe(3)
})
})

View File

@@ -183,11 +183,30 @@ export function rutaListadoPlanes(mios: boolean): string {
/** /**
* Vista inicial del toggle "Mis planes / Todos": los vendedores arrancan en * Vista inicial del toggle "Mis planes / Todos": los vendedores arrancan en
* "Mis planes"; el resto de roles (admin, coordinador, corporativo) en "Todos". * "Mis planes"; el resto de roles (admin, coordinador, corporativo) en "Todos".
* Ojo: los planes históricos tienen `vendedorId` nulo, así que "Mis planes"
* puede legítimamente venir vacío; las páginas detectan ese caso y muestran
* todos los planes con un aviso (ver `sinPlanesAsignados`).
*/ */
export function miosPorDefecto(rol: string | null | undefined): boolean { export function miosPorDefecto(rol: string | null | undefined): boolean {
return rol === 'vendedor' return rol === 'vendedor'
} }
/**
* Roles que pueden editar parámetros del plan (tasa de mora). Espejo de
* ROLES_EDITAN_PLAN del API (PATCH /planes/:id): admin, manager y dirección
* (más los roles legacy que la matriz homologa a dirección de ventas).
*/
export function puedeEditarMora(rol: string | null | undefined): boolean {
return (
rol === 'admin' ||
rol === 'manager' ||
rol === 'direccion_general' ||
rol === 'director_ventas' ||
rol === 'coordinador' ||
rol === 'corporativo'
)
}
/** /**
* GET /planes. Ante error de red cae a localStorage (sembrando si está vacío). * GET /planes. Ante error de red cae a localStorage (sembrando si está vacío).
* Si el API responde vacío pero hay planes locales, los conserva solo localmente * Si el API responde vacío pero hay planes locales, los conserva solo localmente
@@ -306,3 +325,43 @@ export async function cancelarPlanAsync(
return { plan: cancelarPlan(id, storage), origen: 'local' } return { plan: cancelarPlan(id, storage), origen: 'local' }
} }
} }
/** Campos del plan que se pueden editar (PATCH /planes/:id) */
export interface EdicionPlan {
/** Tasa de mora mensual en % sobre el restante vencido; 0 = sin mora */
tasaMoraMensual: number
}
/**
* PATCH /planes/:id. El API exige rol admin/manager/dirección (403 → se
* propaga). Solo la red cae a localStorage.
*/
export async function actualizarPlanAsync(
id: string,
cambios: EdicionPlan,
storage: StorageLike = storageDefault(),
): Promise<ResultadoPlan> {
try {
const plan = await request<PlanApi>(`/planes/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(cambios),
})
return { plan: desdeApi(plan), origen: 'api' }
} catch (err) {
if (!esErrorDeRed(err)) throw err
console.warn('[cobranza] API no disponible; los cambios se guardaron solo en localStorage:', err)
const local = obtenerPlan(id, storage)
if (!local) throw new Error('Plan no encontrado')
return { plan: actualizarPlan({ ...local, ...cambios }, storage), origen: 'local' }
}
}
/**
* ¿El listado "Mis planes" vino vacío? El API respondió sin error pero el
* usuario no tiene planes asignados (los planes históricos tienen
* `vendedorId` nulo). Las páginas lo usan para no dejar al usuario viendo una
* lista vacía: caen a todos los planes y lo avisan.
*/
export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean {
return mios && resultado.origen === 'api' && resultado.planes.length === 0
}

View File

@@ -1,4 +1,5 @@
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' /** SIN_PLAN = no hay calendario capturado (no debe confundirse con LIQUIDADO) */
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' | 'SIN_PLAN'
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL' export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
export interface PlanPago { export interface PlanPago {

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import type { Unidad, TipoPropuesta } from '../types' import type { Unidad, TipoPropuesta } from '../types'
import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades' import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades'
import { formatoMoneda } from '../lib/formatters' import { formatoMoneda } from '../lib/formatters'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos' import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas'
interface Props { interface Props {
unidades: Unidad[] unidades: Unidad[]
@@ -10,6 +10,8 @@ interface Props {
tipo: TipoPropuesta tipo: TipoPropuesta
apartado: number apartado: number
acabados: boolean acabados: boolean
/** Reglas comerciales por desarrollo (vacío => constantes) */
reglas?: ReglasDesarrollo
} }
const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`) const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`)
@@ -23,25 +25,42 @@ interface Fila {
/** /**
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado, * Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
* con la propuesta activa calculada por el motor con los parámetros de cada * con la propuesta activa calculada por el motor con los parámetros de cada
* desarrollo. * desarrollo (las reglas del admin con respaldo en las constantes). Solo ofrece
* unidades DISPONIBLES de desarrollos activos en el cotizador: no se compara
* (ni se cotiza) inventario apartado, vendido, bloqueado o desactivado.
*/ */
export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props) { export function ComparadorUnidades({ unidades, tipo, apartado, acabados, reglas = SIN_REGLAS }: Props) {
const [abierto, setAbierto] = useState(false) const [abierto, setAbierto] = useState(false)
const [codigoA, setCodigoA] = useState('') const [codigoA, setCodigoA] = useState('')
const [codigoB, setCodigoB] = useState('') const [codigoB, setCodigoB] = useState('')
const desarrollos = useMemo(() => idsCotizables(reglas), [reglas])
const disponibles = useMemo(
() =>
unidades.filter(
(u) =>
(u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' &&
desarrollos.includes(u.desarrollo ?? 'DIAGONAL'),
),
[unidades, desarrollos],
)
const porCodigo = useMemo(() => { const porCodigo = useMemo(() => {
const m = new Map<string, Unidad>() const m = new Map<string, Unidad>()
for (const u of unidades) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u) for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
return m return m
}, [unidades]) }, [disponibles])
const unidadA = porCodigo.get(codigoA) ?? null const unidadA = porCodigo.get(codigoA) ?? null
const unidadB = porCodigo.get(codigoB) ?? null const unidadB = porCodigo.get(codigoB) ?? null
const comparacion = useMemo( const comparacion = useMemo(
() => (unidadA && unidadB ? compararUnidades(unidadA, unidadB, tipo, apartado, acabados) : null), () =>
[unidadA, unidadB, tipo, apartado, acabados], unidadA && unidadB
? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas)
: null,
[unidadA, unidadB, tipo, apartado, acabados, reglas],
) )
const filas: Fila[] = [ const filas: Fila[] = [
@@ -81,11 +100,11 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none" className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
> >
<option value="">Selecciona una unidad…</option> <option value="">Selecciona una unidad…</option>
{LISTA_DESARROLLOS.map((dev) => { {desarrollos.map((dev) => {
const delDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev) const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
if (delDesarrollo.length === 0) return null if (delDesarrollo.length === 0) return null
return ( return (
<optgroup key={dev} label={obtenerDesarrollo(dev).nombre}> <optgroup key={dev} label={desarrolloEfectivo(reglas, dev).nombre}>
{delDesarrollo.map((u) => ( {delDesarrollo.map((u) => (
<option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}> <option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}>
{u.codigo} — {u.modelo} {u.codigo} — {u.modelo}
@@ -172,8 +191,8 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
</tbody> </tbody>
</table> </table>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]"> <p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Cada unidad se calcula con los parámetros de su desarrollo y el mismo apartado. Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
El enganche ya incluye la deducción del apartado. su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
</p> </p>
</div> </div>
)} )}

View File

@@ -27,6 +27,15 @@ const formatoFechaHora = (iso: string): string =>
minute: '2-digit', minute: '2-digit',
}) })
/**
* Mensualidades guardadas: "N × $m". Las cotizaciones viejas (plazo 0/null, sin
* mensualidad) muestran "—".
*/
const formatoMensualidades = (c: CotizacionGuardada): string => {
if (c.mensualidad === null || c.numMensualidades === 0) return '—'
return `${c.numMensualidades} × ${formatoMoneda(c.mensualidad)}`
}
/** Sección colapsable "Mis cotizaciones": historial del vendedor logueado */ /** Sección colapsable "Mis cotizaciones": historial del vendedor logueado */
export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Props) { export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Props) {
const [abierto, setAbierto] = useState(false) const [abierto, setAbierto] = useState(false)
@@ -65,7 +74,7 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<table className="w-full min-w-[640px] border-collapse text-sm"> <table className="w-full min-w-[640px] border-collapse text-sm">
<thead> <thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]"> <tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Precio final', ''].map( {['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Mensualidades', 'Precio final', ''].map(
(h) => ( (h) => (
<th <th
key={h} key={h}
@@ -95,6 +104,9 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap"> <td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{TITULO_PROPUESTA[c.tipoPropuesta] ?? c.tipoPropuesta} {TITULO_PROPUESTA[c.tipoPropuesta] ?? c.tipoPropuesta}
</td> </td>
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{formatoMensualidades(c)}
</td>
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap"> <td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap">
{formatoMoneda(c.precioFinal)} {formatoMoneda(c.precioFinal)}
</td> </td>

View File

@@ -1,10 +1,16 @@
import type { DescuentosPropuestas, ParametrosDesarrollo } from '../types' import type { DescuentosPropuestas, ParametrosDesarrollo } from '../types'
/** /**
* Parámetros comerciales por desarrollo, replicados de los cotizadores Excel * Parámetros comerciales por desarrollo: VALORES POR DEFECTO / RESPALDO,
* de producción (ver reference/cotizadores/*.md). NO "corregir" fórmulas: * replicados de los cotizadores Excel de producción (ver
* algunas usan precio de lista donde intuitivamente iría precio final; así * reference/cotizadores/*.md).
* está en los Excel y así debe quedar. *
* Las reglas que usa el cotizador son las que el admin configura en la BD y
* llegan por el API (GET /desarrollos/reglas-cotizador); estas constantes son
* el respaldo para los desarrollos sin reglas guardadas y completan los campos
* que falten (ver lib/reglas.ts). NO "corregir" fórmulas: algunas usan precio
* de lista donde intuitivamente iría precio final; así está en los Excel y así
* debe quedar.
*/ */
const DIAGONAL: ParametrosDesarrollo = { const DIAGONAL: ParametrosDesarrollo = {
@@ -12,6 +18,9 @@ const DIAGONAL: ParametrosDesarrollo = {
nombre: 'Diagonal', nombre: 'Diagonal',
precioUnico: false, precioUnico: false,
fuenteDeN: 'fechaEntrega', fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega: mediana de la
// terminación de obra del inventario disponible (2026-09) = 13 mensualidades
nDefault: 13,
cuotaMantenimiento: 1500, cuotaMantenimiento: 1500,
prefijoUnidadPdf: 'CASA', prefijoUnidadPdf: 'CASA',
propuestas: [ propuestas: [
@@ -180,7 +189,10 @@ const SENDA: ParametrosDesarrollo = {
id: 'SENDA', id: 'SENDA',
nombre: 'Senda', nombre: 'Senda',
precioUnico: true, precioUnico: true,
fuenteDeN: 'fechaEntrega', // unidades sin fecha en TERM => N null (mensualidades "—") fuenteDeN: 'fechaEntrega', // N = meses hasta TERM. OBRA (captura ESCRITURACION)
// Último recurso si la unidad no trae fecha: mediana del inventario
// disponible (2026-09) = 6 mensualidades
nDefault: 6,
cuotaMantenimiento: null, cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA', prefijoUnidadPdf: 'CASA',
propuestas: [ propuestas: [
@@ -236,6 +248,9 @@ const SOLANA: ParametrosDesarrollo = {
nombre: 'Solana', nombre: 'Solana',
precioUnico: true, precioUnico: true,
fuenteDeN: 'porUnidad', // MESES PARA FIRMA APROX del inventario (1–4) fuenteDeN: 'porUnidad', // MESES PARA FIRMA APROX del inventario (1–4)
// Último recurso si la unidad no trae meses para firma: las casas vendibles
// del inventario Excel están en 1 mes
nDefault: 1,
cuotaMantenimiento: null, cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA', prefijoUnidadPdf: 'CASA',
propuestas: [ propuestas: [
@@ -300,9 +315,13 @@ export const DESARROLLO_DEFAULT = 'DIAGONAL'
/** Ids en orden de presentación en el selector */ /** Ids en orden de presentación en el selector */
export const LISTA_DESARROLLOS: string[] = ['DIAGONAL', 'ATELIER', 'ESTANZA', 'SENDA', 'SOLANA'] export const LISTA_DESARROLLOS: string[] = ['DIAGONAL', 'ATELIER', 'ESTANZA', 'SENDA', 'SOLANA']
/** Parámetros de un desarrollo; cae a DIAGONAL si el id es desconocido */ /**
* Parámetros de un desarrollo por CONSTANTE (respaldo); cae a DIAGONAL si el id
* es desconocido. Para las reglas efectivas (las del admin) usar
* `desarrolloEfectivo` de lib/reglas.ts.
*/
export function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo { export function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo {
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT] return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]!
} }
/** Descuentos por defecto del desarrollo, indexados por clave de propuesta */ /** Descuentos por defecto del desarrollo, indexados por clave de propuesta */

View File

@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { useCotizacion } from './useCotizacion'
import { reglasDesdeApi, SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas'
import { DESARROLLOS } from '../data/desarrollos'
import { INVENTARIO_SEED } from '../data/unidades'
/**
* Reglas comerciales configurables en el cotizador: las propuestas desactivadas
* por el admin dejan de ofrecerse y, si la elegida se desactiva, se avisa y se
* cae a la primera vigente.
*/
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
function reglasDiagonales(activa: (tipo: string) => boolean): ReglasDesarrollo {
return reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => ({ ...p, activa: activa(p.tipo) })),
},
})
}
function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
const hook = renderHook(({ reglas }) => useCotizacion(reglas), {
initialProps: { reglas: reglas as ReglasDesarrollo },
})
act(() => {
hook.result.current.setUnidad(unidadDiagonal)
})
return hook
}
afterEach(cleanup)
describe('useCotizacion y las reglas del admin', () => {
it('sin reglas ofrece las 4 propuestas de siempre', () => {
const { result } = montar()
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
])
expect(result.current.avisoPropuestaDesactivada).toBeNull()
})
it('una propuesta desactivada no se ofrece ni se puede elegir', () => {
const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO'))
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
})
it('si el admin desactiva la propuesta elegida, avisa y cae a la primera vigente', () => {
const { result, rerender } = montar()
// El vendedor elige Contado (índice 2)
act(() => {
result.current.setPropuestaIndex(2)
})
expect(result.current.propuestas[2]?.tipo).toBe('CONTADO')
// El admin la desactiva (llegan reglas nuevas)
rerender({ reglas: reglasDiagonales((tipo) => tipo !== 'CONTADO') })
expect(result.current.propuestas.map((p) => p.tipo)).not.toContain('CONTADO')
expect(result.current.propuestaIndex).toBe(0)
expect(result.current.avisoPropuestaDesactivada).toContain('Contado')
expect(result.current.avisoPropuestaDesactivada).toContain('Crédito Bancario')
})
it('los descuentos por defecto salen de las reglas configuradas', () => {
const { result } = montar(
reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.06 } : p,
),
},
}),
)
expect(result.current.descuentos.contado).toBe(0.06)
expect(result.current.descuentos.semiContado).toBe(0.01)
})
it('las reglas cambian los números de las propuestas', () => {
const { result } = montar(
reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.05 } : p,
),
},
}),
)
const bancario = result.current.propuestas.find((p) => p.tipo === 'CREDITO_BANCARIO')
const precioLista = result.current.precioLista
expect(bancario?.enganche).toBeCloseTo(0.05 * precioLista - result.current.apartadoEfectivo, 2)
})
})

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types' import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
import { import {
APARTADO_DEFAULT, APARTADO_DEFAULT,
@@ -8,7 +8,14 @@ import {
validarEnganchePct, validarEnganchePct,
type PropuestaResultado, type PropuestaResultado,
} from '../lib/calculations' } from '../lib/calculations'
import { DESARROLLO_DEFAULT, descuentosDefault, obtenerDesarrollo } from '../data/desarrollos' import { DESARROLLO_DEFAULT } from '../data/desarrollos'
import {
SIN_REGLAS,
descuentosDefaultEfectivo,
desarrolloEfectivo,
propuestasVigentes,
type ReglasDesarrollo,
} from '../lib/reglas'
interface UseCotizacionResult { interface UseCotizacionResult {
desarrollo: string desarrollo: string
@@ -40,6 +47,8 @@ interface UseCotizacionResult {
/** Número efectivo de mensualidades (la elegida o la calculada) */ /** Número efectivo de mensualidades (la elegida o la calculada) */
numMeses: number | null numMeses: number | null
propuestas: PropuestaResultado[] propuestas: PropuestaResultado[]
/** Aviso cuando el admin desactivó la propuesta que estaba elegida; null si no aplica */
avisoPropuestaDesactivada: string | null
/** Restaura el estado completo desde una cotización guardada (historial) */ /** Restaura el estado completo desde una cotización guardada (historial) */
restaurar: (estado: EstadoRestaurar) => void restaurar: (estado: EstadoRestaurar) => void
/** Reinicia la cotización (botón "Nueva") */ /** Reinicia la cotización (botón "Nueva") */
@@ -55,26 +64,51 @@ export interface EstadoRestaurar {
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */ /** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */
descuento?: number descuento?: number
propuestaIndex?: number propuestaIndex?: number
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */
numMensualidades?: number | null
} }
/** Estado central de la cotización: desarrollo, unidad, acabados, apartado y descuentos */ /**
export function useCotizacion(): UseCotizacionResult { * Estado central de la cotización: desarrollo, unidad, acabados, apartado y
* descuentos. Las reglas comerciales llegan del API (con respaldo en las
* constantes): si el admin desactiva una propuesta, deja de ofrecerse y, si era
* la elegida, se avisa y se cae a la primera vigente.
*/
export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult {
const [desarrollo, setDesarrolloState] = useState<string>(DESARROLLO_DEFAULT) const [desarrollo, setDesarrolloState] = useState<string>(DESARROLLO_DEFAULT)
const [unidad, setUnidad] = useState<Unidad | null>(null) const [unidad, setUnidad] = useState<Unidad | null>(null)
const [acabados, setAcabados] = useState(false) const [acabados, setAcabados] = useState(false)
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT) const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT)
const [enganchePct, setEnganchePct] = useState<number | null>(null) const [enganchePct, setEnganchePct] = useState<number | null>(null)
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(descuentosDefault(DESARROLLO_DEFAULT)) const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(
descuentosDefaultEfectivo(reglas, DESARROLLO_DEFAULT),
)
const [propuestaIndex, setPropuestaIndex] = useState(0) const [propuestaIndex, setPropuestaIndex] = useState(0)
const [numMensualidades, setNumMensualidades] = useState<number | null>(null) const [numMensualidades, setNumMensualidades] = useState<number | null>(null)
const [avisoPropuestaDesactivada, setAvisoPropuestaDesactivada] = useState<string | null>(null)
const params = obtenerDesarrollo(desarrollo) const params = desarrolloEfectivo(reglas, desarrollo)
// Los descuentos por defecto se re-aplican cuando cambian las reglas del
// desarrollo (p. ej. al llegar las del API), sin pisar lo que capturó el
// vendedor mientras las reglas no cambian.
const descuentosAplicados = useRef<string>('')
const defaults = useMemo(
() => descuentosDefaultEfectivo(reglas, desarrollo),
[reglas, desarrollo],
)
useEffect(() => {
const clave = JSON.stringify(defaults)
if (descuentosAplicados.current === clave) return
descuentosAplicados.current = clave
setDescuentos(defaults)
}, [defaults])
const setDesarrollo = (id: string) => { const setDesarrollo = (id: string) => {
setDesarrolloState(id) setDesarrolloState(id)
setUnidad(null) setUnidad(null)
setAcabados(false) setAcabados(false)
setDescuentos(descuentosDefault(id)) setDescuentos(descuentosDefaultEfectivo(reglas, id))
setPropuestaIndex(0) setPropuestaIndex(0)
setNumMensualidades(null) setNumMensualidades(null)
} }
@@ -88,14 +122,17 @@ export function useCotizacion(): UseCotizacionResult {
setUnidad(estado.unidad) setUnidad(estado.unidad)
setAcabados(estado.acabados) setAcabados(estado.acabados)
setApartado(estado.apartado) setApartado(estado.apartado)
const base = descuentosDefault(estado.desarrollo) const base = descuentosDefaultEfectivo(reglas, estado.desarrollo)
const paramsRestaurados = desarrolloEfectivo(reglas, estado.desarrollo)
const vigentes = propuestasVigentes(paramsRestaurados)
if (estado.descuento !== undefined) { if (estado.descuento !== undefined) {
const idx = estado.propuestaIndex ?? 0 const idx = estado.propuestaIndex ?? 0
const clave = obtenerDesarrollo(estado.desarrollo).propuestas[idx]?.claveDescuento const clave = vigentes[idx]?.claveDescuento
if (clave) base[clave] = estado.descuento if (clave) base[clave] = estado.descuento
} }
setDescuentos(base) setDescuentos(base)
setPropuestaIndex(estado.propuestaIndex ?? 0) setPropuestaIndex(estado.propuestaIndex ?? 0)
setNumMensualidades(estado.numMensualidades ?? null)
} }
const nueva = () => { const nueva = () => {
@@ -103,9 +140,10 @@ export function useCotizacion(): UseCotizacionResult {
setAcabados(false) setAcabados(false)
setApartado(APARTADO_DEFAULT) setApartado(APARTADO_DEFAULT)
setEnganchePct(null) setEnganchePct(null)
setDescuentos(descuentosDefault(desarrollo)) setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo))
setPropuestaIndex(0) setPropuestaIndex(0)
setNumMensualidades(null) setNumMensualidades(null)
setAvisoPropuestaDesactivada(null)
} }
const numMesesCalculado = useMemo( const numMesesCalculado = useMemo(
@@ -131,8 +169,40 @@ export function useCotizacion(): UseCotizacionResult {
const propuestas = useMemo(() => { const propuestas = useMemo(() => {
if (!unidad) return [] if (!unidad) return []
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo, numMeses: numMeses ?? undefined }) return calcularPropuestas({
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo, numMeses]) unidad,
acabados,
apartado: apartadoEfectivo,
descuentos,
desarrollo,
reglas: params,
enganchePct: engancheEfectivo,
numMeses: numMeses ?? undefined,
})
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, params, engancheEfectivo, numMeses])
// Propuesta elegida: si el índice se sale de la lista vigente (p. ej. el admin
// desactivó una propuesta) se cae a la primera y, si la que estaba elegida ya
// no existe, se avisa con claridad.
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
const tipoPrevio = useRef<{ tipo: string; titulo: string } | null>(null)
useEffect(() => {
const previo = tipoPrevio.current
const vigentes = propuestasVigentes(params)
if (previo !== null && !vigentes.some((p) => p.tipo === previo.tipo)) {
const sustituto = propuestas[0]?.titulo ?? 'otra propuesta'
setAvisoPropuestaDesactivada(
`El administrador desactivó la propuesta ${previo.titulo} para ${params.nombre}. Se seleccionó ${sustituto}; elige otra si lo prefieres.`,
)
setPropuestaIndex(0)
} else if (propuestaIndex >= propuestas.length) {
setPropuestaIndex(0)
}
tipoPrevio.current = propuestaActiva
? { tipo: propuestaActiva.tipo, titulo: propuestaActiva.titulo }
: null
}, [params, propuestas, propuestaActiva, propuestaIndex])
return { return {
desarrollo, desarrollo,
@@ -157,6 +227,7 @@ export function useCotizacion(): UseCotizacionResult {
setNumMensualidades, setNumMensualidades,
numMeses, numMeses,
propuestas, propuestas,
avisoPropuestaDesactivada,
restaurar, restaurar,
nueva, nueva,
} }

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { Unidad } from '../types' import type { Unidad } from '../types'
import { getUnidades } from '../services/api' import { getReglasCotizador, getUnidades } from '../services/api'
import { SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas'
interface UseUnidadesResult { interface UseUnidadesResult {
unidades: Unidad[] unidades: Unidad[]
@@ -8,21 +9,29 @@ interface UseUnidadesResult {
error: string | null error: string | null
/** true cuando el inventario viene del seed local (sin conexión al API) */ /** true cuando el inventario viene del seed local (sin conexión al API) */
usandoSeed: boolean usandoSeed: boolean
/** Reglas comerciales por desarrollo (vacío => se usan las constantes) */
reglas: ReglasDesarrollo
} }
/** Carga el inventario de unidades desde el API Gateway, con fallback al seed local */ /**
* Carga el inventario de unidades desde el API Gateway, con fallback al seed
* local, junto con las reglas comerciales configuradas por el admin (que caen a
* los valores por defecto si no llegan).
*/
export function useUnidades(): UseUnidadesResult { export function useUnidades(): UseUnidadesResult {
const [unidades, setUnidades] = useState<Unidad[]>([]) const [unidades, setUnidades] = useState<Unidad[]>([])
const [reglas, setReglas] = useState<ReglasDesarrollo>(SIN_REGLAS)
const [cargando, setCargando] = useState(true) const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [usandoSeed, setUsandoSeed] = useState(false) const [usandoSeed, setUsandoSeed] = useState(false)
useEffect(() => { useEffect(() => {
let activo = true let activo = true
getUnidades() Promise.all([getUnidades(), getReglasCotizador()])
.then(({ unidades, usandoSeed }) => { .then(([{ unidades, usandoSeed }, reglasApi]) => {
if (activo) { if (activo) {
setUnidades(unidades) setUnidades(unidades)
setReglas(reglasApi)
setUsandoSeed(usandoSeed) setUsandoSeed(usandoSeed)
setCargando(false) setCargando(false)
} }
@@ -38,5 +47,5 @@ export function useUnidades(): UseUnidadesResult {
} }
}, []) }, [])
return { unidades, cargando, error, usandoSeed } return { unidades, cargando, error, usandoSeed, reglas }
} }

View File

@@ -169,7 +169,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0 en el Excel, N = 4)', ()
describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => { describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
const unidad = porCodigo(UNIDADES_SENDA, 'SENDA-411-25-A101') const unidad = porCodigo(UNIDADES_SENDA, 'SENDA-411-25-A101')
it('N = null y mensualidades "—" con nota; enganche y pago final sí se calculan', () => { it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => {
expect(unidad.fechaEntrega).toBeNull() expect(unidad.fechaEntrega).toBeNull()
const [bancario] = calcularPropuestas({ const [bancario] = calcularPropuestas({
unidad, unidad,
@@ -177,13 +177,13 @@ describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
apartado: APARTADO_DEFAULT, apartado: APARTADO_DEFAULT,
descuentos: descuentosDefault('SENDA'), descuentos: descuentosDefault('SENDA'),
}) })
expect(bancario.numMensualidades).toBeNull() // Antes: numMensualidades/mensualidad null y "— (sin fecha de entrega)"
expect(bancario.mensualidad).toBeNull() expect(bancario.numMensualidades).toBe(DESARROLLOS['SENDA'].nDefault)
expect(bancario.mensualidadesTotal).toBeNull() expect(bancario.notaMensualidades).toBeUndefined()
expect(bancario.notaMensualidades).toBe('sin fecha de entrega') expect(bancario.mensualidad).not.toBeNull()
expect(bancario.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2) expect(bancario.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
expect(bancario.pagoFinal).toBeCloseTo(0.9 * 1948000, 2) expect(bancario.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeNull() expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2)
}) })
}) })
@@ -209,11 +209,12 @@ describe('SOLANA — casa 6 (L = 2,560,000, apartado 20,000, mesesParaFirma = 1)
expect(p.pagoFinal).toBeCloseTo(2304000, 2) expect(p.pagoFinal).toBeCloseTo(2304000, 2)
}) })
it('INFONAVIT: enganche 0% con guarda (no resta el apartado)', () => { it('INFONAVIT: enganche 0% y el apartado se descuenta de las mensualidades', () => {
const [, p] = calcular() const [, p] = calcular()
expect(p.enganche).toBe(0) expect(p.enganche).toBe(0)
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2) expect(p.mensualidadesTotal).toBeCloseTo(108000, 2)
expect(p.pagoFinal).toBeCloseTo(2432000, 2) expect(p.pagoFinal).toBeCloseTo(2432000, 2)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2560000, 2)
}) })
it('CONTADO', () => { it('CONTADO', () => {
@@ -240,19 +241,115 @@ describe('SOLANA — casa 9 (L = 2,605,000, mesesParaFirma = 1)', () => {
descuentos: descuentosDefault('SOLANA'), descuentos: descuentosDefault('SOLANA'),
}) })
it('las 4 propuestas al centavo', () => { it('las 4 propuestas al centavo (Infonavit descuenta el apartado)', () => {
const [bancario, infonavit, contado, semi] = calcular() const [bancario, infonavit, contado, semi] = calcular()
expect(bancario.enganche).toBeCloseTo(110250, 2) expect(bancario.enganche).toBeCloseTo(110250, 2)
expect(bancario.mensualidadesTotal).toBeCloseTo(130250, 2) expect(bancario.mensualidadesTotal).toBeCloseTo(130250, 2)
expect(bancario.pagoFinal).toBeCloseTo(2344500, 2) expect(bancario.pagoFinal).toBeCloseTo(2344500, 2)
expect(infonavit.enganche).toBe(0) expect(infonavit.enganche).toBe(0)
expect(infonavit.mensualidadesTotal).toBeCloseTo(130250, 2) expect(infonavit.mensualidadesTotal).toBeCloseTo(110250, 2)
expect(infonavit.pagoFinal).toBeCloseTo(2474750, 2) expect(infonavit.pagoFinal).toBeCloseTo(2474750, 2)
expect(contado.enganche).toBeCloseTo(2324500, 2) expect(contado.enganche).toBeCloseTo(2324500, 2)
expect(contado.pagoFinal).toBeCloseTo(260500, 2) expect(contado.pagoFinal).toBeCloseTo(260500, 2)
expect(semi.enganche).toBeCloseTo(1282500, 2) expect(semi.enganche).toBeCloseTo(1282500, 2)
expect(semi.mensualidadesTotal).toBeCloseTo(1042000, 2) expect(semi.mensualidadesTotal).toBeCloseTo(1042000, 2)
expect(semi.pagoFinal).toBeCloseTo(260500, 2) expect(semi.pagoFinal).toBeCloseTo(260500, 2)
for (const p of [bancario, infonavit, contado, semi]) {
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2605000, 2)
}
})
})
describe('cuadre del total: apartado y enganche personalizado', () => {
const TIPOS = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
] as const
const CASOS = [
{ desarrollo: 'DIAGONAL', precio: 4_415_000 },
{ desarrollo: 'ATELIER', precio: 3_997_500 },
{ desarrollo: 'ESTANZA', precio: 2_437_250 },
{ desarrollo: 'SENDA', precio: 2_451_750 },
{ desarrollo: 'SOLANA', precio: 2_560_000 },
]
const unidadDe = (desarrollo: string, precio: number): Unidad => ({
codigo: `${desarrollo}-TEST`,
modelo: 'X',
desarrollo,
fechaEntrega: null,
mesesParaFirma: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: precio,
precioConAcabados: precio,
status: 'DISPONIBLE',
})
const calcular = (caso: (typeof CASOS)[number], tipo: (typeof TIPOS)[number], apartado: number, enganchePct?: number) =>
calcularPropuestas({
unidad: unidadDe(caso.desarrollo, caso.precio),
acabados: false,
apartado,
descuentos: descuentosDefault(caso.desarrollo),
enganchePct: enganchePct ?? null,
}).find((p) => p.tipo === tipo)!
it('las 4 propuestas de cada desarrollo cuestan lo que el precio final: ni más ni menos', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
const p = calcular(caso, tipo, APARTADO_DEFAULT)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(p.precioFinal, 2)
}
}
})
it('nunca hay bloques negativos y el total es el precio (apartados y enganches extremos)', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
for (const apartado of [0, APARTADO_DEFAULT, 200_000]) {
for (const enganchePct of [undefined, 0.05, 0.2, 0.5, 1]) {
const p = calcular(caso, tipo, apartado, enganchePct)
expect(p.enganche).toBeGreaterThanOrEqual(0)
expect(p.mensualidadesTotal ?? 0).toBeGreaterThanOrEqual(0)
expect(p.pagoFinal).toBeGreaterThanOrEqual(0)
expect(totalPagado(p, apartado)).toBeCloseTo(p.precioFinal, 2)
}
}
}
}
})
it('enganche personalizado alto (20%) no infla el total: sigue siendo el precio', () => {
const u101 = unidadDe('DIAGONAL', 4_415_000)
const [bancario] = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: descuentosDefault('DIAGONAL'),
enganchePct: 0.2,
})
// Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2)
expect(bancario.mensualidadesTotal).toBe(0)
expect(bancario.enganche).toBeCloseTo(0.2 * 4_415_000 - APARTADO_DEFAULT, 2)
})
it('apartado mayor al enganche: el enganche queda en 0 y el total cuadra', () => {
const u101 = unidadDe('DIAGONAL', 4_415_000)
const [bancario] = calcularPropuestas({
unidad: u101,
acabados: false,
// 2.5% de 4,415,000 = 110,375 → con apartado 200,000 el neto sería −89,625
apartado: 200_000,
descuentos: descuentosDefault('DIAGONAL'),
})
expect(bancario.enganche).toBe(0)
expect(bancario.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - bancario.pagoFinal, 2)
expect(totalPagado(bancario, 200_000)).toBeCloseTo(4_415_000, 2)
}) })
}) })

View File

@@ -261,11 +261,16 @@ describe('enganche personalizado (mínimo 2.5% del valor de la propiedad)', () =
}) })
describe('validarEnganchePct', () => { describe('validarEnganchePct', () => {
it('null es válido (sin override); mínimo 2.5%; máximo 100%', () => { it('null es válido (sin override); rango válido del Excel: 2.5% a 5%', () => {
expect(validarEnganchePct(null)).toBeNull() expect(validarEnganchePct(null)).toBeNull()
expect(validarEnganchePct(2.5)).toBeNull() expect(validarEnganchePct(2.5)).toBeNull()
expect(validarEnganchePct(50)).toBeNull() expect(validarEnganchePct(5)).toBeNull()
expect(validarEnganchePct(2.4)).toBe('El enganche mínimo es 2.5% del valor de la propiedad') expect(validarEnganchePct(2.4)).toBe('El enganche mínimo es 2.5% del valor de la propiedad')
expect(validarEnganchePct(101)).toBe('El enganche no puede ser mayor al 100%') expect(validarEnganchePct(5.5)).toBe(
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
)
expect(validarEnganchePct(20)).toBe(
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
)
}) })
}) })

View File

@@ -6,6 +6,7 @@ import type {
PropuestaConfig, PropuestaConfig,
} from '../types' } from '../types'
import { DESARROLLO_DEFAULT, obtenerDesarrollo } from '../data/desarrollos' import { DESARROLLO_DEFAULT, obtenerDesarrollo } from '../data/desarrollos'
import { propuestasVigentes } from './reglas'
/** /**
* Cálculos replicados fielmente de los Excel de producción "COTIZADOR" de * Cálculos replicados fielmente de los Excel de producción "COTIZADOR" de
@@ -23,16 +24,22 @@ export const APARTADO_DEFAULT = 20000
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */ /** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
export const ENGANCHE_MINIMO_PCT = 0.025 export const ENGANCHE_MINIMO_PCT = 0.025
/** Enganche máximo que permite el Excel: 5% (lista de validación 0%, 2.5%, 5%) */
export const ENGANCHE_MAXIMO_PCT = 5
/** /**
* Mensaje de error si el enganche capturado (en PORCENTAJE, 2.5 = 2.5%) es * Mensaje de error si el enganche capturado (en PORCENTAJE, 2.5 = 2.5%) es
* inválido; null si es válido. null de entrada = sin override (default Excel). * inválido; null si es válido. null de entrada = sin override (default Excel).
* El rango válido es el del Excel: 2.5% a 5%.
*/ */
export function validarEnganchePct(pct: number | null): string | null { export function validarEnganchePct(pct: number | null): string | null {
if (pct === null) return null if (pct === null) return null
if (!Number.isFinite(pct) || pct < 2.5) { if (!Number.isFinite(pct) || pct < 2.5) {
return 'El enganche mínimo es 2.5% del valor de la propiedad' return 'El enganche mínimo es 2.5% del valor de la propiedad'
} }
if (pct > 100) return 'El enganche no puede ser mayor al 100%' if (pct > ENGANCHE_MAXIMO_PCT) {
return 'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)'
}
return null return null
} }
@@ -77,6 +84,11 @@ export interface CotizacionInput {
descuentos: DescuentosPropuestas descuentos: DescuentosPropuestas
/** Desarrollo cuyos parámetros aplican; por defecto unidad.desarrollo ?? 'DIAGONAL' */ /** Desarrollo cuyos parámetros aplican; por defecto unidad.desarrollo ?? 'DIAGONAL' */
desarrollo?: string desarrollo?: string
/**
* Reglas comerciales efectivas del desarrollo (las del admin, con respaldo en
* las constantes). Si no vienen, se usan las constantes.
*/
reglas?: ParametrosDesarrollo
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */ /** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
numMeses?: number numMeses?: number
/** /**
@@ -100,6 +112,10 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
* - fijo: nFijo (entrega inmediata) * - fijo: nFijo (entrega inmediata)
* - porUnidad: mesesParaFirma del inventario (null si falta) * - porUnidad: mesesParaFirma del inventario (null si falta)
* - fechaEntrega: meses desde hoy (null si la unidad no tiene fecha) * - fechaEntrega: meses desde hoy (null si la unidad no tiene fecha)
*
* Cuando el inventario no trae el dato se usa el plazo por defecto del
* desarrollo (`nDefault`), para que la mensualidad y el total SIEMPRE se
* calculen (antes salían "— (sin fecha de entrega)" y sin total).
*/ */
export function resolverNumMensualidades( export function resolverNumMensualidades(
unidad: Unidad, unidad: Unidad,
@@ -109,9 +125,11 @@ export function resolverNumMensualidades(
case 'fijo': case 'fijo':
return params.nFijo ?? 1 return params.nFijo ?? 1
case 'porUnidad': case 'porUnidad':
return unidad.mesesParaFirma ?? null return unidad.mesesParaFirma ?? params.nDefault ?? null
case 'fechaEntrega': case 'fechaEntrega':
return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null return unidad.fechaEntrega
? calcularNumMensualidades(unidad.fechaEntrega)
: (params.nDefault ?? null)
} }
} }
@@ -122,6 +140,42 @@ function baseComun(precioLista: number, descuento: number) {
return { montoDescuento, precioFinal } return { montoDescuento, precioFinal }
} }
/** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */
interface BloquesPago {
enganche: number
mensualidades: number | null
pagoFinal: number
}
/**
* Cuadra los bloques para que apartado + enganche + mensualidades + pago final
* sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la
* propuesta), aunque el apartado o un enganche personalizado descuadren las
* fórmulas:
* - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando
* el apartado supera el enganche, ni mensualidades negativas con enganche
* alto);
* - el excedente se recorta de las mensualidades (bloque flexible del
* remanente) y, si no alcanza, del pago final. Nunca se suma por encima del
* precio.
*/
function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago {
const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal
const enganche = Math.max(0, bloques.enganche)
let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades)
let pagoFinal = Math.max(0, bloques.pagoFinal)
let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total
if (excedente > 0 && mensualidades !== null) {
const recorte = Math.min(mensualidades, excedente)
mensualidades -= recorte
excedente -= recorte
}
if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente)
return { enganche, mensualidades, pagoFinal }
}
/** Calcula una propuesta comercial con los parámetros dados */ /** Calcula una propuesta comercial con los parámetros dados */
function calcularPropuestaConfig( function calcularPropuestaConfig(
cfg: PropuestaConfig, cfg: PropuestaConfig,
@@ -140,18 +194,18 @@ function calcularPropuestaConfig(
? Math.max(ENGANCHE_MINIMO_PCT, enganchePct) ? Math.max(ENGANCHE_MINIMO_PCT, enganchePct)
: cfg.pctEnganche : cfg.pctEnganche
// Enganche: pct sobre su base, neto del apartado (salvo la guarda de SOLANA) // Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de
// SOLANA lo deja en 0 cuando el % es 0
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
const enganche = const engancheBruto = cfg.engancheCeroSinApartado && pctEnganche === 0 ? 0 : pctEnganche * baseEnganche
cfg.engancheCeroSinApartado && pctEnganche === 0 // Fórmula del Excel: enganche neto del apartado (puede salir negativo si el
? 0 // apartado supera el enganche; cuadrarBloques lo acota a 0)
: pctEnganche * baseEnganche - apartado const engancheExcel = engancheBruto - apartado
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
const pagoFinal = cfg.pctPagoFinal * basePagoFinal const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal
let mensualidadesTotal: number | null = null let mensualidadesExcel: number | null = null
let mensualidad: number | null = null
let notaMensualidades: string | undefined let notaMensualidades: string | undefined
if (cfg.modoMensualidades !== 'ninguna') { if (cfg.modoMensualidades !== 'ninguna') {
if (n === null) { if (n === null) {
@@ -160,23 +214,26 @@ function calcularPropuestaConfig(
} else { } else {
switch (cfg.modoMensualidades) { switch (cfg.modoMensualidades) {
case 'remanente': case 'remanente':
mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel
break break
case 'remanenteLista': case 'remanenteLista':
// Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal // Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel
break break
case 'porcentaje': case 'porcentaje':
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal) mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
break break
} }
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
// (ya no hay nada que diferir; el cliente paga de más al inicio)
mensualidadesTotal = Math.max(0, mensualidadesTotal)
mensualidad = mensualidadesTotal / n
} }
} }
const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, {
enganche: engancheExcel,
mensualidades: mensualidadesExcel,
pagoFinal: pagoFinalExcel,
})
const mensualidad = mensualidades === null || n === null ? null : mensualidades / n
return { return {
tipo: cfg.tipo, tipo: cfg.tipo,
titulo: cfg.titulo, titulo: cfg.titulo,
@@ -186,7 +243,7 @@ function calcularPropuestaConfig(
enganche, enganche,
numMensualidades: n, numMensualidades: n,
mensualidad, mensualidad,
mensualidadesTotal, mensualidadesTotal: mensualidades,
pagoFinal, pagoFinal,
...(notaMensualidades !== undefined ? { notaMensualidades } : {}), ...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
} }
@@ -222,13 +279,13 @@ export function indiceMenorTotal(totales: number[]): number {
return indice return indice
} }
/** Calcula las propuestas comerciales del desarrollo de la unidad */ /** Propuestas comerciales vigentes del desarrollo de la unidad */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] { export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input const { unidad, acabados, apartado, descuentos } = input
const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT) const params = input.reglas ?? obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
const precioLista = obtenerPrecioLista(unidad, acabados) const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? resolverNumMensualidades(unidad, params) const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
return params.propuestas.map((cfg) => return propuestasVigentes(params).map((cfg) =>
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n, input.enganchePct ?? null), calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n, input.enganchePct ?? null),
) )
} }

View File

@@ -5,7 +5,12 @@ import {
totalPagado, totalPagado,
type PropuestaResultado, type PropuestaResultado,
} from './calculations' } from './calculations'
import { descuentosDefault, obtenerDesarrollo } from '../data/desarrollos' import {
SIN_REGLAS,
descuentosDefaultEfectivo,
desarrolloEfectivo,
type ReglasDesarrollo,
} from './reglas'
export interface ColumnaComparador { export interface ColumnaComparador {
unidad: Unidad unidad: Unidad
@@ -36,16 +41,18 @@ export function compararUnidades(
tipo: TipoPropuesta, tipo: TipoPropuesta,
apartado: number, apartado: number,
acabados: boolean, acabados: boolean,
reglas: ReglasDesarrollo = SIN_REGLAS,
): ComparacionUnidades { ): ComparacionUnidades {
const columna = (unidad: Unidad): ColumnaComparador => { const columna = (unidad: Unidad): ColumnaComparador => {
const desarrolloId = unidad.desarrollo ?? 'DIAGONAL' const desarrolloId = unidad.desarrollo ?? 'DIAGONAL'
const params = obtenerDesarrollo(desarrolloId) const params = desarrolloEfectivo(reglas, desarrolloId)
const propuestas = calcularPropuestas({ const propuestas = calcularPropuestas({
unidad, unidad,
acabados, acabados,
apartado, apartado,
descuentos: descuentosDefault(desarrolloId), descuentos: descuentosDefaultEfectivo(reglas, desarrolloId),
desarrollo: desarrolloId, desarrollo: desarrolloId,
reglas: params,
}) })
const propuesta = propuestas.find((p) => p.tipo === tipo) ?? propuestas[0] const propuesta = propuestas.find((p) => p.tipo === tipo) ?? propuestas[0]
if (!propuesta) throw new Error(`El desarrollo ${desarrolloId} no tiene propuestas`) if (!propuesta) throw new Error(`El desarrollo ${desarrolloId} no tiene propuestas`)

View File

@@ -5,6 +5,7 @@ import { compararUnidades } from './comparadorUnidades'
import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations' import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations'
import { UNIDADES_SEED } from '../data/unidades' import { UNIDADES_SEED } from '../data/unidades'
import { UNIDADES_ATELIER } from '../data/unidades-atelier' import { UNIDADES_ATELIER } from '../data/unidades-atelier'
import { DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters' import { formatoMoneda } from './formatters'
describe('folioCotizacion', () => { describe('folioCotizacion', () => {
@@ -106,10 +107,11 @@ describe('compararUnidades', () => {
expect(r.a.precioLista).toBe(diagonal.precioSinAcabados) expect(r.a.precioLista).toBe(diagonal.precioSinAcabados)
}) })
it('total null cuando el plazo no se puede conocer (sin fecha de entrega)', () => { it('una unidad sin fecha de entrega usa el plazo por defecto del desarrollo', () => {
const sinFecha = { ...diagonal, fechaEntrega: null } const sinFecha = { ...diagonal, fechaEntrega: null }
const r = compararUnidades(sinFecha, diagonal, 'CREDITO_BANCARIO', APARTADO_DEFAULT, true) const r = compararUnidades(sinFecha, diagonal, 'CREDITO_BANCARIO', APARTADO_DEFAULT, true)
expect(r.a.total).toBeNull() expect(r.a.propuesta.numMensualidades).toBe(DESARROLLOS['DIAGONAL']?.nDefault)
expect(r.a.total).not.toBeNull()
expect(r.b.total).not.toBeNull() expect(r.b.total).not.toBeNull()
}) })
}) })

View File

@@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest'
import { APARTADO_DEFAULT, calcularPropuestas, totalPagado } from './calculations'
import {
SIN_REGLAS,
descuentosDefaultEfectivo,
desarrolloEfectivo,
fusionarReglas,
idsCotizables,
propuestasVigentes,
reglasDesdeApi,
} from './reglas'
import { DESARROLLOS, LISTA_DESARROLLOS, descuentosDefault } from '../data/desarrollos'
import { INVENTARIO_SEED } from '../data/unidades'
import type { DescuentosPropuestas, ParametrosDesarrollo, Unidad } from '../types'
/**
* Reglas comerciales configurables: el cotizador usa las que manda el API (con
* respaldo en las constantes). Las unidades son las del inventario real.
*/
const DESCUENTOS: DescuentosPropuestas = {
creditoBancario: 0.01,
creditoInfonavit: 0.005,
contado: 0.03,
semiContado: 0.015,
}
/** Tres unidades reales del inventario por desarrollo */
function unidadesDe(desarrollo: string): Unidad[] {
return INVENTARIO_SEED.filter((u) => u.desarrollo === desarrollo).slice(0, 3)
}
/** Reglas tal como las devuelve el API: JSON serializado con campos extra del admin */
function reglasDeApi(extras: Record<string, Record<string, unknown>> = {}): Record<string, unknown> {
const mapa: Record<string, unknown> = {}
for (const [id, params] of Object.entries(DESARROLLOS)) {
mapa[id] = JSON.parse(
JSON.stringify({
...params,
activoEnCotizador: true,
propuestas: params.propuestas.map((p) => ({ ...p, activa: true })),
...(extras[id] ?? {}),
}),
) as unknown
}
return mapa
}
describe('reglasDesdeApi / desarrolloEfectivo', () => {
it('sin reglas del API todo se calcula con las constantes', () => {
expect(idsCotizables(SIN_REGLAS)).toEqual(LISTA_DESARROLLOS)
for (const id of LISTA_DESARROLLOS) {
expect(desarrolloEfectivo(SIN_REGLAS, id)).toEqual(DESARROLLOS[id])
expect(descuentosDefaultEfectivo(SIN_REGLAS, id)).toEqual(descuentosDefault(id))
}
})
it('las reglas del API ganan a la constante y completan los campos que falten', () => {
const reglas = reglasDesdeApi({
DIAGONAL: { propuestas: [{ tipo: 'CONTADO', descuentoDefault: 0.08 }] },
})
const params = desarrolloEfectivo(reglas, 'DIAGONAL')
// El campo que vino del API manda...
expect(params.propuestas).toHaveLength(1)
expect(params.propuestas[0]?.descuentoDefault).toBe(0.08)
// ...y lo que falta se completa con la constante del desarrollo
expect(params.propuestas[0]?.titulo).toBe('Contado')
expect(params.propuestas[0]?.pctEnganche).toBe(0.9)
expect(params.nombre).toBe('Diagonal')
expect(params.nDefault).toBe(13)
expect(params.cuotaMantenimiento).toBe(1500)
expect(descuentosDefaultEfectivo(reglas, 'DIAGONAL').contado).toBe(0.08)
})
it('un JSON que no se puede usar deja al desarrollo con su constante', () => {
const reglas = reglasDesdeApi({
SENDA: null,
ATELIER: 'roto',
ESTANZA: { propuestas: [] },
SOLANA: { propuestas: [{ tipo: 'NO_EXISTE' }] },
})
expect(reglas).toEqual({})
expect(desarrolloEfectivo(reglas, 'SENDA')).toEqual(DESARROLLOS['SENDA'])
})
it('un desarrollo con activoEnCotizador false no se ofrece (el API también lo rechaza)', () => {
const reglas = reglasDesdeApi(reglasDeApi({ SOLANA: { activoEnCotizador: false } }))
expect(idsCotizables(reglas)).toEqual(['DIAGONAL', 'ATELIER', 'ESTANZA', 'SENDA'])
expect(desarrolloEfectivo(reglas, 'SOLANA').activoEnCotizador).toBe(false)
})
it('una propuesta desactivada no se ofrece ni se calcula; el resto sigue vigente', () => {
const base = DESARROLLOS['DIAGONAL']!
const reglas = reglasDesdeApi(
reglasDeApi({
DIAGONAL: {
propuestas: base.propuestas.map((p) => ({ ...p, activa: p.tipo !== 'CONTADO' })),
},
}),
)
const params = desarrolloEfectivo(reglas, 'DIAGONAL')
const unidad = unidadesDe('DIAGONAL')[0]!
expect(propuestasVigentes(params).map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
const propuestas = calcularPropuestas({
unidad,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: descuentosDefaultEfectivo(reglas, 'DIAGONAL'),
reglas: params,
})
expect(propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
})
})
describe('reglas equivalentes de BD vs constantes: mismos números', () => {
it('los 5 desarrollos cotizan igual con las reglas del API que con las constantes', () => {
const reglas = reglasDesdeApi(reglasDeApi())
for (const id of LISTA_DESARROLLOS) {
const unidades = unidadesDe(id)
expect(unidades).toHaveLength(3)
for (const unidad of unidades) {
for (const acabados of [false, true]) {
const conConstantes = calcularPropuestas({
unidad,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
desarrollo: id,
})
const conReglas = calcularPropuestas({
unidad,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
desarrollo: id,
reglas: desarrolloEfectivo(reglas, id),
})
expect(conReglas).toEqual(conConstantes)
for (const p of conReglas) {
expect(totalPagado(p, APARTADO_DEFAULT)).not.toBeNull()
}
}
}
}
})
it('fusionarReglas no cambia un solo número cuando la BD tiene los valores de hoy', () => {
const fusionadas = fusionarReglas('SENDA', reglasDeApi()['SENDA']) as ParametrosDesarrollo
expect(fusionadas).toEqual(DESARROLLOS['SENDA'])
})
})

View File

@@ -0,0 +1,227 @@
import type {
BasePrecio,
DescuentosPropuestas,
ModoMensualidades,
ParametrosDesarrollo,
PropuestaConfig,
TipoPropuesta,
} from '../types'
import { DESARROLLOS, LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
/**
* Reglas comerciales del cotizador.
*
* Las reglas "efectivas" las configura el admin y llegan del API
* (`GET /desarrollos/reglas-cotizador`); `data/desarrollos.ts` son los valores
* por defecto/respaldo: si un desarrollo no trae reglas (o llegan a medias), se
* completan campo por campo con su constante. Sin reglas del API, la app se
* comporta exactamente como antes.
*
* Igual que en el motor, las fórmulas no se tocan: aquí solo se decide de dónde
* salen los parámetros.
*/
/** Reglas efectivas indexadas por id de desarrollo ('DIAGONAL', 'SENDA', …) */
export type ReglasDesarrollo = Record<string, ParametrosDesarrollo>
/** Sin reglas del API: todo se calcula con las constantes */
export const SIN_REGLAS: ReglasDesarrollo = {}
const TIPOS: TipoPropuesta[] = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
]
const CLAVES: (keyof DescuentosPropuestas)[] = [
'creditoBancario',
'creditoInfonavit',
'contado',
'semiContado',
]
/** Clave de descuento que le toca a cada tipo cuando ni la BD ni la constante la traen */
const CLAVE_POR_TIPO: Record<TipoPropuesta, keyof DescuentosPropuestas> = {
CREDITO_BANCARIO: 'creditoBancario',
CREDITO_INFONAVIT_COFINAVIT: 'creditoInfonavit',
CONTADO: 'contado',
SEMI_CONTADO: 'semiContado',
}
function texto(valor: unknown): string | null {
return typeof valor === 'string' && valor.trim() !== '' ? valor.trim() : null
}
function numero(valor: unknown): number | null {
return typeof valor === 'number' && Number.isFinite(valor) ? valor : null
}
function tipoPropuesta(valor: unknown): TipoPropuesta | null {
return typeof valor === 'string' && (TIPOS as string[]).includes(valor)
? (valor as TipoPropuesta)
: null
}
function claveDescuento(valor: unknown): keyof DescuentosPropuestas | null {
return typeof valor === 'string' && (CLAVES as string[]).includes(valor)
? (valor as keyof DescuentosPropuestas)
: null
}
function basePrecio(valor: unknown): BasePrecio | null {
return valor === 'lista' || valor === 'final' ? valor : null
}
function modoMensualidades(valor: unknown): ModoMensualidades | null {
return valor === 'ninguna' || valor === 'remanente' || valor === 'remanenteLista' || valor === 'porcentaje'
? valor
: null
}
/** Propuestas vigentes: el admin puede desactivar una propuesta por desarrollo */
export function propuestasVigentes(params: ParametrosDesarrollo): PropuestaConfig[] {
return params.propuestas.filter(propuestaVigente)
}
/** ¿La propuesta se ofrece en el cotizador? (activa !== false) */
export function propuestaVigente(propuesta: PropuestaConfig): boolean {
return propuesta.activa !== false
}
/** Una propuesta de la BD, completando lo que falte con la constante del tipo */
function fusionarPropuesta(
bruta: unknown,
constante: ParametrosDesarrollo | undefined,
): PropuestaConfig | null {
if (typeof bruta !== 'object' || bruta === null) return null
const p = bruta as Record<string, unknown>
const tipo = tipoPropuesta(p['tipo'])
if (tipo === null) return null
const base = constante?.propuestas.find((c) => c.tipo === tipo)
const propuesta: PropuestaConfig = {
tipo,
claveDescuento: claveDescuento(p['claveDescuento']) ?? base?.claveDescuento ?? CLAVE_POR_TIPO[tipo],
titulo: texto(p['titulo']) ?? base?.titulo ?? tipo,
descuentoDefault: numero(p['descuentoDefault']) ?? base?.descuentoDefault ?? 0,
pctEnganche: numero(p['pctEnganche']) ?? base?.pctEnganche ?? 0,
engancheBase: basePrecio(p['engancheBase']) ?? base?.engancheBase ?? 'final',
pctPagoFinal: numero(p['pctPagoFinal']) ?? base?.pctPagoFinal ?? 0,
pagoFinalBase: basePrecio(p['pagoFinalBase']) ?? base?.pagoFinalBase ?? 'final',
modoMensualidades: modoMensualidades(p['modoMensualidades']) ?? base?.modoMensualidades ?? 'porcentaje',
}
const ceroSinApartado = p['engancheCeroSinApartado'] ?? base?.engancheCeroSinApartado
if (ceroSinApartado === true) propuesta.engancheCeroSinApartado = true
// Solo se conserva el "false" (desactivada): true/ausente es lo mismo y así una
// configuración igual a la constante da exactamente el mismo objeto
const activa = p['activa'] ?? p['activo'] ?? base?.activa
if (activa === false) propuesta.activa = false
return propuesta
}
/**
* Reglas de un desarrollo que llegan del API, completadas con su constante.
* Devuelve null si el JSON no es usable: el desarrollo se queda con su
* constante (comportamiento de siempre).
*/
export function fusionarReglas(id: string, bruto: unknown): ParametrosDesarrollo | null {
if (typeof bruto !== 'object' || bruto === null || Array.isArray(bruto)) return null
const r = bruto as Record<string, unknown>
const brutoPropuestas = r['propuestas']
if (!Array.isArray(brutoPropuestas) || brutoPropuestas.length === 0) return null
const constante = DESARROLLOS[id]
const propuestas: PropuestaConfig[] = []
for (const bruta of brutoPropuestas) {
const propuesta = fusionarPropuesta(bruta, constante)
if (propuesta === null) return null
propuestas.push(propuesta)
}
const fuenteDeN = r['fuenteDeN']
const params: ParametrosDesarrollo = {
id,
nombre: texto(r['nombre']) ?? constante?.nombre ?? id,
precioUnico:
typeof r['precioUnico'] === 'boolean' ? r['precioUnico'] : (constante?.precioUnico ?? true),
fuenteDeN:
fuenteDeN === 'fechaEntrega' || fuenteDeN === 'fijo' || fuenteDeN === 'porUnidad'
? fuenteDeN
: (constante?.fuenteDeN ?? 'fijo'),
cuotaMantenimiento:
'cuotaMantenimiento' in r
? (numero(r['cuotaMantenimiento']) ?? null)
: (constante?.cuotaMantenimiento ?? null),
prefijoUnidadPdf: texto(r['prefijoUnidadPdf']) ?? constante?.prefijoUnidadPdf ?? 'CASA',
propuestas,
}
const nFijo = numero(r['nFijo']) ?? constante?.nFijo
if (typeof nFijo === 'number') params.nFijo = nFijo
const nDefault = numero(r['nDefault']) ?? constante?.nDefault
if (typeof nDefault === 'number') params.nDefault = nDefault
// Solo se conserva el "false" (desactivado): true/ausente es lo mismo
const activo = r['activoEnCotizador'] ?? r['activo']
if (activo === false) params.activoEnCotizador = false
return params
}
/**
* Convierte el mapa `reglasPorDesarrollo` del API en reglas efectivas. Los
* desarrollos sin reglas usables se omiten (quedan en su constante).
*/
export function reglasDesdeApi(bruto: unknown): ReglasDesarrollo {
const reglas: ReglasDesarrollo = {}
if (typeof bruto !== 'object' || bruto === null) return reglas
for (const [id, valor] of Object.entries(bruto as Record<string, unknown>)) {
const fusionado = fusionarReglas(id, valor)
if (fusionado !== null) reglas[id] = fusionado
}
return reglas
}
/** Reglas del desarrollo: las del admin si las hay; si no, la constante (o DIAGONAL) */
export function desarrolloEfectivo(
reglas: ReglasDesarrollo | undefined,
id?: string | null,
): ParametrosDesarrollo {
if (id !== undefined && id !== null) {
const delDesarrollo = reglas?.[id]
if (delDesarrollo) return delDesarrollo
}
return obtenerDesarrollo(id)
}
/** Descuentos por defecto del desarrollo, indexados por clave de propuesta */
export function descuentosDefaultEfectivo(
reglas: ReglasDesarrollo | undefined,
id?: string | null,
): DescuentosPropuestas {
const params = desarrolloEfectivo(reglas, id)
const descuentos: DescuentosPropuestas = {
creditoBancario: 0,
creditoInfonavit: 0,
contado: 0,
semiContado: 0,
}
for (const propuesta of params.propuestas) descuentos[propuesta.claveDescuento] = propuesta.descuentoDefault
return descuentos
}
/**
* Desarrollos que se pueden ofrecer en el cotizador: los conocidos más los que
* el admin haya configurado, sin los desactivados ni los que se quedaron sin
* propuestas vigentes.
*/
export function idsCotizables(reglas: ReglasDesarrollo = SIN_REGLAS): string[] {
const ids = [
...LISTA_DESARROLLOS,
...Object.keys(reglas).filter((id) => !LISTA_DESARROLLOS.includes(id)),
]
return ids.filter((id) => {
const params = desarrolloEfectivo(reglas, id)
return params.activoEnCotizador !== false && propuestasVigentes(params).length > 0
})
}

View File

@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import CotizadorPage from './CotizadorPage'
import { DESARROLLOS } from '../data/desarrollos'
import { INVENTARIO_SEED } from '../data/unidades'
/**
* Reglas configurables por el admin en la página del cotizador:
* - un desarrollo con activoEnCotizador: false no se ofrece
* - una propuesta desactivada no se muestra ni se puede elegir
*/
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
const unidadSolana = INVENTARIO_SEED.find((u) => u.desarrollo === 'SOLANA')!
const respuesta = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })
/** Reglas del API con los ajustes del test (JSON serializado, como en la BD) */
function reglasDeApi(): { reglasPorDesarrollo: Record<string, unknown> } {
const mapa: Record<string, unknown> = {}
for (const [id, params] of Object.entries(DESARROLLOS)) {
mapa[id] = JSON.parse(JSON.stringify(params)) as unknown
}
const solana = mapa['SOLANA'] as Record<string, unknown>
solana['activoEnCotizador'] = false
const diagonal = mapa['DIAGONAL'] as { propuestas: Record<string, unknown>[] }
for (const propuesta of diagonal.propuestas) {
if (propuesta['tipo'] === 'CONTADO') propuesta['activa'] = false
}
return { reglasPorDesarrollo: mapa }
}
beforeEach(() => {
const almacenamiento = new Map<string, string>()
window.history.replaceState({}, '', '/')
vi.stubGlobal('localStorage', {
getItem: (k: string) => almacenamiento.get(k) ?? null,
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
removeItem: (k: string) => void almacenamiento.delete(k),
clear: () => almacenamiento.clear(),
})
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/desarrollos/reglas-cotizador')) return Promise.resolve(respuesta(reglasDeApi()))
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidadDiagonal, unidadSolana]))
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([]))
return Promise.reject(new TypeError(`fetch inesperado: ${url}`))
}),
)
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('CotizadorPage con reglas del admin', () => {
it('no ofrece el desarrollo desactivado ni la propuesta desactivada', async () => {
render(
<MemoryRouter>
<CotizadorPage />
</MemoryRouter>,
)
const inputUnidad = (await screen.findByLabelText('Unidad')) as HTMLInputElement
// SOLANA está desactivado: no aparece su botón en el selector de desarrollos
expect(screen.queryByRole('button', { name: /Solana/ })).toBeNull()
expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy()
expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy()
// Cargar las 4 propuestas de DIAGONAL: solo las 3 vigentes
fireEvent.focus(inputUnidad)
fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } })
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) }))
await waitFor(() => {
expect(screen.getByText('Crédito Bancario')).toBeTruthy()
})
expect(screen.getByText('Crédito Infonavit-Cofinavit')).toBeTruthy()
expect(screen.getByText('Semi Contado')).toBeTruthy()
expect(screen.queryByText('Contado')).toBeNull()
})
})

View File

@@ -20,6 +20,7 @@ const respuesta = (data: unknown, status = 200) =>
const almacenamiento = new Map<string, string>() const almacenamiento = new Map<string, string>()
beforeEach(() => { beforeEach(() => {
almacenamiento.clear() almacenamiento.clear()
window.history.replaceState({}, '', '/')
vi.stubGlobal('localStorage', { vi.stubGlobal('localStorage', {
getItem: (k: string) => almacenamiento.get(k) ?? null, getItem: (k: string) => almacenamiento.get(k) ?? null,
setItem: (k: string, v: string) => void almacenamiento.set(k, v), setItem: (k: string, v: string) => void almacenamiento.set(k, v),
@@ -88,4 +89,58 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
}) })
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy() expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
}) })
it('no deja cotizar una unidad que ya no está disponible: avisa y bloquea las acciones', async () => {
const vendida = { ...unidad, codigo: 'U-900', status: 'VENDIDO' as const }
const cotizacionVendida = {
id: 'cot-vendida',
folio: 'COT-VENDID',
unidadCodigo: vendida.codigo,
desarrollo: 'DIAGONAL',
clienteNombre: 'Cliente Antiguo',
clienteTelefono: null,
clienteEmail: null,
tipoPropuesta: 'BANCARIO',
acabados: false,
apartado: 20000,
descuento: 0,
precioLista: vendida.precioConAcabados,
precioFinal: 0,
enganche: 0,
numMensualidades: 12,
mensualidad: 1000,
pagoFinal: 0,
createdAt: '2026-09-01T00:00:00.000Z',
}
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidad, vendida]))
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([cotizacionVendida]))
return Promise.reject(new TypeError(`fetch inesperado: ${url}`))
})
vi.stubGlobal('fetch', fetchMock)
// Prellenado desde Mi día con una unidad que ya se vendió
window.history.replaceState({}, '', `/?unidad=${vendida.codigo}`)
render(
<MemoryRouter>
<CotizadorPage />
</MemoryRouter>,
)
// El prellenado NO la preselecciona y lo dice
expect(await screen.findByText(/ya no está disponible \(estatus: VENDIDO\)/)).toBeTruthy()
// Retomar la cotización vieja de esa unidad: se restaura, pero avisa y
// deja Guardar/Compartir/Generar PDF bloqueados
fireEvent.click(await screen.findByRole('button', { name: /Ver historial/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Retomar' }))
const alerta = await screen.findByRole('alert')
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`)
expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
}
})
}) })

View File

@@ -21,18 +21,22 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia' import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio' import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir' import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { indiceMenorTotal, totalPagado } from '../lib/calculations' import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos' import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas'
import { import {
getMisCotizaciones, getMisCotizaciones,
guardarCotizacion, guardarCotizacion,
urlPublicaCotizacion, urlPublicaCotizacion,
ErrorApiCotizacion,
TIPO_A_API, TIPO_A_API,
TIPO_DE_API, TIPO_DE_API,
type CotizacionGuardada, type CotizacionGuardada,
} from '../services/api' } from '../services/api'
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db' import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
import type { Cliente } from '../types' import type { Cliente, Unidad } from '../types'
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
const esquemaCliente = z.object({ const esquemaCliente = z.object({
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'), nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
@@ -52,8 +56,8 @@ const campoInputError = `${campoInputBase} border-red-400`
const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]' const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
export default function CotizadorPage() { export default function CotizadorPage() {
const { unidades, cargando, error, usandoSeed } = useUnidades() const { unidades, cargando, error, usandoSeed, reglas } = useUnidades()
const cotizacion = useCotizacion() const cotizacion = useCotizacion(reglas)
const { const {
desarrollo, desarrollo,
setDesarrollo, setDesarrollo,
@@ -75,10 +79,13 @@ export default function CotizadorPage() {
setNumMensualidades, setNumMensualidades,
numMeses, numMeses,
propuestas, propuestas,
avisoPropuestaDesactivada,
restaurar, restaurar,
nueva, nueva,
} = cotizacion } = cotizacion
/** Desarrollos que se ofrecen: los activos en las reglas del admin */
const desarrollos = idsCotizables(reglas)
const unidadesDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === desarrollo) const unidadesDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === desarrollo)
const disponiblesDesarrollo = unidadesDesarrollo.filter((u) => u.status === 'DISPONIBLE').length const disponiblesDesarrollo = unidadesDesarrollo.filter((u) => u.status === 'DISPONIBLE').length
@@ -97,6 +104,24 @@ export default function CotizadorPage() {
const [oportunidadId, setOportunidadId] = useState<string | null>(null) const [oportunidadId, setOportunidadId] = useState<string | null>(null)
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null) const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
// primero disponible para no dejar el cotizador en un desarrollo muerto.
useEffect(() => {
if (desarrollos.includes(desarrollo)) return
const alterno = desarrollos[0]
if (alterno === undefined) return
setDesarrollo(alterno)
setAviso(
`El desarrollo ${desarrollo} ya no está disponible en el cotizador; se cambió a ${alterno}.`,
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [desarrollos, desarrollo])
// La propuesta elegida dejó de estar disponible (la desactivó el admin)
useEffect(() => {
if (avisoPropuestaDesactivada !== null) setAviso(avisoPropuestaDesactivada)
}, [avisoPropuestaDesactivada])
useEffect(() => { useEffect(() => {
let vivo = true let vivo = true
/** Combina servidor + locales, sin duplicados, más recientes primero */ /** Combina servidor + locales, sin duplicados, más recientes primero */
@@ -155,18 +180,38 @@ export default function CotizadorPage() {
if (cliente) setValue('nombre', cliente, { shouldValidate: true }) if (cliente) setValue('nombre', cliente, { shouldValidate: true })
if (telefono) setValue('telefono', telefono, { shouldValidate: true }) if (telefono) setValue('telefono', telefono, { shouldValidate: true })
if (email) setValue('email', email, { shouldValidate: true }) if (email) setValue('email', email, { shouldValidate: true })
if (desarrolloParam && LISTA_DESARROLLOS.includes(desarrolloParam)) setDesarrollo(desarrolloParam) if (desarrolloParam) {
if (desarrollos.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
else
setAviso(
`El desarrollo ${desarrolloParam} no está disponible en el cotizador. Elige un desarrollo de la lista.`,
)
}
if (unidadParam) setUnidadPendiente(unidadParam) if (unidadParam) setUnidadPendiente(unidadParam)
if (opId) setOportunidadId(opId) if (opId) setOportunidadId(opId)
// Solo al montar: es el punto de entrada desde Mi día // Solo al montar: es el punto de entrada desde Mi día
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// Preselecciona la unidad cuando el inventario terminó de cargar // Preselecciona la unidad cuando el inventario terminó de cargar. Solo
// unidades DISPONIBLES: si la pedida ya se apartó/vendió (o no está en el
// inventario) se avisa y no se preselecciona nada.
useEffect(() => { useEffect(() => {
if (unidadPendiente === null || cargando) return if (unidadPendiente === null || cargando) return
const encontrada = unidades.find((u) => u.codigo === unidadPendiente) const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
if (encontrada) { if (!encontrada) {
setAviso(
`La unidad ${unidadPendiente} no está en el inventario actual: elige otra unidad disponible.`,
)
} else if (!esDisponible(encontrada)) {
setAviso(
`La unidad ${encontrada.codigo} ya no está disponible (estatus: ${encontrada.status}). Elige otra unidad disponible.`,
)
} else if (!desarrollos.includes(encontrada.desarrollo ?? 'DIAGONAL')) {
setAviso(
`El desarrollo de la unidad ${encontrada.codigo} no está disponible en el cotizador: elige otra unidad disponible.`,
)
} else {
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL' const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad) if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
setUnidad(encontrada) setUnidad(encontrada)
@@ -180,7 +225,9 @@ export default function CotizadorPage() {
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer // Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
const puedeGenerarPdf = Boolean(unidad) && !errorEnganche && !guardando // Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando
// ---------- Preselección por defecto: la propuesta de menor total pagado ---------- // ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta // Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
@@ -234,6 +281,8 @@ export default function CotizadorPage() {
* Persiste la cotización en el API. Si no responde, la guarda localmente * Persiste la cotización en el API. Si no responde, la guarda localmente
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF * (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor). * siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
* Si el servidor RECHAZA la cotización (unidad no disponible, datos inválidos)
* devuelve null: se avisa y no se guarda nada.
*/ */
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => { const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null if (!unidad || !propuestaActiva) return null
@@ -247,6 +296,7 @@ export default function CotizadorPage() {
acabados, acabados,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento, descuento: propuestaActiva.descuento,
...(numMeses !== null ? { numMensualidades: numMeses } : {}),
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}), ...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
...(oportunidadId !== null ? { oportunidadId } : {}), ...(oportunidadId !== null ? { oportunidadId } : {}),
}) })
@@ -256,6 +306,12 @@ export default function CotizadorPage() {
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50)) setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
return conFolio return conFolio
} catch (err) { } catch (err) {
// El servidor contestó y rechazó la cotización: no se guarda ni local
if (err instanceof ErrorApiCotizacion) {
toast.error(err.message)
setAviso(err.message)
return null
}
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err) console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
const local = cotizacionLocal(cliente) const local = cotizacionLocal(cliente)
void guardarCotizacionLocal(local) void guardarCotizacionLocal(local)
@@ -272,10 +328,11 @@ export default function CotizadorPage() {
setGuardando(true) setGuardando(true)
try { try {
const guardada = await guardarEnApi(cliente) const guardada = await guardarEnApi(cliente)
let folio: string | undefined // Rechazada por el servidor (p. ej. la unidad dejó de estar disponible):
// no se genera el PDF
if (!guardada) return
const folio = guardada.folio ?? folioCotizacion(guardada.id)
let qrDataUrl: string | undefined let qrDataUrl: string | undefined
if (guardada) {
folio = guardada.folio ?? folioCotizacion(guardada.id)
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene // El QR apunta al resumen público en el servidor; una cotización local no lo tiene
if (!esCotizacionLocal(guardada)) { if (!esCotizacionLocal(guardada)) {
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), { qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
@@ -283,7 +340,6 @@ export default function CotizadorPage() {
width: 200, width: 200,
}) })
} }
}
generarPdf({ generarPdf({
unidad, unidad,
acabados, acabados,
@@ -293,7 +349,7 @@ export default function CotizadorPage() {
propuestas: propuestaActiva ? [propuestaActiva] : [], propuestas: propuestaActiva ? [propuestaActiva] : [],
cliente, cliente,
desarrollo: params, desarrollo: params,
...(folio ? { folio } : {}), folio,
...(qrDataUrl ? { qrDataUrl } : {}), ...(qrDataUrl ? { qrDataUrl } : {}),
vigencia: calcularVigencia(), vigencia: calcularVigencia(),
}) })
@@ -393,7 +449,13 @@ export default function CotizadorPage() {
return return
} }
const tipo = TIPO_DE_API[c.tipoPropuesta] const tipo = TIPO_DE_API[c.tipoPropuesta]
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo) const paramsGuardados = desarrolloEfectivo(reglas, c.desarrollo)
const idx = paramsGuardados.propuestas.filter(propuestaVigente).findIndex((p) => p.tipo === tipo)
if (idx < 0) {
setAviso(
`La cotización ${c.folio ?? ''} usa la propuesta ${c.tipoPropuesta}, que ya no está disponible para ${c.desarrollo}. Elige otra propuesta.`,
)
}
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
restaurar({ restaurar({
desarrollo: c.desarrollo, desarrollo: c.desarrollo,
@@ -402,12 +464,17 @@ export default function CotizadorPage() {
apartado: c.apartado, apartado: c.apartado,
descuento: c.descuento, descuento: c.descuento,
propuestaIndex: idx >= 0 ? idx : 0, propuestaIndex: idx >= 0 ? idx : 0,
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null,
}) })
setValue('nombre', c.clienteNombre, { shouldValidate: true }) setValue('nombre', c.clienteNombre, { shouldValidate: true })
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true }) setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
setValue('email', c.clienteEmail ?? '', { shouldValidate: true }) setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
setUltimaGuardada(c) setUltimaGuardada(c)
setAviso(`Cotización ${c.folio ?? ''} retomada.`) setAviso(
esDisponible(u)
? `Cotización ${c.folio ?? ''} retomada.`
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`,
)
} }
const handleNueva = () => { const handleNueva = () => {
@@ -442,6 +509,17 @@ export default function CotizadorPage() {
</p> </p>
)} )}
{unidadNoDisponible && unidad && (
<p
role="alert"
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-800"
>
La unidad {unidad.codigo} no está disponible (estatus: {unidad.status}): no se puede
cotizar ni apartar. Elige otra unidad disponible; Guardar, Compartir y Generar PDF
quedan bloqueados.
</p>
)}
<div className="grid items-start gap-6 md:grid-cols-1 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8"> <div className="grid items-start gap-6 md:grid-cols-1 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
{/* Panel de configuración */} {/* Panel de configuración */}
<aside className="space-y-6 lg:sticky lg:top-24"> <aside className="space-y-6 lg:sticky lg:top-24">
@@ -457,7 +535,7 @@ export default function CotizadorPage() {
<div> <div>
<span className={etiquetaCampo}>Desarrollo</span> <span className={etiquetaCampo}>Desarrollo</span>
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group"> <div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
{LISTA_DESARROLLOS.map((id) => { {desarrollos.map((id) => {
const activo = id === desarrollo const activo = id === desarrollo
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
const disponibles = unidades.filter( const disponibles = unidades.filter(
@@ -475,7 +553,7 @@ export default function CotizadorPage() {
: 'bg-white text-[#4B5563] dark:text-[#CBD5E1] hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]' : 'bg-white text-[#4B5563] dark:text-[#CBD5E1] hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
}`} }`}
> >
{obtenerDesarrollo(id).nombre} {desarrolloEfectivo(reglas, id).nombre}
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B] dark:text-[#94A3B8]'}`}> <span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B] dark:text-[#94A3B8]'}`}>
{disponibles}/{total} {disponibles}/{total}
</span> </span>
@@ -549,7 +627,7 @@ export default function CotizadorPage() {
id="enganche" id="enganche"
type="number" type="number"
min={2.5} min={2.5}
max={100} max={ENGANCHE_MAXIMO_PCT}
step={0.5} step={0.5}
placeholder="Según propuesta" placeholder="Según propuesta"
value={enganchePct ?? ''} value={enganchePct ?? ''}
@@ -568,7 +646,8 @@ export default function CotizadorPage() {
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p> <p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
) : ( ) : (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]"> <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Aplica a crédito bancario e Infonavit/Cofinavit; mínimo 2.5% Aplica a crédito bancario e Infonavit/Cofinavit; rango válido 2.5% a{' '}
{ENGANCHE_MAXIMO_PCT}% (lo que permite el Excel)
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''} {unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
</p> </p>
)} )}
@@ -607,7 +686,9 @@ export default function CotizadorPage() {
if (lead.telefono) setValue('telefono', lead.telefono, { shouldValidate: true }) if (lead.telefono) setValue('telefono', lead.telefono, { shouldValidate: true })
if (lead.email) setValue('email', lead.email, { shouldValidate: true }) if (lead.email) setValue('email', lead.email, { shouldValidate: true })
if (lead.fuente === 'oportunidad') setOportunidadId(lead.id) if (lead.fuente === 'oportunidad') setOportunidadId(lead.id)
if (lead.desarrollo && LISTA_DESARROLLOS.includes(lead.desarrollo)) setDesarrollo(lead.desarrollo) if (lead.desarrollo && desarrollos.includes(lead.desarrollo)) {
setDesarrollo(lead.desarrollo)
}
setAviso( setAviso(
`Datos de ${lead.nombre} cargados${ `Datos de ${lead.nombre} cargados${
lead.fuente === 'oportunidad' ? ' — la cotización quedará vinculada a su oportunidad' : '' lead.fuente === 'oportunidad' ? ' — la cotización quedará vinculada a su oportunidad' : ''
@@ -726,6 +807,7 @@ export default function CotizadorPage() {
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'} tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
apartado={apartadoEfectivo} apartado={apartadoEfectivo}
acabados={acabados} acabados={acabados}
reglas={reglas}
/> />
</div> </div>
</main> </main>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest' import { describe, it, expect, vi, afterEach } from 'vitest'
import { getUnidades } from './api' import { getReglasCotizador, getUnidades } from './api'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
import type { Unidad } from '../types' import type { Unidad } from '../types'
@@ -65,3 +65,43 @@ describe('getUnidades', () => {
expect(warn).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledOnce()
}) })
}) })
describe('getReglasCotizador', () => {
it('devuelve las reglas por desarrollo que manda el API', async () => {
const fetchMock = vi.fn().mockResolvedValue(
respuestaOk({
reglasPorDesarrollo: {
DIAGONAL: { nombre: 'Diagonal', propuestas: [{ tipo: 'CONTADO', descuentoDefault: 0.05 }] },
},
}),
)
vi.stubGlobal('fetch', fetchMock)
const reglas = await getReglasCotizador()
expect(fetchMock).toHaveBeenCalledWith('/api/desarrollos/reglas-cotizador', {})
// Lo que manda el API gana; lo que falta se completa con la constante
expect(reglas['DIAGONAL']?.propuestas[0]?.descuentoDefault).toBe(0.05)
expect(reglas['DIAGONAL']?.propuestas[0]?.pctEnganche).toBe(0.9)
expect(reglas['DIAGONAL']?.nDefault).toBe(13)
})
it('sin respuesta del API devuelve un mapa vacío (todo cae a las constantes)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('error', { status: 500 })))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const reglas = await getReglasCotizador()
expect(reglas).toEqual({})
expect(warn).toHaveBeenCalledOnce()
})
it('respuesta con reglas corruptas: esas entradas se descartan', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(respuestaOk({ reglasPorDesarrollo: { SENDA: 'roto', ATELIER: null } })),
)
expect(await getReglasCotizador()).toEqual({})
})
})

View File

@@ -1,7 +1,13 @@
import type { Unidad, TipoPropuesta } from '../types' import type { Unidad, TipoPropuesta } from '../types'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas'
import { conAuth, manejar401 } from './auth' import { conAuth, manejar401 } from './auth'
import { guardarUnidades, cargarUnidadesLocal } from './offline-db' import {
guardarUnidades,
cargarUnidadesLocal,
guardarReglasLocal,
cargarReglasLocal,
} from './offline-db'
// TODO produccion: '/api' same-origin detras de NGINX // TODO produccion: '/api' same-origin detras de NGINX
const API_URL = import.meta.env.VITE_API_URL || '/api' const API_URL = import.meta.env.VITE_API_URL || '/api'
@@ -38,6 +44,32 @@ export async function getUnidades(): Promise<GetUnidadesResult> {
} }
} }
// ---------- Reglas comerciales del cotizador ----------
/**
* Reglas comerciales EFECTIVAS por desarrollo (`GET /desarrollos/reglas-cotizador`):
* las que el admin configuró en la BD, con las constantes de respaldo para los
* desarrollos sin reglas guardadas. Nunca lanza: sin servidor devuelve la
* última copia guardada y, si no hay, un mapa vacío (todo cae a las constantes).
*/
export async function getReglasCotizador(): Promise<ReglasDesarrollo> {
try {
const resp = await fetch(`${API_URL}/desarrollos/reglas-cotizador`, conAuth())
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const cuerpo = (await resp.json()) as { reglasPorDesarrollo?: unknown } | null
const reglas = reglasDesdeApi(cuerpo?.reglasPorDesarrollo)
void guardarReglasLocal(reglas)
return reglas
} catch (err) {
console.warn(
'[api] No se pudieron obtener las reglas del cotizador; se usan los valores por defecto.',
err,
)
return reglasDesdeApi(await cargarReglasLocal())
}
}
// ---------- Cotizaciones guardadas (historial del vendedor) ---------- // ---------- Cotizaciones guardadas (historial del vendedor) ----------
/** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */ /** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */
@@ -87,10 +119,27 @@ export interface GuardarCotizacionPayload {
acabados: boolean acabados: boolean
apartado?: number apartado?: number
descuento?: number descuento?: number
/** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */
numMensualidades?: number
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */ /** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string oportunidadId?: string
} }
/**
* Error del API al guardar una cotización (status HTTP + mensaje del servidor).
* Se distingue de los fallos de red: el servidor SÍ contestó y rechazó la
* cotización (p. ej. la unidad ya no está disponible), así que no se guarda.
*/
export class ErrorApiCotizacion extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ErrorApiCotizacion'
}
}
/** /**
* URL pública de una cotización (destino del QR del PDF). Debe ser ABSOLUTA: * URL pública de una cotización (destino del QR del PDF). Debe ser ABSOLUTA:
* un QR con ruta relativa ("/api/...") no es navegable al escanearlo desde el * un QR con ruta relativa ("/api/...") no es navegable al escanearlo desde el
@@ -111,7 +160,11 @@ export async function guardarCotizacion(payload: GuardarCotizacionPayload): Prom
body: JSON.stringify(payload), body: JSON.stringify(payload),
})) }))
manejar401(resp) manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`) if (!resp.ok) {
// El API explica el rechazo (unidad no disponible, datos inválidos, …)
const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null
throw new ErrorApiCotizacion(resp.status, cuerpo?.error ?? `No se pudo guardar la cotización (HTTP ${resp.status})`)
}
return (await resp.json()) as CotizacionGuardada return (await resp.json()) as CotizacionGuardada
} }

View File

@@ -32,6 +32,26 @@ export async function cargarUnidadesLocal(): Promise<unknown> {
} }
} }
// Las reglas del cotizador se guardan en la misma tienda que el inventario
// (clave propia), sin subir la versión de la BD.
export async function guardarReglasLocal(reglas: unknown) {
try {
const db = await getDB()
await db.put('unidades', reglas, 'reglas')
} catch {
// Sin almacenamiento: la próxima sesión las recarga del API
}
}
export async function cargarReglasLocal(): Promise<unknown> {
try {
const db = await getDB()
return db.get('unidades', 'reglas')
} catch {
return undefined
}
}
export async function guardarCotizaciones(cotizaciones: unknown[]) { export async function guardarCotizaciones(cotizaciones: unknown[]) {
try { try {
const db = await getDB() const db = await getDB()

View File

@@ -75,6 +75,11 @@ export interface PropuestaConfig {
modoMensualidades: ModoMensualidades modoMensualidades: ModoMensualidades
/** SOLANA Infonavit: si pctEnganche es 0, el enganche es 0 (no se resta el apartado) */ /** SOLANA Infonavit: si pctEnganche es 0, el enganche es 0 (no se resta el apartado) */
engancheCeroSinApartado?: boolean engancheCeroSinApartado?: boolean
/**
* Regla del admin: false => la propuesta no se ofrece ni se puede elegir en
* el cotizador (tampoco se calcula en el API). Ausente/true => vigente.
*/
activa?: boolean
} }
export interface ParametrosDesarrollo { export interface ParametrosDesarrollo {
@@ -86,9 +91,20 @@ export interface ParametrosDesarrollo {
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad' fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
/** N fijo cuando fuenteDeN === 'fijo' (entrega inmediata / term. obra) */ /** N fijo cuando fuenteDeN === 'fijo' (entrega inmediata / term. obra) */
nFijo?: number nFijo?: number
/**
* N por defecto del desarrollo: se usa cuando el inventario no trae fecha de
* entrega ni meses para firma. Garantiza que la mensualidad y el total
* siempre se calculen.
*/
nDefault?: number
/** Cuota mensual de mantenimiento (MXN); null => se muestra "PENDIENTE" */ /** Cuota mensual de mantenimiento (MXN); null => se muestra "PENDIENTE" */
cuotaMantenimiento: number | null cuotaMantenimiento: number | null
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */ /** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
prefijoUnidadPdf: string prefijoUnidadPdf: string
/**
* Regla del admin: false => el desarrollo no se ofrece en el cotizador (el
* API también rechaza cotizarlo). Ausente/true => disponible.
*/
activoEnCotizador?: boolean
propuestas: PropuestaConfig[] propuestas: PropuestaConfig[]
} }

View File

@@ -8,16 +8,30 @@ import { ConfigPage } from './components/config/ConfigPage'
import { CapturaPage } from './pages/CapturaPage' import { CapturaPage } from './pages/CapturaPage'
import { ClientesMaestroPage } from './pages/ClientesMaestroPage' import { ClientesMaestroPage } from './pages/ClientesMaestroPage'
import { SoportePage } from './pages/SoportePage' import { SoportePage } from './pages/SoportePage'
import { DesarrollosPage } from './pages/DesarrollosPage'
import { DesarrolloPublicoPage } from './pages/DesarrolloPublicoPage'
import { getToken, type UsuarioSesion } from './services/auth' import { getToken, type UsuarioSesion } from './services/auth'
import { getMe } from './services/api' import { getMe } from './services/api'
type Vista = 'home' | 'contactos' | 'captura' | 'clientes-maestro' | 'soporte' | 'configuraciones' type Vista = 'home' | 'contactos' | 'captura' | 'clientes-maestro' | 'soporte' | 'desarrollos' | 'configuraciones'
/**
* Micrositio público: NGINX sirve la landing en cualquier ruta, así que el deep
* link /d/<clave> (el que se comparte con el cliente final) se resuelve aquí y
* se renderiza sin navbar ni sesión.
*/
function clavePublicaDeRuta(ruta: string): string | null {
const match = /^\/d\/([^/]+)\/?$/.exec(ruta)
return match === null ? null : decodeURIComponent(match[1])
}
export default function App() { export default function App() {
const [vista, setVista] = useState<Vista>('home') const [vista, setVista] = useState<Vista>('home')
// Módulos permitidos por el rol (GET /auth/me); null = sin sesión → se muestran todos // Módulos permitidos por el rol (GET /auth/me); null = sin sesión → se muestran todos
const [permitidos, setPermitidos] = useState<string[] | null>(null) const [permitidos, setPermitidos] = useState<string[] | null>(null)
const [usuario, setUsuario] = useState<UsuarioSesion | null>(null) const [usuario, setUsuario] = useState<UsuarioSesion | null>(null)
// Se evalúa una sola vez al montar: el micrositio no cambia de ruta internamente
const [clavePublica] = useState(() => clavePublicaDeRuta(window.location.pathname))
useEffect(() => { useEffect(() => {
let cancelado = false let cancelado = false
@@ -45,6 +59,10 @@ export default function App() {
} }
}, []) }, [])
if (clavePublica !== null) {
return <DesarrolloPublicoPage clave={clavePublica} />
}
if (vista === 'contactos') { if (vista === 'contactos') {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
@@ -85,6 +103,16 @@ export default function App() {
) )
} }
if (vista === 'desarrollos' && usuario?.rol === 'admin') {
return (
<div className="flex min-h-screen flex-col">
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
<DesarrollosPage onVolver={() => setVista('home')} />
</div>
)
}
if (vista === 'configuraciones' && (usuario?.rol === 'admin' || usuario?.rol === 'manager')) { if (vista === 'configuraciones' && (usuario?.rol === 'admin' || usuario?.rol === 'manager')) {
return ( return (
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
@@ -128,6 +156,8 @@ export default function App() {
? () => setVista('clientes-maestro') ? () => setVista('clientes-maestro')
: modulo.id === 'soporte' : modulo.id === 'soporte'
? () => setVista('soporte') ? () => setVista('soporte')
: modulo.id === 'desarrollos'
? () => setVista('desarrollos')
: undefined : undefined
} }
/> />

View File

@@ -16,6 +16,10 @@ const MODULOS_DISPONIBLES = [
'mi-tablero', 'mi-tablero',
'tablero-comercial', 'tablero-comercial',
'contactos', 'contactos',
'clientes-maestro',
'captura',
'soporte',
'desarrollos',
] ]
const ACCIONES_DISPONIBLES = [ const ACCIONES_DISPONIBLES = [
@@ -48,6 +52,10 @@ const MODULO_LABELS: Record<string, string> = {
'mi-tablero': 'Mi tablero', 'mi-tablero': 'Mi tablero',
'tablero-comercial': 'Tablero comercial', 'tablero-comercial': 'Tablero comercial',
contactos: 'Contactos', contactos: 'Contactos',
'clientes-maestro': 'Clientes maestro',
captura: 'Captura',
soporte: 'Soporte',
desarrollos: 'Desarrollos (solo admin)',
} }
interface RolEstado { interface RolEstado {

View File

@@ -0,0 +1,345 @@
import { useCallback, useEffect, useState } from 'react'
import {
eliminarDesarrollo,
ErrorApi,
obtenerDesarrollo,
type DesarrolloFicha,
} from '../../services/desarrollos'
import {
BadgeEstado,
BadgeEstatus,
BOTON_MINIMO,
BOTON_PELIGRO,
BOTON_SECUNDARIO,
Chip,
ETIQUETA_ESTADO,
ETIQUETA_TIPO,
MensajeError,
MensajeExito,
TARJETA,
} from './comun'
import { FormularioDesarrollo } from './FormularioDesarrollo'
import { TabInventario } from './TabInventario'
import { TabModelos } from './TabModelos'
import { TabReglas } from './TabReglas'
import Skeleton from '../Skeleton'
type Tab = 'datos' | 'modelos' | 'inventario' | 'reglas'
const TABS: { key: Tab; label: string }[] = [
{ key: 'datos', label: 'Datos' },
{ key: 'modelos', label: 'Modelos' },
{ key: 'inventario', label: 'Inventario' },
{ key: 'reglas', label: 'Reglas del cotizador' },
]
const fecha = (valor: string | null | undefined) => (valor ? valor.slice(0, 10) : '—')
const texto = (valor: string | null | undefined) => (valor && valor.trim() !== '' ? valor : '—')
function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<div>
<dt className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">{etiqueta}</dt>
<dd className="text-sm text-[#111827] dark:text-[#E2E8F0]">{valor}</dd>
</div>
)
}
interface Props {
desarrolloId: string
onVolver: () => void
/** Avisa al listado que los datos del desarrollo cambiaron */
onCambio: () => void
}
/** Ficha de un desarrollo con las pestañas de datos, modelos, inventario y reglas */
export function FichaDesarrollo({ desarrolloId, onVolver, onCambio }: Props) {
const [ficha, setFicha] = useState<DesarrolloFicha | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
const [tab, setTab] = useState<Tab>('datos')
const [editando, setEditando] = useState(false)
const [errorEliminar, setErrorEliminar] = useState('')
const [sugerenciaEliminar, setSugerenciaEliminar] = useState('')
const [exitoDatos, setExitoDatos] = useState('')
const [eliminando, setEliminando] = useState(false)
const cargar = useCallback(async () => {
setCargando(true)
setError('')
try {
setFicha(await obtenerDesarrollo(desarrolloId))
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: 'No se pudo cargar la ficha del desarrollo.',
)
} finally {
setCargando(false)
}
}, [desarrolloId])
useEffect(() => {
void cargar()
}, [cargar])
const borrar = async (actual: DesarrolloFicha) => {
if (
typeof window !== 'undefined' &&
!window.confirm(`¿Eliminar el desarrollo "${actual.nombre}"? Esta acción no se puede deshacer.`)
) {
return
}
setErrorEliminar('')
setSugerenciaEliminar('')
setExitoDatos('')
setEliminando(true)
try {
await eliminarDesarrollo(actual.id)
onCambio()
onVolver()
} catch (err) {
setErrorEliminar(
err instanceof Error && err.message
? err.message
: 'No se pudo eliminar el desarrollo.',
)
if (err instanceof ErrorApi && err.status === 409) {
setSugerenciaEliminar(
'Para dejar de operarlo sin perder su historial, cambia su estado a CERRADO y desactiva su visibilidad en cotizador y altas.',
)
}
} finally {
setEliminando(false)
}
}
if (cargando) {
return (
<div className={`${TARJETA} p-6`}>
<Skeleton className="mb-3 h-5 w-1/3" />
<Skeleton className="mb-2 h-3 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
)
}
if (error || !ficha) {
return (
<div className={`${TARJETA} p-6`}>
<MensajeError>{error || 'No se encontró el desarrollo.'}</MensajeError>
<div className="mt-4 flex gap-3">
<button type="button" onClick={() => void cargar()} className={BOTON_SECUNDARIO}>
Reintentar
</button>
<button type="button" onClick={onVolver} className={BOTON_SECUNDARIO}>
Volver al listado
</button>
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start gap-4">
{ficha.logoUrl && (
<img
src={ficha.logoUrl}
alt={`Logo de ${ficha.nombre}`}
className="h-12 w-12 rounded-lg border border-[#E5E7EB] object-contain dark:border-[#334155]"
/>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0]">
{ficha.nombre}
</h1>
<BadgeEstado estado={ficha.estado} />
</div>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
{ficha.clave} · {ETIQUETA_TIPO[ficha.tipo] ?? ficha.tipo} ·{' '}
{ficha.unidadesDisponibles ?? 0} de {ficha.unidadesTotales ?? 0} unidades disponibles
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{ficha.activoEnCotizador ? <Chip>En cotizador</Chip> : <Chip>Oculto en cotizador</Chip>}
{ficha.activoEnAlta ? <Chip>En altas</Chip> : <Chip>Oculto en altas</Chip>}
{!ficha.tieneReglas && <Chip>Sin reglas del cotizador</Chip>}
</div>
</div>
<button type="button" onClick={onVolver} className={`${BOTON_SECUNDARIO} order-first`}>
← Volver al listado
</button>
</div>
<nav aria-label="Secciones de la ficha del desarrollo">
<ul
role="tablist"
className="flex flex-wrap gap-1 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-1 dark:border-[#1E293B] dark:bg-[#1E293B]"
>
{TABS.map((t) => (
<li key={t.key}>
<button
type="button"
role="tab"
id={`tab-${t.key}`}
aria-selected={tab === t.key}
aria-controls={`panel-${t.key}`}
aria-label={t.label}
onClick={() => setTab(t.key)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
tab === t.key
? 'bg-white text-[#111827] shadow-sm dark:bg-[#1E293B] dark:text-[#E2E8F0]'
: 'text-[#64748B] hover:text-[#374151] dark:text-[#94A3B8] dark:hover:text-[#CBD5E1]'
}`}
>
{t.label}
</button>
</li>
))}
</ul>
</nav>
<section id={`panel-${tab}`} role="tabpanel" aria-labelledby={`tab-${tab}`} className="space-y-4">
{tab === 'datos' && (
<>
{editando ? (
<div className={`${TARJETA} p-4`}>
<h2 className="mb-3 text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Editar datos del desarrollo
</h2>
<FormularioDesarrollo
inicial={ficha}
idPrefijo="dev-ficha"
onGuardado={(actualizada) => {
setFicha(actualizada)
setEditando(false)
setExitoDatos('Datos del desarrollo actualizados.')
onCambio()
}}
onCancelar={() => setEditando(false)}
/>
</div>
) : (
<>
<div className={`${TARJETA} p-4`}>
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Datos del desarrollo
</h2>
<button
type="button"
onClick={() => {
setEditando(true)
setExitoDatos('')
}}
className={BOTON_MINIMO}
>
Editar
</button>
</div>
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Dato etiqueta="Clave" valor={ficha.clave} />
<Dato etiqueta="Estado" valor={ETIQUETA_ESTADO[ficha.estado] ?? ficha.estado} />
<Dato etiqueta="Tipo" valor={ETIQUETA_TIPO[ficha.tipo] ?? ficha.tipo} />
<Dato etiqueta="Zona" valor={texto(ficha.zona)} />
<Dato etiqueta="Dirección" valor={texto(ficha.direccion)} />
<Dato etiqueta="Constructora" valor={texto(ficha.constructora)} />
<Dato etiqueta="Inicio de obra" valor={fecha(ficha.fechaInicioObra)} />
<Dato etiqueta="Entrega programada" valor={fecha(ficha.fechaEntregaProgramada)} />
<Dato
etiqueta="Entrega reprogramada"
valor={fecha(ficha.fechaEntregaReprogramada)}
/>
<Dato etiqueta="Prefijo en PDF" valor={texto(ficha.prefijoUnidadPdf)} />
<Dato
etiqueta="Visibilidad"
valor={`${ficha.activoEnCotizador ? 'Cotizador' : 'Sin cotizador'} · ${
ficha.activoEnAlta ? 'Altas' : 'Sin altas'
}`}
/>
</dl>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<p className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">Descripción</p>
<p className="whitespace-pre-line text-sm text-[#111827] dark:text-[#E2E8F0]">
{texto(ficha.descripcion)}
</p>
</div>
<div>
<p className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">Amenidades</p>
<p className="whitespace-pre-line text-sm text-[#111827] dark:text-[#E2E8F0]">
{texto(ficha.amenidades)}
</p>
</div>
</div>
{ficha.imagenUrl && (
<img
src={ficha.imagenUrl}
alt={`Imagen de ${ficha.nombre}`}
className="mt-4 max-h-48 w-auto max-w-full rounded-lg border border-[#E5E7EB] object-contain dark:border-[#334155]"
/>
)}
{Object.keys(ficha.conteosPorEstatus ?? {}).length > 0 && (
<div className="mt-4">
<p className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8] dark:text-[#64748B]">
Inventario por estatus
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{Object.entries(ficha.conteosPorEstatus).map(([estatus, cantidad]) => (
<span key={estatus} className="flex items-center gap-1.5">
<BadgeEstatus estatus={estatus} />
<span className="text-xs font-medium text-[#475569] dark:text-[#CBD5E1]">
{cantidad}
</span>
</span>
))}
</div>
</div>
)}
</div>
<div className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Zona de riesgo
</h2>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Eliminar el desarrollo solo es posible si no tiene unidades, modelos ni ventas
asociadas.
</p>
<div className="mt-3 flex flex-col gap-3">
<button
type="button"
onClick={() => void borrar(ficha)}
disabled={eliminando}
className={`${BOTON_PELIGRO} self-start`}
>
{eliminando ? 'Eliminando…' : 'Eliminar desarrollo'}
</button>
{errorEliminar && <MensajeError>{errorEliminar}</MensajeError>}
{sugerenciaEliminar && (
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
{sugerenciaEliminar}
</p>
)}
</div>
</div>
</>
)}
{exitoDatos && <MensajeExito>{exitoDatos}</MensajeExito>}
</>
)}
{tab === 'modelos' && <TabModelos desarrollo={ficha} onRefrescar={cargar} />}
{tab === 'inventario' && <TabInventario desarrollo={ficha} onRefrescar={cargar} />}
{tab === 'reglas' && <TabReglas desarrollo={ficha} onRefrescar={cargar} />}
</section>
</div>
)
}

View File

@@ -0,0 +1,458 @@
import { useState, type FormEvent } from 'react'
import {
actualizarDesarrollo,
crearDesarrollo,
type DatosDesarrollo,
type DesarrolloFicha,
type EstadoDesarrollo,
type TipoDesarrollo,
} from '../../services/desarrollos'
import {
AYUDA,
BOTON_PRIMARIO,
BOTON_SECUNDARIO,
Campo,
ETIQUETA_ESTADO,
ETIQUETA_TIPO,
INPUT,
INPUT_ERROR,
MensajeError,
MensajeExito,
TARJETA,
} from './comun'
const CLAVE_RE = /^[A-Z0-9-]{2,20}$/
const MENSAJE_CLAVE =
'La clave debe tener de 2 a 20 caracteres: mayúsculas, números y guiones (p. ej. SENDA-401).'
const ESTADOS: EstadoDesarrollo[] = ['ACTIVO', 'PREVENTA', 'CERRADO']
const TIPOS: TipoDesarrollo[] = ['VERTICAL', 'HORIZONTAL']
interface FormEstado {
clave: string
nombre: string
estado: EstadoDesarrollo
tipo: TipoDesarrollo
zona: string
direccion: string
constructora: string
descripcion: string
amenidades: string
fechaInicioObra: string
fechaEntregaProgramada: string
fechaEntregaReprogramada: string
activoEnCotizador: boolean
activoEnAlta: boolean
prefijoUnidadPdf: string
imagenUrl: string
logoUrl: string
}
function estadoInicial(ficha?: DesarrolloFicha | null): FormEstado {
return {
clave: ficha?.clave ?? '',
nombre: ficha?.nombre ?? '',
estado: ficha?.estado ?? 'PREVENTA',
tipo: ficha?.tipo ?? 'HORIZONTAL',
zona: ficha?.zona ?? '',
direccion: ficha?.direccion ?? '',
constructora: ficha?.constructora ?? '',
descripcion: ficha?.descripcion ?? '',
amenidades: ficha?.amenidades ?? '',
fechaInicioObra: ficha?.fechaInicioObra?.slice(0, 10) ?? '',
fechaEntregaProgramada: ficha?.fechaEntregaProgramada?.slice(0, 10) ?? '',
fechaEntregaReprogramada: ficha?.fechaEntregaReprogramada?.slice(0, 10) ?? '',
activoEnCotizador: ficha?.activoEnCotizador ?? true,
activoEnAlta: ficha?.activoEnAlta ?? true,
prefijoUnidadPdf: ficha?.prefijoUnidadPdf ?? '',
imagenUrl: ficha?.imagenUrl ?? '',
logoUrl: ficha?.logoUrl ?? '',
}
}
const nulo = (valor: string) => (valor.trim() === '' ? null : valor.trim())
function VistaPrevia({ url, etiqueta }: { url: string; etiqueta: string }) {
const [fallo, setFallo] = useState(false)
if (url.trim() === '') return null
return (
<div className="mt-2">
{fallo ? (
<p className={AYUDA}>No se pudo cargar la vista previa de {etiqueta}.</p>
) : (
<img
src={url}
alt={`Vista previa de ${etiqueta}`}
onError={() => setFallo(true)}
className="max-h-24 w-auto max-w-full rounded-lg border border-[#E5E7EB] object-contain dark:border-[#334155]"
/>
)}
</div>
)
}
interface Props {
/** Ficha a editar; sin ficha el formulario da de alta */
inicial?: DesarrolloFicha | null
/** Prefijo de ids para que varios formularios convivan en la misma pantalla */
idPrefijo?: string
onGuardado: (ficha: DesarrolloFicha) => void
onCancelar?: () => void
}
/**
* Alta y edición de un desarrollo. Se usa dentro del modal del listado y en la
* pestaña "Datos" de la ficha, así que no lleva encabezado propio.
*/
export function FormularioDesarrollo({
inicial = null,
idPrefijo = 'dev',
onGuardado,
onCancelar,
}: Props) {
const [form, setForm] = useState<FormEstado>(() => estadoInicial(inicial))
const [errores, setErrores] = useState<Record<string, string>>({})
const [errorServidor, setErrorServidor] = useState('')
const [exito, setExito] = useState('')
const [guardando, setGuardando] = useState(false)
const id = (campo: string) => `${idPrefijo}-${campo}`
const set = <K extends keyof FormEstado>(campo: K, valor: FormEstado[K]) =>
setForm((prev) => ({ ...prev, [campo]: valor }))
const errorClave =
form.clave !== '' && !CLAVE_RE.test(form.clave)
? MENSAJE_CLAVE
: errores.clave
const validar = (): Record<string, string> => {
const nuevos: Record<string, string> = {}
const clave = form.clave.trim()
if (clave === '') nuevos.clave = 'La clave es obligatoria.'
else if (!CLAVE_RE.test(clave)) nuevos.clave = MENSAJE_CLAVE
if (form.nombre.trim().length < 3) nuevos.nombre = 'Escribe el nombre del desarrollo (mínimo 3 caracteres).'
return nuevos
}
const enviar = async (evento: FormEvent) => {
evento.preventDefault()
setErrorServidor('')
setExito('')
const nuevos = validar()
setErrores(nuevos)
if (Object.keys(nuevos).length > 0) return
const datos: DatosDesarrollo = {
clave: form.clave.trim(),
nombre: form.nombre.trim(),
estado: form.estado,
tipo: form.tipo,
activoEnCotizador: form.activoEnCotizador,
activoEnAlta: form.activoEnAlta,
zona: nulo(form.zona),
direccion: nulo(form.direccion),
constructora: nulo(form.constructora),
descripcion: nulo(form.descripcion),
amenidades: nulo(form.amenidades),
fechaInicioObra: nulo(form.fechaInicioObra),
fechaEntregaProgramada: nulo(form.fechaEntregaProgramada),
fechaEntregaReprogramada: nulo(form.fechaEntregaReprogramada),
prefijoUnidadPdf: nulo(form.prefijoUnidadPdf),
imagenUrl: nulo(form.imagenUrl),
logoUrl: nulo(form.logoUrl),
}
setGuardando(true)
try {
const ficha = inicial
? await actualizarDesarrollo(inicial.id, datos)
: await crearDesarrollo(datos)
setExito(inicial ? 'Cambios guardados.' : `Desarrollo "${datos.clave}" creado.`)
onGuardado(ficha)
} catch (err) {
setErrorServidor(
err instanceof Error && err.message
? err.message
: 'No se pudo guardar el desarrollo.',
)
} finally {
setGuardando(false)
}
}
return (
<form onSubmit={enviar} className="space-y-4" noValidate>
{/* Identidad */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-identidad')}>
<h3 id={id('titulo-identidad')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Identidad
</h3>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<Campo
id={id('clave')}
etiqueta="Clave"
requerido
ayuda="Forma los códigos de unidad del inventario, p. ej. SENDA-401-{n}."
error={errorClave}
>
<input
id={id('clave')}
type="text"
value={form.clave}
maxLength={20}
aria-invalid={errorClave ? true : undefined}
onChange={(e) => set('clave', e.target.value.toUpperCase())}
placeholder="SENDA-401"
className={`${INPUT} ${errorClave ? INPUT_ERROR : ''}`}
/>
</Campo>
<Campo
id={id('nombre')}
etiqueta="Nombre"
requerido
error={errores.nombre}
>
<input
id={id('nombre')}
type="text"
value={form.nombre}
aria-invalid={errores.nombre ? true : undefined}
onChange={(e) => set('nombre', e.target.value)}
placeholder="Senda Residencial"
className={`${INPUT} ${errores.nombre ? INPUT_ERROR : ''}`}
/>
</Campo>
<Campo id={id('estado')} etiqueta="Estado">
<select
id={id('estado')}
value={form.estado}
onChange={(e) => set('estado', e.target.value as EstadoDesarrollo)}
className={INPUT}
>
{ESTADOS.map((opcion) => (
<option key={opcion} value={opcion}>
{ETIQUETA_ESTADO[opcion]}
</option>
))}
</select>
</Campo>
<Campo id={id('tipo')} etiqueta="Tipo">
<select
id={id('tipo')}
value={form.tipo}
onChange={(e) => set('tipo', e.target.value as TipoDesarrollo)}
className={INPUT}
>
{TIPOS.map((opcion) => (
<option key={opcion} value={opcion}>
{ETIQUETA_TIPO[opcion]}
</option>
))}
</select>
</Campo>
</div>
</section>
{/* Ubicación */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-ubicacion')}>
<h3 id={id('titulo-ubicacion')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Ubicación
</h3>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<Campo id={id('zona')} etiqueta="Zona">
<input
id={id('zona')}
type="text"
value={form.zona}
onChange={(e) => set('zona', e.target.value)}
placeholder="Zona Norte"
className={INPUT}
/>
</Campo>
<Campo id={id('constructora')} etiqueta="Constructora">
<input
id={id('constructora')}
type="text"
value={form.constructora}
onChange={(e) => set('constructora', e.target.value)}
placeholder="Constructora Frasa"
className={INPUT}
/>
</Campo>
<Campo id={id('direccion')} etiqueta="Dirección" className="sm:col-span-2">
<input
id={id('direccion')}
type="text"
value={form.direccion}
onChange={(e) => set('direccion', e.target.value)}
placeholder="Av. Reforma 123, Col. Centro"
className={INPUT}
/>
</Campo>
</div>
</section>
{/* Comercial */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-comercial')}>
<h3 id={id('titulo-comercial')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Comercial
</h3>
<div className="mt-3 grid gap-4">
<Campo id={id('descripcion')} etiqueta="Descripción">
<textarea
id={id('descripcion')}
rows={3}
value={form.descripcion}
onChange={(e) => set('descripcion', e.target.value)}
className={INPUT}
/>
</Campo>
<Campo
id={id('amenidades')}
etiqueta="Amenidades"
ayuda="Una por línea o separadas por comas."
>
<textarea
id={id('amenidades')}
rows={3}
value={form.amenidades}
onChange={(e) => set('amenidades', e.target.value)}
className={INPUT}
/>
</Campo>
</div>
</section>
{/* Fechas clave */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-fechas')}>
<h3 id={id('titulo-fechas')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Fechas clave
</h3>
<div className="mt-3 grid gap-4 sm:grid-cols-3">
<Campo id={id('fechaInicioObra')} etiqueta="Inicio de obra">
<input
id={id('fechaInicioObra')}
type="date"
value={form.fechaInicioObra}
onChange={(e) => set('fechaInicioObra', e.target.value)}
className={INPUT}
/>
</Campo>
<Campo id={id('fechaEntregaProgramada')} etiqueta="Entrega programada">
<input
id={id('fechaEntregaProgramada')}
type="date"
value={form.fechaEntregaProgramada}
onChange={(e) => set('fechaEntregaProgramada', e.target.value)}
className={INPUT}
/>
</Campo>
<Campo id={id('fechaEntregaReprogramada')} etiqueta="Entrega reprogramada">
<input
id={id('fechaEntregaReprogramada')}
type="date"
value={form.fechaEntregaReprogramada}
onChange={(e) => set('fechaEntregaReprogramada', e.target.value)}
className={INPUT}
/>
</Campo>
</div>
</section>
{/* Visibilidad */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-visibilidad')}>
<h3 id={id('titulo-visibilidad')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Visibilidad
</h3>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:gap-8">
<label htmlFor={id('activoEnCotizador')} className="flex items-center gap-2 text-sm text-[#374151] dark:text-[#CBD5E1]">
<input
id={id('activoEnCotizador')}
type="checkbox"
checked={form.activoEnCotizador}
onChange={(e) => set('activoEnCotizador', e.target.checked)}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Aparece en el cotizador
</label>
<label htmlFor={id('activoEnAlta')} className="flex items-center gap-2 text-sm text-[#374151] dark:text-[#CBD5E1]">
<input
id={id('activoEnAlta')}
type="checkbox"
checked={form.activoEnAlta}
onChange={(e) => set('activoEnAlta', e.target.checked)}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Aparece en altas
</label>
</div>
</section>
{/* Presentación */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-presentacion')}>
<h3 id={id('titulo-presentacion')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Presentación
</h3>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<Campo id={id('imagenUrl')} etiqueta="URL de imagen">
<input
id={id('imagenUrl')}
type="url"
value={form.imagenUrl}
onChange={(e) => set('imagenUrl', e.target.value)}
placeholder="https://…/render.jpg"
className={INPUT}
/>
<VistaPrevia url={form.imagenUrl} etiqueta="la imagen" />
</Campo>
<Campo id={id('logoUrl')} etiqueta="URL de logo">
<input
id={id('logoUrl')}
type="url"
value={form.logoUrl}
onChange={(e) => set('logoUrl', e.target.value)}
placeholder="https://…/logo.png"
className={INPUT}
/>
<VistaPrevia url={form.logoUrl} etiqueta="el logo" />
</Campo>
<Campo
id={id('prefijoUnidadPdf')}
etiqueta="Prefijo de unidad en el PDF"
ayuda="Cómo se nombra la unidad en la cotización impresa (CASA, DEPTO…)."
>
<input
id={id('prefijoUnidadPdf')}
type="text"
value={form.prefijoUnidadPdf}
onChange={(e) => set('prefijoUnidadPdf', e.target.value)}
placeholder="CASA"
className={INPUT}
/>
</Campo>
</div>
</section>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<button type="submit" disabled={guardando} className={BOTON_PRIMARIO}>
{guardando ? 'Guardando…' : inicial ? 'Guardar cambios' : 'Crear desarrollo'}
</button>
{onCancelar && (
<button type="button" onClick={onCancelar} className={BOTON_SECUNDARIO}>
Cancelar
</button>
)}
<div className="sm:flex-1">
{exito && <MensajeExito>{exito}</MensajeExito>}
{errorServidor && <MensajeError>{errorServidor}</MensajeError>}
</div>
</div>
</form>
)
}
export { CLAVE_RE }

View File

@@ -0,0 +1,137 @@
import { useState } from 'react'
import type { DesarrolloResumen, EstadoDesarrollo } from '../../services/desarrollos'
import { BOTON_PRIMARIO, BadgeEstado, INPUT, LABEL, TARJETA } from './comun'
import { TarjetaDesarrollo } from './TarjetaDesarrollo'
import Skeleton from '../Skeleton'
const ESTADOS: EstadoDesarrollo[] = ['ACTIVO', 'PREVENTA', 'CERRADO']
interface Props {
desarrollos: DesarrolloResumen[]
cargando: boolean
onNuevo: () => void
onAbrir: (desarrollo: DesarrolloResumen) => void
onEditar: (desarrollo: DesarrolloResumen) => void
}
/** Listado de desarrollos en tarjetas, con buscador y filtro por estado */
export function ListaDesarrollos({ desarrollos, cargando, onNuevo, onAbrir, onEditar }: Props) {
const [busqueda, setBusqueda] = useState('')
const [estado, setEstado] = useState<'' | EstadoDesarrollo>('')
const termino = busqueda.trim().toLowerCase()
const filtrados = desarrollos.filter((desarrollo) => {
const coincideTexto =
termino === '' ||
desarrollo.nombre.toLowerCase().includes(termino) ||
desarrollo.clave.toLowerCase().includes(termino)
const coincideEstado = estado === '' || desarrollo.estado === estado
return coincideTexto && coincideEstado
})
return (
<div className="space-y-4">
<div className={`${TARJETA} p-4`}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="grid flex-1 gap-3 sm:grid-cols-2">
<div>
<label htmlFor="buscar-desarrollo" className={LABEL}>
Buscar desarrollo
</label>
<input
id="buscar-desarrollo"
type="search"
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
placeholder="Nombre o clave…"
className={INPUT}
/>
</div>
<div>
<label htmlFor="filtro-estado" className={LABEL}>
Estado
</label>
<select
id="filtro-estado"
value={estado}
onChange={(e) => setEstado(e.target.value as '' | EstadoDesarrollo)}
className={INPUT}
>
<option value="">Todos los estados</option>
{ESTADOS.map((opcion) => (
<option key={opcion} value={opcion}>
{opcion}
</option>
))}
</select>
</div>
</div>
<button type="button" onClick={onNuevo} className={BOTON_PRIMARIO}>
+ Nuevo desarrollo
</button>
</div>
</div>
{cargando ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={`${TARJETA} p-4`}>
<Skeleton className="mb-3 h-4 w-1/2" />
<Skeleton className="mb-2 h-3 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
))}
</div>
) : desarrollos.length === 0 ? (
<div className={`${TARJETA} p-8 text-center`}>
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
Aún no hay desarrollos
</p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Crea el primero para capturar sus modelos, inventario y reglas del cotizador.
</p>
</div>
) : filtrados.length === 0 ? (
<div className={`${TARJETA} p-8 text-center`}>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
No se encontraron desarrollos con ese filtro.
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtrados.map((desarrollo) => (
<TarjetaDesarrollo
key={desarrollo.id}
desarrollo={desarrollo}
onAbrir={onAbrir}
onEditar={onEditar}
/>
))}
</div>
)}
{!cargando && desarrollos.length > 0 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
{filtrados.length} de {desarrollos.length} desarrollos
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-[#94A3B8] dark:text-[#64748B]">Conteo por estado:</span>
{ESTADOS.map((opcion) => {
const cantidad = desarrollos.filter((d) => d.estado === opcion).length
if (cantidad === 0) return null
return (
<span key={opcion} className="flex items-center gap-1.5">
<BadgeEstado estado={opcion} />
<span className="text-xs font-medium text-[#475569] dark:text-[#CBD5E1]">
{cantidad}
</span>
</span>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { useEffect } from 'react'
import { X } from 'lucide-react'
import type { DesarrolloFicha } from '../../services/desarrollos'
import { FormularioDesarrollo } from './FormularioDesarrollo'
interface Props {
/** Sin ficha el modal da de alta; con ficha edita */
desarrollo?: DesarrolloFicha | null
onCerrar: () => void
onGuardado: (ficha: DesarrolloFicha) => void
}
/** Alta/edición de un desarrollo en una ventana modal */
export function ModalDesarrollo({ desarrollo = null, onCerrar, onGuardado }: Props) {
useEffect(() => {
const alPresionar = (evento: KeyboardEvent) => {
if (evento.key === 'Escape') onCerrar()
}
window.addEventListener('keydown', alPresionar)
return () => window.removeEventListener('keydown', alPresionar)
}, [onCerrar])
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="titulo-modal-desarrollo"
>
<div className="w-full max-w-3xl rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4 shadow-xl sm:p-6 dark:border-[#1E293B] dark:bg-[#0F172A]">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2
id="titulo-modal-desarrollo"
className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]"
>
{desarrollo ? `Editar ${desarrollo.nombre}` : 'Nuevo desarrollo'}
</h2>
<p className="mt-0.5 text-sm text-[#64748B] dark:text-[#94A3B8]">
{desarrollo
? `Clave ${desarrollo.clave}`
: 'Captura la identidad, ubicación y visibilidad del desarrollo.'}
</p>
</div>
<button
type="button"
onClick={onCerrar}
aria-label="Cerrar"
className="rounded-lg p-1 text-[#64748B] transition-colors hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]"
>
<X className="h-5 w-5" />
</button>
</div>
<FormularioDesarrollo
inicial={desarrollo}
idPrefijo="dev-modal"
onGuardado={onGuardado}
onCancelar={onCerrar}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,761 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
crearUnidades,
eliminarUnidad,
estatusDe,
listarUnidades,
type DesarrolloFicha,
type UnidadDesarrollo,
type UnidadManual,
} from '../../services/desarrollos'
import {
aNumero,
AYUDA,
BadgeEstatus,
BOTON_MINIMO,
BOTON_PELIGRO,
BOTON_PRIMARIO,
BOTON_SECUNDARIO,
Campo,
ESTATUS_UNIDAD,
INPUT,
MensajeError,
MensajeExito,
moneda,
TARJETA,
} from './comun'
import { generarCodigos, totalDelRango, validarGenerador } from './generador'
import Skeleton from '../Skeleton'
interface Props {
desarrollo: DesarrolloFicha
onRefrescar: () => Promise<void> | void
}
type Modo = 'generador' | 'manual'
interface FilaManual {
codigo: string
manzana: string
lote: string
nivel: string
modeloId: string
precioLista: string
precioAcabados: string
supConstruccion: string
}
const filaVacia = (): FilaManual => ({
codigo: '',
manzana: '',
lote: '',
nivel: '',
modeloId: '',
precioLista: '',
precioAcabados: '',
supConstruccion: '',
})
/** Campo de la captura manual: sin etiqueta visible, con aria-label propio */
function EntradaFila({
etiqueta,
valor,
onChange,
tipo = 'text',
ancho = '',
}: {
etiqueta: string
valor: string
onChange: (valor: string) => void
tipo?: 'text' | 'number'
ancho?: string
}) {
return (
<input
type={tipo}
value={valor}
aria-label={etiqueta}
placeholder={etiqueta}
onChange={(e) => onChange(e.target.value)}
className={`w-full rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] ${ancho}`}
/>
)
}
const UNIDADES_POR_PAGINA = 10
/** Inventario del desarrollo: alta por generador o manual, y tabla existente */
export function TabInventario({ desarrollo, onRefrescar }: Props) {
const [modo, setModo] = useState<Modo>('generador')
// Generador
const [patron, setPatron] = useState('')
const [desde, setDesde] = useState('')
const [hasta, setHasta] = useState('')
const [modeloId, setModeloId] = useState('')
const [precioLista, setPrecioLista] = useState('')
const [precioAcabados, setPrecioAcabados] = useState('')
const [supConstruccion, setSupConstruccion] = useState('')
// Manual
const [filas, setFilas] = useState<FilaManual[]>([filaVacia(), filaVacia()])
const [error, setError] = useState('')
const [exito, setExito] = useState('')
const [enviando, setEnviando] = useState(false)
// Inventario existente (GET /api/unidades?desarrollo=CLAVE)
const [unidades, setUnidades] = useState<UnidadDesarrollo[]>([])
const [cargandoUnidades, setCargandoUnidades] = useState(true)
const [avisoUnidades, setAvisoUnidades] = useState('')
const [busqueda, setBusqueda] = useState('')
const [filtroEstatus, setFiltroEstatus] = useState('')
const [pagina, setPagina] = useState(1)
const clave = desarrollo.clave
const cargarUnidades = useCallback(async () => {
setCargandoUnidades(true)
try {
setUnidades(await listarUnidades(clave))
setAvisoUnidades('')
} catch (err) {
setUnidades([])
setAvisoUnidades(
err instanceof Error && err.message
? `No se pudo cargar el inventario: ${err.message}`
: 'No se pudo cargar el inventario existente.',
)
} finally {
setCargandoUnidades(false)
}
}, [clave])
useEffect(() => {
void cargarUnidades()
}, [cargarUnidades])
const num = (valor: string) => (valor.trim() === '' ? Number.NaN : Number(valor))
const desdeNum = num(desde)
const hastaNum = num(hasta)
const errorGenerador = validarGenerador(patron, desdeNum, hastaNum)
const totalPrevisto = totalDelRango(desdeNum, hastaNum)
const previos = useMemo(
() => generarCodigos(patron, clave, desdeNum, hastaNum, 5),
[patron, clave, desdeNum, hastaNum],
)
const modelos = desarrollo.modelos ?? []
const guardarGenerador = async () => {
setError('')
setExito('')
if (errorGenerador) {
setError(errorGenerador)
return
}
const lista = aNumero(precioLista)
if (lista === null) {
setError('Escribe el precio de lista de las unidades.')
return
}
setEnviando(true)
try {
const resultado = await crearUnidades(desarrollo.id, {
generar: {
patron: patron.trim(),
desde: desdeNum,
hasta: hastaNum,
...(modeloId ? { modeloId } : {}),
precioLista: lista,
...(aNumero(precioAcabados) !== null ? { precioAcabados: aNumero(precioAcabados)! } : {}),
...(aNumero(supConstruccion) !== null
? { supConstruccion: aNumero(supConstruccion)! }
: {}),
},
})
setExito(`Se crearon ${resultado.creadas} unidades en el inventario.`)
setPatron('')
setDesde('')
setHasta('')
await cargarUnidades()
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudieron crear las unidades.')
} finally {
setEnviando(false)
}
}
const guardarManual = async () => {
setError('')
setExito('')
const capturadas = filas.filter(
(fila) =>
fila.codigo.trim() !== '' ||
fila.manzana.trim() !== '' ||
fila.lote.trim() !== '' ||
fila.nivel.trim() !== '' ||
fila.precioLista.trim() !== '' ||
fila.precioAcabados.trim() !== '' ||
fila.supConstruccion.trim() !== '',
)
if (capturadas.length === 0) {
setError('Captura al menos una unidad con su código y precio de lista.')
return
}
if (capturadas.some((fila) => fila.codigo.trim() === '')) {
setError('Cada unidad necesita un código.')
return
}
const sinPrecio = capturadas.filter((fila) => aNumero(fila.precioLista) === null)
if (sinPrecio.length > 0) {
setError(
`El precio de lista es obligatorio: falta en ${sinPrecio
.map((fila) => fila.codigo.trim())
.join(', ')}.`,
)
return
}
const codigos = capturadas.map((fila) => fila.codigo.trim())
const repetidos = codigos.filter((codigo, i) => codigos.indexOf(codigo) !== i)
if (repetidos.length > 0) {
setError(`Hay códigos repetidos en la captura: ${[...new Set(repetidos)].join(', ')}.`)
return
}
const unidadesNuevas: UnidadManual[] = capturadas.map((fila) => {
const precioAcabados = aNumero(fila.precioAcabados)
const supConstruccion = aNumero(fila.supConstruccion)
return {
codigo: fila.codigo.trim(),
...(fila.manzana.trim() !== '' ? { manzana: fila.manzana.trim() } : {}),
...(fila.lote.trim() !== '' ? { lote: fila.lote.trim() } : {}),
...(fila.nivel.trim() !== '' ? { nivel: fila.nivel.trim() } : {}),
...(fila.modeloId !== '' ? { modeloId: fila.modeloId } : {}),
precioLista: aNumero(fila.precioLista)!,
...(precioAcabados !== null ? { precioAcabados } : {}),
...(supConstruccion !== null ? { supConstruccion } : {}),
}
})
setEnviando(true)
try {
const resultado = await crearUnidades(desarrollo.id, { unidades: unidadesNuevas })
setExito(`Se crearon ${resultado.creadas} unidades en el inventario.`)
setFilas([filaVacia(), filaVacia()])
await cargarUnidades()
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudieron crear las unidades.')
} finally {
setEnviando(false)
}
}
const borrarUnidad = async (unidad: UnidadDesarrollo) => {
if (
typeof window !== 'undefined' &&
!window.confirm(`¿Eliminar la unidad ${unidad.codigo} del inventario?`)
) {
return
}
setError('')
setExito('')
try {
await eliminarUnidad(desarrollo.id, unidad.codigo)
setExito(`Unidad ${unidad.codigo} eliminada.`)
await cargarUnidades()
await onRefrescar()
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: `No se pudo eliminar la unidad ${unidad.codigo}.`,
)
}
}
// Conteos por estatus: los del servidor mandan; si no vienen, se cuentan aquí
const conteos = useMemo(() => {
const delServidor = desarrollo.conteosPorEstatus ?? {}
if (Object.keys(delServidor).length > 0) return delServidor
return unidades.reduce<Record<string, number>>((acc, unidad) => {
const estatus = estatusDe(unidad) ?? 'SIN ESTATUS'
acc[estatus] = (acc[estatus] ?? 0) + 1
return acc
}, {})
}, [desarrollo.conteosPorEstatus, unidades])
const termino = busqueda.trim().toLowerCase()
const filtradas = unidades.filter((unidad) => {
const coincideTexto =
termino === '' ||
unidad.codigo.toLowerCase().includes(termino) ||
(unidad.modelo ?? '').toLowerCase().includes(termino)
const coincideEstatus = filtroEstatus === '' || (estatusDe(unidad) ?? '') === filtroEstatus
return coincideTexto && coincideEstatus
})
const totalPaginas = Math.max(1, Math.ceil(filtradas.length / UNIDADES_POR_PAGINA))
const paginaActual = Math.min(pagina, totalPaginas)
const visibles = filtradas.slice(
(paginaActual - 1) * UNIDADES_POR_PAGINA,
paginaActual * UNIDADES_POR_PAGINA,
)
return (
<div className="space-y-4">
<section className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Agregar inventario
</h2>
<div className="mt-3 flex gap-1 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-1 dark:border-[#1E293B] dark:bg-[#0F172A]">
{(
[
['generador', 'Generador'],
['manual', 'Captura manual'],
] as const
).map(([clave, etiqueta]) => (
<button
key={clave}
type="button"
onClick={() => {
setModo(clave)
setError('')
setExito('')
}}
aria-pressed={modo === clave}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
modo === clave
? 'bg-white text-[#111827] shadow-sm dark:bg-[#1E293B] dark:text-[#E2E8F0]'
: 'text-[#64748B] hover:text-[#374151] dark:text-[#94A3B8] dark:hover:text-[#CBD5E1]'
}`}
>
{etiqueta}
</button>
))}
</div>
{modo === 'generador' ? (
<div className="mt-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Campo
id="inv-patron"
etiqueta="Patrón de código"
requerido
ayuda="Admite {clave} y {n}. Ejemplos: SENDA-401-{n}, {clave}-{n}, CASA-{n}."
className="sm:col-span-2"
>
<input
id="inv-patron"
type="text"
value={patron}
onChange={(e) => setPatron(e.target.value)}
placeholder="SENDA-401-{n}"
className={INPUT}
/>
</Campo>
<Campo id="inv-desde" etiqueta="Desde" requerido>
<input
id="inv-desde"
type="number"
value={desde}
onChange={(e) => setDesde(e.target.value)}
placeholder="1"
className={INPUT}
/>
</Campo>
<Campo id="inv-hasta" etiqueta="Hasta" requerido>
<input
id="inv-hasta"
type="number"
value={hasta}
onChange={(e) => setHasta(e.target.value)}
placeholder="20"
className={INPUT}
/>
</Campo>
<Campo id="inv-modelo" etiqueta="Modelo (opcional)">
<select
id="inv-modelo"
value={modeloId}
onChange={(e) => setModeloId(e.target.value)}
className={INPUT}
>
<option value="">Sin modelo</option>
{modelos.map((modelo) => (
<option key={modelo.id} value={modelo.id}>
{modelo.nombre}
</option>
))}
</select>
</Campo>
<Campo id="inv-precio-lista" etiqueta="Precio de lista" requerido>
<input
id="inv-precio-lista"
type="number"
min={0}
value={precioLista}
onChange={(e) => setPrecioLista(e.target.value)}
placeholder="1500000"
className={INPUT}
/>
</Campo>
<Campo id="inv-precio-acabados" etiqueta="Precio con acabados">
<input
id="inv-precio-acabados"
type="number"
min={0}
value={precioAcabados}
onChange={(e) => setPrecioAcabados(e.target.value)}
placeholder="1650000"
className={INPUT}
/>
</Campo>
<Campo id="inv-sup-construccion" etiqueta="Superficie de construcción (m²)">
<input
id="inv-sup-construccion"
type="number"
min={0}
step="any"
value={supConstruccion}
onChange={(e) => setSupConstruccion(e.target.value)}
placeholder="120"
className={INPUT}
/>
</Campo>
</div>
{/* Vista previa en vivo */}
<div
className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-4 dark:border-[#334155] dark:bg-[#0F172A]"
aria-live="polite"
>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Vista previa
</p>
{errorGenerador ? (
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
Completa patrón y rango para ver los códigos que se crearán.
</p>
) : (
<>
<p className="mt-2 text-sm text-[#111827] dark:text-[#E2E8F0]">
{`Se crearán ${totalPrevisto} unidades.`}
</p>
<ol className="mt-2 flex flex-wrap gap-1.5">
{previos.map((codigo) => (
<li
key={codigo}
className="rounded-lg border border-[#E5E7EB] bg-white px-2 py-1 font-mono text-xs text-[#111827] dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#E2E8F0]"
>
{codigo}
</li>
))}
{totalPrevisto > previos.length && (
<li className="px-2 py-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{`… y ${totalPrevisto - previos.length} más`}
</li>
)}
</ol>
</>
)}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="button"
onClick={() => void guardarGenerador()}
disabled={enviando || errorGenerador !== null || precioLista.trim() === ''}
className={BOTON_PRIMARIO}
>
{enviando ? 'Creando…' : `Crear ${totalPrevisto || ''} unidades`.trim()}
</button>
{errorGenerador && !error && (
<p className={AYUDA}>{errorGenerador}</p>
)}
{!errorGenerador && precioLista.trim() === '' && (
<p className={AYUDA}>Falta el precio de lista de las unidades.</p>
)}
</div>
</div>
) : (
<div className="mt-4 space-y-3">
<div className="hidden gap-2 xl:grid xl:grid-cols-[1.2fr_0.8fr_0.8fr_0.8fr_1fr_1fr_1fr_0.8fr_auto]">
{['Código', 'Manzana', 'Lote', 'Nivel', 'Modelo', 'Precio lista *', 'Con acabados', 'm² const.', ''].map(
(titulo, i) => (
<span
key={`${titulo}-${i}`}
className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8] dark:text-[#64748B]"
>
{titulo}
</span>
),
)}
</div>
{filas.map((fila, indice) => (
<div
key={indice}
className="grid gap-2 rounded-lg border border-[#E5E7EB] p-2 xl:grid-cols-[1.2fr_0.8fr_0.8fr_0.8fr_1fr_1fr_1fr_0.8fr_auto] xl:items-center xl:border-0 xl:p-0 dark:border-[#334155]"
>
<EntradaFila
etiqueta={`Código de la fila ${indice + 1}`}
valor={fila.codigo}
onChange={(valor) =>
setFilas((prev) => prev.map((f, i) => (i === indice ? { ...f, codigo: valor } : f)))
}
/>
<EntradaFila
etiqueta={`Manzana de la fila ${indice + 1}`}
valor={fila.manzana}
onChange={(valor) =>
setFilas((prev) => prev.map((f, i) => (i === indice ? { ...f, manzana: valor } : f)))
}
/>
<EntradaFila
etiqueta={`Lote de la fila ${indice + 1}`}
valor={fila.lote}
onChange={(valor) =>
setFilas((prev) => prev.map((f, i) => (i === indice ? { ...f, lote: valor } : f)))
}
/>
<EntradaFila
etiqueta={`Nivel de la fila ${indice + 1}`}
valor={fila.nivel}
onChange={(valor) =>
setFilas((prev) => prev.map((f, i) => (i === indice ? { ...f, nivel: valor } : f)))
}
/>
<select
value={fila.modeloId}
aria-label={`Modelo de la fila ${indice + 1}`}
onChange={(e) =>
setFilas((prev) =>
prev.map((f, i) => (i === indice ? { ...f, modeloId: e.target.value } : f)),
)
}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Modelo…</option>
{modelos.map((modelo) => (
<option key={modelo.id} value={modelo.id}>
{modelo.nombre}
</option>
))}
</select>
<EntradaFila
etiqueta={`Precio de lista de la fila ${indice + 1}`}
valor={fila.precioLista}
tipo="number"
onChange={(valor) =>
setFilas((prev) =>
prev.map((f, i) => (i === indice ? { ...f, precioLista: valor } : f)),
)
}
/>
<EntradaFila
etiqueta={`Precio con acabados de la fila ${indice + 1}`}
valor={fila.precioAcabados}
tipo="number"
onChange={(valor) =>
setFilas((prev) =>
prev.map((f, i) => (i === indice ? { ...f, precioAcabados: valor } : f)),
)
}
/>
<EntradaFila
etiqueta={`Superficie de construcción de la fila ${indice + 1}`}
valor={fila.supConstruccion}
tipo="number"
onChange={(valor) =>
setFilas((prev) =>
prev.map((f, i) => (i === indice ? { ...f, supConstruccion: valor } : f)),
)
}
/>
<button
type="button"
onClick={() => setFilas((prev) => prev.filter((_, i) => i !== indice))}
disabled={filas.length === 1}
aria-label={`Quitar fila ${indice + 1}`}
className={BOTON_MINIMO}
>
Quitar
</button>
</div>
))}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="button"
onClick={() => setFilas((prev) => [...prev, filaVacia()])}
className={BOTON_SECUNDARIO}
>
+ Agregar fila
</button>
<button
type="button"
onClick={() => void guardarManual()}
disabled={enviando}
className={BOTON_PRIMARIO}
>
{enviando ? 'Guardando…' : 'Guardar unidades'}
</button>
</div>
</div>
)}
{error && <div className="mt-3"><MensajeError>{error}</MensajeError></div>}
{exito && <div className="mt-3"><MensajeExito>{exito}</MensajeExito></div>}
</section>
<section className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Inventario existente
</h2>
{Object.keys(conteos).length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2">
{Object.entries(conteos).map(([estatus, cantidad]) => (
<span key={estatus} className="flex items-center gap-1.5">
<BadgeEstatus estatus={estatus} />
<span className="text-xs font-medium text-[#475569] dark:text-[#CBD5E1]">{cantidad}</span>
</span>
))}
</div>
)}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div>
<label htmlFor="inv-buscar" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Buscar unidad
</label>
<input
id="inv-buscar"
type="search"
value={busqueda}
onChange={(e) => {
setBusqueda(e.target.value)
setPagina(1)
}}
placeholder="Código o modelo…"
className={INPUT}
/>
</div>
<div>
<label htmlFor="inv-estatus" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Estatus
</label>
<select
id="inv-estatus"
value={filtroEstatus}
onChange={(e) => {
setFiltroEstatus(e.target.value)
setPagina(1)
}}
className={INPUT}
>
<option value="">Todos los estatus</option>
{ESTATUS_UNIDAD.map((estatus) => (
<option key={estatus} value={estatus}>
{estatus}
</option>
))}
</select>
</div>
</div>
{avisoUnidades && <div className="mt-3"><MensajeError>{avisoUnidades}</MensajeError></div>}
<div className="mt-4">
{cargandoUnidades ? (
<div className="space-y-3 py-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-2/3" />
</div>
))}
</div>
) : visibles.length === 0 ? (
<p className="py-6 text-center text-sm text-[#64748B] dark:text-[#94A3B8]">
{unidades.length === 0
? 'Aún no hay unidades en este desarrollo.'
: 'Ninguna unidad coincide con el filtro.'}
</p>
) : (
<ul className="divide-y divide-[#E5E7EB] dark:divide-[#334155]">
{visibles.map((unidad) => (
<li
key={unidad.codigo}
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="font-mono text-sm text-[#111827] dark:text-[#E2E8F0]">
{unidad.codigo}
</p>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">
{[
unidad.modelo,
unidad.nivel ? `Nivel ${unidad.nivel}` : null,
unidad.supConstruccion != null ? `${unidad.supConstruccion} m²` : null,
unidad.fechaEntrega ? `Entrega ${unidad.fechaEntrega}` : null,
`${moneda(unidad.precioSinAcabados ?? unidad.precioLista)} lista`,
`${moneda(unidad.precioConAcabados)} c/acabados`,
]
.filter(Boolean)
.join(' · ')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<BadgeEstatus estatus={estatusDe(unidad)} />
<button
type="button"
onClick={() => void borrarUnidad(unidad)}
aria-label={`Eliminar unidad ${unidad.codigo}`}
className={BOTON_PELIGRO}
>
Eliminar
</button>
</div>
</li>
))}
</ul>
)}
</div>
{filtradas.length > UNIDADES_POR_PAGINA && (
<div className="mt-4 flex flex-col items-center gap-2 sm:flex-row sm:justify-between">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Mostrando {(paginaActual - 1) * UNIDADES_POR_PAGINA + 1}–
{Math.min(paginaActual * UNIDADES_POR_PAGINA, filtradas.length)} de {filtradas.length} unidades
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPagina(paginaActual - 1)}
disabled={paginaActual === 1}
className={BOTON_SECUNDARIO}
>
Anterior
</button>
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Página {paginaActual} de {totalPaginas}
</span>
<button
type="button"
onClick={() => setPagina(paginaActual + 1)}
disabled={paginaActual === totalPaginas}
className={BOTON_SECUNDARIO}
>
Siguiente
</button>
</div>
</div>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,360 @@
import { useEffect, useState, type FormEvent } from 'react'
import {
actualizarModelo,
crearModelo,
eliminarModelo,
listarUnidades,
type DesarrolloFicha,
type ModeloDesarrollo,
} from '../../services/desarrollos'
import {
aNumero,
aTexto,
BOTON_MINIMO,
BOTON_PELIGRO,
BOTON_PRIMARIO,
BOTON_SECUNDARIO,
Campo,
INPUT,
INPUT_ERROR,
MensajeError,
MensajeExito,
TARJETA,
} from './comun'
interface Props {
desarrollo: DesarrolloFicha
onRefrescar: () => Promise<void> | void
}
interface FormModeloEstado {
nombre: string
recamaras: string
banos: string
estacionamientos: string
supConstruccion: string
activo: boolean
}
const estadoVacio: FormModeloEstado = {
nombre: '',
recamaras: '',
banos: '',
estacionamientos: '',
supConstruccion: '',
activo: true,
}
function estadoDesdeModelo(modelo: ModeloDesarrollo): FormModeloEstado {
return {
nombre: modelo.nombre,
recamaras: aTexto(modelo.recamaras),
banos: aTexto(modelo.banos),
estacionamientos: aTexto(modelo.estacionamientos),
supConstruccion: aTexto(modelo.supConstruccion),
activo: modelo.activo,
}
}
/** Alta y baja de modelos de un desarrollo */
export function TabModelos({ desarrollo, onRefrescar }: Props) {
const [nuevo, setNuevo] = useState<FormModeloEstado>(estadoVacio)
const [formAbierto, setFormAbierto] = useState(false)
const [editandoId, setEditandoId] = useState<string | null>(null)
const [edicion, setEdicion] = useState<FormModeloEstado>(estadoVacio)
const [error, setError] = useState('')
const [exito, setExito] = useState('')
const [guardando, setGuardando] = useState(false)
const modelos = desarrollo.modelos ?? []
// Unidades por modelo: se leen del inventario para poder avisar antes de borrar
const [conteoPorModelo, setConteoPorModelo] = useState<Record<string, number>>({})
useEffect(() => {
let cancelado = false
listarUnidades(desarrollo.clave)
.then((unidades) => {
if (cancelado) return
const conteo: Record<string, number> = {}
for (const unidad of unidades) {
if (!unidad.modelo) continue
conteo[unidad.modelo] = (conteo[unidad.modelo] ?? 0) + 1
}
setConteoPorModelo(conteo)
})
.catch(() => {
// Sin inventario disponible el borrado sigue avisando con el 409 del API
})
return () => {
cancelado = true
}
}, [desarrollo.clave])
const unidadesDeModelo = (nombre: string) => conteoPorModelo[nombre] ?? 0
const crear = async (evento: FormEvent) => {
evento.preventDefault()
setError('')
setExito('')
if (nuevo.nombre.trim().length < 1) {
setError('Escribe el nombre del modelo.')
return
}
setGuardando(true)
try {
await crearModelo(desarrollo.id, {
nombre: nuevo.nombre.trim(),
recamaras: aNumero(nuevo.recamaras),
banos: aNumero(nuevo.banos),
estacionamientos: aNumero(nuevo.estacionamientos),
supConstruccion: aNumero(nuevo.supConstruccion),
activo: nuevo.activo,
})
setNuevo(estadoVacio)
setFormAbierto(false)
setExito(`Modelo "${nuevo.nombre.trim()}" agregado.`)
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudo crear el modelo.')
} finally {
setGuardando(false)
}
}
const guardarEdicion = async (evento: FormEvent, modeloId: string) => {
evento.preventDefault()
setError('')
setExito('')
if (edicion.nombre.trim().length < 1) {
setError('Escribe el nombre del modelo.')
return
}
setGuardando(true)
try {
await actualizarModelo(modeloId, {
nombre: edicion.nombre.trim(),
recamaras: aNumero(edicion.recamaras),
banos: aNumero(edicion.banos),
estacionamientos: aNumero(edicion.estacionamientos),
supConstruccion: aNumero(edicion.supConstruccion),
activo: edicion.activo,
})
setEditandoId(null)
setExito('Modelo actualizado.')
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudo actualizar el modelo.')
} finally {
setGuardando(false)
}
}
const borrar = async (modelo: ModeloDesarrollo) => {
const conUnidades = unidadesDeModelo(modelo.nombre)
const aviso =
conUnidades > 0
? `El modelo "${modelo.nombre}" tiene ${conUnidades} unidades registradas. ¿Eliminarlo de todos modos?`
: `¿Eliminar el modelo "${modelo.nombre}"?`
if (typeof window !== 'undefined' && !window.confirm(aviso)) return
setError('')
setExito('')
try {
await eliminarModelo(modelo.id)
setExito(`Modelo "${modelo.nombre}" eliminado.`)
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudo eliminar el modelo.')
}
}
const campos = (
valores: FormModeloEstado,
setValores: (fn: (prev: FormModeloEstado) => FormModeloEstado) => void,
prefijo: string,
) => (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Campo id={`${prefijo}-nombre`} etiqueta="Nombre" requerido>
<input
id={`${prefijo}-nombre`}
type="text"
value={valores.nombre}
onChange={(e) => setValores((prev) => ({ ...prev, nombre: e.target.value }))}
placeholder="2RA"
className={`${INPUT} ${valores.nombre.trim() === '' ? INPUT_ERROR : ''}`}
/>
</Campo>
{(
[
['recamaras', 'Recámaras'],
['banos', 'Baños'],
['estacionamientos', 'Estacionamientos'],
['supConstruccion', 'Superficie (m²)'],
] as const
).map(([campo, etiqueta]) => (
<Campo key={campo} id={`${prefijo}-${campo}`} etiqueta={etiqueta}>
<input
id={`${prefijo}-${campo}`}
type="number"
min={0}
step="any"
value={valores[campo]}
onChange={(e) => setValores((prev) => ({ ...prev, [campo]: e.target.value }))}
className={INPUT}
/>
</Campo>
))}
<Campo id={`${prefijo}-activo`} etiqueta="Estado del modelo">
<select
id={`${prefijo}-activo`}
value={valores.activo ? 'activo' : 'inactivo'}
onChange={(e) => setValores((prev) => ({ ...prev, activo: e.target.value === 'activo' }))}
className={INPUT}
>
<option value="activo">Activo</option>
<option value="inactivo">Inactivo</option>
</select>
</Campo>
</div>
)
return (
<div className="space-y-4">
<section className={`${TARJETA} p-4`}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Modelos</h2>
<button
type="button"
onClick={() => {
setFormAbierto((v) => !v)
setError('')
setExito('')
}}
className={BOTON_MINIMO}
>
{formAbierto ? 'Cerrar' : '+ Nuevo modelo'}
</button>
</div>
{formAbierto && (
<form onSubmit={crear} className="mt-4 space-y-3">
{campos(nuevo, setNuevo, 'modelo-nuevo')}
<div className="flex items-center gap-3">
<button type="submit" disabled={guardando} className={BOTON_PRIMARIO}>
{guardando ? 'Guardando…' : 'Agregar modelo'}
</button>
<button
type="button"
onClick={() => {
setFormAbierto(false)
setNuevo(estadoVacio)
setError('')
}}
className={BOTON_SECUNDARIO}
>
Cancelar
</button>
</div>
</form>
)}
{error && <div className="mt-3"><MensajeError>{error}</MensajeError></div>}
{exito && <div className="mt-3"><MensajeExito>{exito}</MensajeExito></div>}
</section>
<section className={`${TARJETA} overflow-hidden`}>
{modelos.length === 0 ? (
<p className="p-6 text-center text-sm text-[#64748B] dark:text-[#94A3B8]">
Este desarrollo aún no tiene modelos.
</p>
) : (
<ul className="divide-y divide-[#E5E7EB] dark:divide-[#334155]">
{modelos.map((modelo) => {
const conUnidades = unidadesDeModelo(modelo.nombre)
return (
<li key={modelo.id} className="p-4">
{editandoId === modelo.id ? (
<form
onSubmit={(e) => void guardarEdicion(e, modelo.id)}
className="space-y-3"
aria-label={`Editar modelo ${modelo.nombre}`}
>
{campos(edicion, setEdicion, `modelo-${modelo.id}`)}
<div className="flex items-center gap-3">
<button type="submit" disabled={guardando} className={BOTON_PRIMARIO}>
{guardando ? 'Guardando…' : 'Guardar'}
</button>
<button
type="button"
onClick={() => setEditandoId(null)}
className={BOTON_SECUNDARIO}
>
Cancelar
</button>
</div>
</form>
) : (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{modelo.nombre}
{!modelo.activo && (
<span className="ml-2 rounded-full bg-[#F1F5F9] px-2 py-0.5 text-[11px] font-medium text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]">
Inactivo
</span>
)}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{[
modelo.recamaras != null ? `${modelo.recamaras} rec.` : null,
modelo.banos != null ? `${modelo.banos} baños` : null,
modelo.estacionamientos != null
? `${modelo.estacionamientos} estac.`
: null,
modelo.supConstruccion != null
? `${modelo.supConstruccion} m²`
: null,
]
.filter(Boolean)
.join(' · ') || 'Sin medidas capturadas'}
</p>
{conUnidades > 0 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
Tiene {conUnidades} unidades en el inventario.
</p>
)}
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => {
setEditandoId(modelo.id)
setEdicion(estadoDesdeModelo(modelo))
setError('')
setExito('')
}}
aria-label={`Editar modelo ${modelo.nombre}`}
className={BOTON_MINIMO}
>
Editar
</button>
<button
type="button"
onClick={() => void borrar(modelo)}
aria-label={`Eliminar modelo ${modelo.nombre}`}
className={BOTON_PELIGRO}
>
Eliminar
</button>
</div>
</div>
)}
</li>
)
})}
</ul>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,558 @@
import { useEffect, useState } from 'react'
import {
guardarReglas,
obtenerReglasEfectivas,
type BasePrecio,
type DesarrolloFicha,
type FuenteDeN,
type ModoMensualidades,
type PropuestaReglas,
type ReglasCotizador,
} from '../../services/desarrollos'
import {
BOTON_PRIMARIO,
Campo,
ETIQUETA_FUENTE_N,
INPUT,
MensajeError,
MensajeExito,
TARJETA,
} from './comun'
import {
completarReglas,
fraccionDePct,
formatearPct,
PROPUESTAS_BASE,
propuestasEditables,
propuestasHabilitadas,
pctDeFraccion,
resumenPropuesta,
} from './reglas'
interface Props {
desarrollo: DesarrolloFicha
onRefrescar: () => Promise<void> | void
}
/** Propuesta con los porcentajes en texto para poder escribirlos en % */
interface PropuestaEditable {
tipo: string
claveDescuento: string
titulo: string
descuentoDefault: string
pctEnganche: string
engancheBase: BasePrecio
pctPagoFinal: string
pagoFinalBase: BasePrecio
modoMensualidades: ModoMensualidades
engancheCeroSinApartado: boolean
}
interface GeneralesEditable {
precioUnico: boolean
fuenteDeN: FuenteDeN
nFijo: string
nDefault: string
cuotaMantenimiento: string
prefijoUnidadPdf: string
}
const MODOS: ModoMensualidades[] = ['remanente', 'remanenteLista', 'porcentaje', 'ninguna']
const ETIQUETA_MODO: Record<ModoMensualidades, string> = {
remanente: 'Remanente (precio final − apartado − enganche − pago final)',
remanenteLista: 'Remanente sobre precio de lista',
porcentaje: 'Porcentaje del precio final',
ninguna: 'Sin mensualidades',
}
const ETIQUETA_BASE_OPCION: Record<BasePrecio, string> = {
lista: 'Precio de lista',
final: 'Precio final',
}
const aPropuestaEditable = (p: PropuestaReglas): PropuestaEditable => ({
tipo: p.tipo,
claveDescuento: p.claveDescuento,
titulo: p.titulo,
descuentoDefault: formatearPct(pctDeFraccion(p.descuentoDefault)),
pctEnganche: formatearPct(pctDeFraccion(p.pctEnganche)),
engancheBase: p.engancheBase,
pctPagoFinal: formatearPct(pctDeFraccion(p.pctPagoFinal)),
pagoFinalBase: p.pagoFinalBase,
modoMensualidades: p.modoMensualidades,
engancheCeroSinApartado: p.engancheCeroSinApartado === true,
})
const comoFraccion = (valor: string) => fraccionDePct(Number(valor.trim() === '' ? 0 : valor))
/** Resumen legible usando el mismo formateador del guardado */
const resumenDe = (p: PropuestaEditable) =>
resumenPropuesta({
tipo: p.tipo,
claveDescuento: p.claveDescuento,
titulo: p.titulo,
descuentoDefault: comoFraccion(p.descuentoDefault),
pctEnganche: comoFraccion(p.pctEnganche),
engancheBase: p.engancheBase,
pctPagoFinal: comoFraccion(p.pctPagoFinal),
pagoFinalBase: p.pagoFinalBase,
modoMensualidades: p.modoMensualidades,
})
function generalesDe(reglas: ReglasCotizador): GeneralesEditable {
return {
precioUnico: reglas.precioUnico,
fuenteDeN: reglas.fuenteDeN,
nFijo: reglas.nFijo != null ? String(reglas.nFijo) : '',
nDefault: String(reglas.nDefault ?? 0),
cuotaMantenimiento: String(reglas.cuotaMantenimiento ?? 0),
prefijoUnidadPdf: reglas.prefijoUnidadPdf ?? '',
}
}
function propuestasDe(reglas: ReglasCotizador | null): Record<string, PropuestaEditable> {
const base = propuestasEditables(reglas)
const editables: Record<string, PropuestaEditable> = {}
for (const [tipo, propuesta] of Object.entries(base)) {
editables[tipo] = aPropuestaEditable(propuesta)
}
return editables
}
/** Editor de las reglas del cotizador del desarrollo */
export function TabReglas({ desarrollo, onRefrescar }: Props) {
// Un desarrollo sin reglas guardadas arranca con las reglas VIGENTES del
// cotizador (GET /desarrollos/reglas-cotizador) para no recapturar a mano.
const [reglasVigentes, setReglasVigentes] = useState<ReglasCotizador | null>(null)
const [cargandoVigentes, setCargandoVigentes] = useState(desarrollo.reglas === null)
const reglasBase = desarrollo.reglas ?? reglasVigentes
const [generales, setGenerales] = useState<GeneralesEditable>(() =>
generalesDe(completarReglas(desarrollo.reglas)),
)
const [habilitadas, setHabilitadas] = useState<Record<string, boolean>>(() =>
propuestasHabilitadas(desarrollo.reglas),
)
const [propuestas, setPropuestas] = useState<Record<string, PropuestaEditable>>(() =>
propuestasDe(desarrollo.reglas),
)
const [error, setError] = useState('')
const [exito, setExito] = useState('')
const [guardando, setGuardando] = useState(false)
useEffect(() => {
if (desarrollo.reglas !== null) {
setReglasVigentes(null)
setCargandoVigentes(false)
return
}
let cancelado = false
setCargandoVigentes(true)
obtenerReglasEfectivas()
.then((mapa) => {
if (!cancelado) setReglasVigentes(mapa[desarrollo.clave] ?? null)
})
.catch(() => {
// Sin reglas vigentes se cae a los valores por defecto del editor
})
.finally(() => {
if (!cancelado) setCargandoVigentes(false)
})
return () => {
cancelado = true
}
}, [desarrollo.clave, desarrollo.reglas])
// Si la ficha se recarga (p. ej. tras guardar), se refresca el editor
useEffect(() => {
setGenerales(generalesDe(completarReglas(reglasBase)))
setHabilitadas(propuestasHabilitadas(reglasBase))
setPropuestas(propuestasDe(reglasBase))
}, [reglasBase])
const setPropuesta = (tipo: string, cambios: Partial<PropuestaEditable>) =>
setPropuestas((prev) => ({ ...prev, [tipo]: { ...prev[tipo]!, ...cambios } }))
const activas = PROPUESTAS_BASE.filter((base) => habilitadas[base.tipo])
if (cargandoVigentes && desarrollo.reglas === null) {
return (
<div className={`${TARJETA} p-6`}>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando reglas del cotizador…</p>
</div>
)
}
const guardar = async () => {
setError('')
setExito('')
if (activas.length === 0) {
setError('Activa al menos un esquema de cotización.')
return
}
if (generales.prefijoUnidadPdf.trim() === '') {
setError('Escribe el prefijo de unidad que se imprime en el PDF (CASA, DEPTO…).')
return
}
for (const base of activas) {
const p = propuestas[base.tipo]!
const suma = Number(p.pctEnganche || 0) + Number(p.pctPagoFinal || 0)
if (!Number.isFinite(suma) || suma > 100) {
setError(
`En "${p.titulo}" el enganche más el pago final no pueden pasar del 100% (hoy suma ${suma}%).`,
)
return
}
if (Number(p.pctEnganche || 0) < 0 || Number(p.pctPagoFinal || 0) < 0) {
setError(`En "${p.titulo}" los porcentajes no pueden ser negativos.`)
return
}
}
const nFijo = Number(generales.nFijo)
const reglas: ReglasCotizador = {
precioUnico: generales.precioUnico,
fuenteDeN: generales.fuenteDeN,
...(generales.fuenteDeN === 'fijo' && generales.nFijo.trim() !== '' && Number.isFinite(nFijo)
? { nFijo: Math.max(0, Math.round(nFijo)) }
: {}),
nDefault: Math.max(0, Math.round(Number(generales.nDefault || 0))),
cuotaMantenimiento: Math.max(0, Number(generales.cuotaMantenimiento || 0)),
prefijoUnidadPdf: generales.prefijoUnidadPdf.trim(),
propuestas: activas.map((base) => {
const p = propuestas[base.tipo]!
return {
tipo: p.tipo,
claveDescuento: p.claveDescuento,
titulo: p.titulo.trim() || base.titulo,
descuentoDefault: comoFraccion(p.descuentoDefault),
pctEnganche: comoFraccion(p.pctEnganche),
engancheBase: p.engancheBase,
pctPagoFinal: comoFraccion(p.pctPagoFinal),
pagoFinalBase: p.pagoFinalBase,
modoMensualidades: p.modoMensualidades,
...(p.engancheCeroSinApartado ? { engancheCeroSinApartado: true } : {}),
}
}),
}
setGuardando(true)
try {
await guardarReglas(desarrollo.id, reglas)
setExito('Reglas guardadas. El cotizador las usará en las próximas propuestas.')
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudieron guardar las reglas.')
} finally {
setGuardando(false)
}
}
return (
<div className="space-y-4">
<section className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Parámetros generales
</h2>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Estos valores los usa el cotizador al armar las propuestas de este desarrollo.
</p>
{desarrollo.reglas === null && !cargandoVigentes && reglasVigentes !== null && (
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-400">
Este desarrollo no tiene reglas guardadas: se muestran los valores que el cotizador usa
hoy. Al guardar quedarán registrados en la base.
</p>
)}
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Campo
id="reglas-fuente-n"
etiqueta="Fuente de N (mensualidades)"
ayuda="De dónde sale el número de mensualidades de la propuesta."
>
<select
id="reglas-fuente-n"
value={generales.fuenteDeN}
onChange={(e) =>
setGenerales((prev) => ({ ...prev, fuenteDeN: e.target.value as FuenteDeN }))
}
className={INPUT}
>
{(Object.keys(ETIQUETA_FUENTE_N) as FuenteDeN[]).map((fuente) => (
<option key={fuente} value={fuente}>
{ETIQUETA_FUENTE_N[fuente]}
</option>
))}
</select>
</Campo>
<Campo
id="reglas-n-default"
etiqueta="N por defecto"
ayuda="Se usa cuando la unidad no trae fecha de entrega ni meses para firma."
>
<input
id="reglas-n-default"
type="number"
min={0}
value={generales.nDefault}
onChange={(e) => setGenerales((prev) => ({ ...prev, nDefault: e.target.value }))}
className={INPUT}
/>
</Campo>
{generales.fuenteDeN === 'fijo' && (
<Campo
id="reglas-n-fijo"
etiqueta="N fijo"
ayuda="Mensualidades que se usan cuando la fuente de N es fija (entrega inmediata)."
>
<input
id="reglas-n-fijo"
type="number"
min={0}
value={generales.nFijo}
onChange={(e) => setGenerales((prev) => ({ ...prev, nFijo: e.target.value }))}
placeholder="1"
className={INPUT}
/>
</Campo>
)}
<Campo id="reglas-cuota" etiqueta="Cuota de mantenimiento (MXN)">
<input
id="reglas-cuota"
type="number"
min={0}
value={generales.cuotaMantenimiento}
onChange={(e) =>
setGenerales((prev) => ({ ...prev, cuotaMantenimiento: e.target.value }))
}
className={INPUT}
/>
</Campo>
<Campo
id="reglas-prefijo"
etiqueta="Prefijo de unidad en el PDF"
ayuda="Cómo se nombra la unidad en la cotización impresa (CASA, DEPTO…)."
>
<input
id="reglas-prefijo"
type="text"
value={generales.prefijoUnidadPdf}
onChange={(e) =>
setGenerales((prev) => ({ ...prev, prefijoUnidadPdf: e.target.value }))
}
className={INPUT}
/>
</Campo>
<div className="flex items-end">
<label
htmlFor="reglas-precio-unico"
className="flex items-center gap-2 text-sm text-[#374151] dark:text-[#CBD5E1]"
>
<input
id="reglas-precio-unico"
type="checkbox"
checked={generales.precioUnico}
onChange={(e) => setGenerales((prev) => ({ ...prev, precioUnico: e.target.checked }))}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Precio único (sin distinguir acabados)
</label>
</div>
</div>
</section>
{PROPUESTAS_BASE.map((base) => {
const p = propuestas[base.tipo]!
const activa = habilitadas[base.tipo]
return (
<section
key={base.tipo}
className={`${TARJETA} p-4 ${activa ? '' : 'opacity-70'}`}
aria-labelledby={`reglas-titulo-${base.tipo}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<h3
id={`reglas-titulo-${base.tipo}`}
className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]"
>
{p.titulo}
</h3>
<label
htmlFor={`reglas-activa-${base.tipo}`}
className="flex items-center gap-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]"
>
<input
id={`reglas-activa-${base.tipo}`}
type="checkbox"
checked={activa}
onChange={(e) =>
setHabilitadas((prev) => ({ ...prev, [base.tipo]: e.target.checked }))
}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Propuesta activa
</label>
</div>
<p className="mt-2 rounded-lg bg-[#F9FAFB] px-3 py-2 text-xs text-[#475569] dark:bg-[#0F172A] dark:text-[#CBD5E1]">
{resumenDe(p)}
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Campo id={`reglas-titulo-campo-${base.tipo}`} etiqueta="Título en la propuesta">
<input
id={`reglas-titulo-campo-${base.tipo}`}
type="text"
value={p.titulo}
onChange={(e) => setPropuesta(base.tipo, { titulo: e.target.value })}
className={INPUT}
/>
</Campo>
<Campo
id={`reglas-enganche-${base.tipo}`}
etiqueta="Enganche (%)"
>
<input
id={`reglas-enganche-${base.tipo}`}
type="number"
min={0}
step="any"
value={p.pctEnganche}
onChange={(e) => setPropuesta(base.tipo, { pctEnganche: e.target.value })}
className={INPUT}
/>
</Campo>
<Campo id={`reglas-base-enganche-${base.tipo}`} etiqueta="Base del enganche">
<select
id={`reglas-base-enganche-${base.tipo}`}
value={p.engancheBase}
onChange={(e) =>
setPropuesta(base.tipo, { engancheBase: e.target.value as BasePrecio })
}
className={INPUT}
>
{(['lista', 'final'] as BasePrecio[]).map((opcion) => (
<option key={opcion} value={opcion}>
{ETIQUETA_BASE_OPCION[opcion]}
</option>
))}
</select>
</Campo>
<Campo id={`reglas-pago-final-${base.tipo}`} etiqueta="Pago final (%)">
<input
id={`reglas-pago-final-${base.tipo}`}
type="number"
min={0}
step="any"
value={p.pctPagoFinal}
onChange={(e) => setPropuesta(base.tipo, { pctPagoFinal: e.target.value })}
className={INPUT}
/>
</Campo>
<Campo id={`reglas-base-pago-${base.tipo}`} etiqueta="Base del pago final">
<select
id={`reglas-base-pago-${base.tipo}`}
value={p.pagoFinalBase}
onChange={(e) =>
setPropuesta(base.tipo, { pagoFinalBase: e.target.value as BasePrecio })
}
className={INPUT}
>
{(['lista', 'final'] as BasePrecio[]).map((opcion) => (
<option key={opcion} value={opcion}>
{ETIQUETA_BASE_OPCION[opcion]}
</option>
))}
</select>
</Campo>
<Campo id={`reglas-descuento-${base.tipo}`} etiqueta="Descuento por defecto (%)">
<input
id={`reglas-descuento-${base.tipo}`}
type="number"
min={0}
step="any"
value={p.descuentoDefault}
onChange={(e) => setPropuesta(base.tipo, { descuentoDefault: e.target.value })}
className={INPUT}
/>
</Campo>
<Campo
id={`reglas-modo-${base.tipo}`}
etiqueta="Mensualidades"
className="sm:col-span-2 lg:col-span-3"
>
<select
id={`reglas-modo-${base.tipo}`}
value={p.modoMensualidades}
onChange={(e) =>
setPropuesta(base.tipo, {
modoMensualidades: e.target.value as ModoMensualidades,
})
}
className={INPUT}
>
{MODOS.map((modo) => (
<option key={modo} value={modo}>
{ETIQUETA_MODO[modo]}
</option>
))}
</select>
</Campo>
<div className="flex items-end sm:col-span-2 lg:col-span-3">
<label
htmlFor={`reglas-cero-apartado-${base.tipo}`}
className="flex items-center gap-2 text-sm text-[#374151] dark:text-[#CBD5E1]"
>
<input
id={`reglas-cero-apartado-${base.tipo}`}
type="checkbox"
checked={p.engancheCeroSinApartado}
onChange={(e) =>
setPropuesta(base.tipo, { engancheCeroSinApartado: e.target.checked })
}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Con enganche 0% no se descuenta el apartado (Infonavit)
</label>
</div>
</div>
</section>
)
})}
<section className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Resumen para el cotizador
</h2>
{activas.length === 0 ? (
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
No hay esquemas activos: el cotizador no mostrará propuestas para este desarrollo.
</p>
) : (
<dl className="mt-3 space-y-2">
{activas.map((base) => (
<div key={base.tipo}>
<dt className="text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
{propuestas[base.tipo]!.titulo}
</dt>
<dd className="text-sm text-[#111827] dark:text-[#E2E8F0]">
{resumenDe(propuestas[base.tipo]!)}
</dd>
</div>
))}
</dl>
)}
<div className="mt-4 flex flex-col gap-3">
<button
type="button"
onClick={() => void guardar()}
disabled={guardando}
className={`${BOTON_PRIMARIO} self-start`}
>
{guardando ? 'Guardando…' : 'Guardar reglas'}
</button>
{error && <MensajeError>{error}</MensajeError>}
{exito && <MensajeExito>{exito}</MensajeExito>}
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import type { DesarrolloResumen } from '../../services/desarrollos'
import { BOTON_MINIMO, BadgeEstado, Chip, ETIQUETA_TIPO, TARJETA } from './comun'
interface Props {
desarrollo: DesarrolloResumen
onAbrir: (desarrollo: DesarrolloResumen) => void
onEditar: (desarrollo: DesarrolloResumen) => void
}
/** Tarjeta del listado: al hacer clic (o Enter) abre la ficha del desarrollo */
export function TarjetaDesarrollo({ desarrollo, onAbrir, onEditar }: Props) {
const disponibles = desarrollo.unidadesDisponibles ?? 0
const totales = desarrollo.unidadesTotales ?? 0
const modelos = desarrollo.modelos ?? []
return (
<article className={`relative flex flex-col ${TARJETA} p-4`}>
<button
type="button"
onClick={() => onAbrir(desarrollo)}
aria-label={`Abrir ficha de ${desarrollo.nombre}`}
className="absolute inset-0 rounded-xl"
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
{desarrollo.nombre}
</h3>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">
{desarrollo.clave} · {ETIQUETA_TIPO[desarrollo.tipo] ?? desarrollo.tipo}
</p>
</div>
<BadgeEstado estado={desarrollo.estado} />
</div>
<dl className="mt-3 grid grid-cols-2 gap-3">
<div>
<dt className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">Unidades</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{totales}</dd>
</div>
<div>
<dt className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">Disponibles</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{disponibles}</dd>
</div>
</dl>
<div className="mt-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8] dark:text-[#64748B]">
Modelos
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{modelos.length === 0 ? (
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">Sin modelos</span>
) : (
modelos.map((modelo) => (
<Chip key={modelo.id ?? modelo.nombre}>{modelo.nombre}</Chip>
))
)}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{desarrollo.activoEnCotizador ? (
<Chip>En cotizador</Chip>
) : (
<Chip>Oculto en cotizador</Chip>
)}
{desarrollo.activoEnAlta ? <Chip>En altas</Chip> : <Chip>Oculto en altas</Chip>}
{!desarrollo.tieneReglas && <Chip>Sin reglas del cotizador</Chip>}
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">Ver ficha →</span>
<button
type="button"
onClick={() => onEditar(desarrollo)}
aria-label={`Editar ${desarrollo.nombre}`}
className={`relative z-10 ${BOTON_MINIMO}`}
>
Editar
</button>
</div>
</article>
)
}

View File

@@ -0,0 +1,189 @@
import type { ReactNode } from 'react'
import type { EstadoDesarrollo, TipoDesarrollo } from '../../services/desarrollos'
// ─── Clases compartidas (mismo lenguaje visual que el resto de la landing) ──
export const TARJETA =
'rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]'
export const INPUT =
'mt-1.5 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
export const INPUT_ERROR = 'border-red-500 dark:border-red-500'
export const LABEL = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
export const AYUDA = 'mt-1 text-[11px] text-[#64748B] dark:text-[#94A3B8]'
export const TEXTO_ERROR = 'mt-1 text-xs text-red-600 dark:text-red-400'
export const BOTON_PRIMARIO =
'rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50'
export const BOTON_SECUNDARIO =
'rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]'
export const BOTON_PELIGRO =
'rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50 dark:border-red-500/40 dark:bg-[#0F172A] dark:text-red-400 dark:hover:bg-red-500/10'
export const BOTON_MINIMO =
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]'
// ─── Etiquetas de dominio ───────────────────────────────────────────
export const ETIQUETA_ESTADO: Record<EstadoDesarrollo, string> = {
ACTIVO: 'Activo',
PREVENTA: 'Preventa',
CERRADO: 'Cerrado',
}
const CLASE_ESTADO: Record<EstadoDesarrollo, string> = {
ACTIVO: 'bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400',
PREVENTA: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
CERRADO: 'bg-[#F1F5F9] text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]',
}
export const ETIQUETA_TIPO: Record<TipoDesarrollo, string> = {
VERTICAL: 'Vertical',
HORIZONTAL: 'Horizontal',
}
/** Base sobre la que se calcula un componente del precio ('lista' | 'final') */
export const ETIQUETA_BASE = { lista: 'precio de lista', final: 'precio final' } as const
export const ETIQUETA_MODO_MENSUALIDADES = {
remanente: 'sobre el remanente',
remanenteLista: 'sobre el remanente del precio de lista',
porcentaje: 'sobre el remanente en porcentaje',
ninguna: 'sin mensualidades',
} as const
export const ETIQUETA_FUENTE_N = {
fechaEntrega: 'Fecha de entrega de la unidad',
fijo: 'N fijo (entrega inmediata / terminación de obra)',
porUnidad: 'Capturado por unidad',
} as const
/** Estatus posibles del inventario (los mismos que maneja el cotizador) */
export const ESTATUS_UNIDAD = [
'DISPONIBLE',
'APARTADO',
'APARTADA',
'VENDIDO',
'VENDIDA',
'ESCRITURADO',
'BLOQUEADO',
] as const
const CLASE_ESTATUS: Record<string, string> = {
DISPONIBLE: 'bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400',
APARTADO: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
APARTADA: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
VENDIDO: 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400',
VENDIDA: 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400',
ESCRITURADO: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400',
BLOQUEADO: 'bg-[#F1F5F9] text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]',
}
export function BadgeEstado({ estado }: { estado: EstadoDesarrollo }) {
return (
<span
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[estado] ?? CLASE_ESTATUS.BLOQUEADO}`}
>
{ETIQUETA_ESTADO[estado] ?? estado}
</span>
)
}
export function BadgeEstatus({ estatus }: { estatus: string | null | undefined }) {
if (!estatus) return <span className="text-xs text-[#94A3B8]">—</span>
return (
<span
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTATUS[estatus] ?? 'bg-[#F1F5F9] text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]'}`}
>
{estatus}
</span>
)
}
export function Chip({ children }: { children: ReactNode }) {
return (
<span className="rounded-full bg-[#F1F5F9] px-2.5 py-0.5 text-[11px] font-medium text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]">
{children}
</span>
)
}
// ─── Piezas de formulario ───────────────────────────────────────────
export function Campo({
id,
etiqueta,
requerido = false,
ayuda,
error,
className = '',
children,
}: {
id: string
etiqueta: string
requerido?: boolean
ayuda?: string
error?: string
className?: string
children: ReactNode
}) {
return (
<div className={className}>
<label htmlFor={id} className={LABEL}>
{etiqueta}
{requerido && <span aria-hidden="true"> *</span>}
</label>
{children}
{ayuda && <p className={AYUDA}>{ayuda}</p>}
{error && (
<p role="alert" className={TEXTO_ERROR}>
{error}
</p>
)}
</div>
)
}
export function MensajeError({ children }: { children: ReactNode }) {
return (
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-400">
{children}
</p>
)
}
export function MensajeExito({ children }: { children: ReactNode }) {
return (
<p role="status" className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-400">
{children}
</p>
)
}
/** Formato de moneda para precios de inventario */
export function moneda(valor: number | null | undefined): string {
if (valor == null) return '—'
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
maximumFractionDigits: 0,
}).format(valor)
}
/** Convierte '' | null | undefined a null y el resto a número (NaN → null) */
export function aNumero(valor: string): number | null {
const limpio = valor.trim()
if (limpio === '') return null
const n = Number(limpio)
return Number.isFinite(n) ? n : null
}
export function aTexto(valor: number | null | undefined): string {
return valor == null ? '' : String(valor)
}

View File

@@ -0,0 +1,46 @@
/**
* Numeración de unidades: el patrón admite `{clave}` (clave del desarrollo) y
* `{n}` (número consecutivo del rango).
*/
export const MAX_UNIDADES_GENERADOR = 1000
export function aplicarPatron(patron: string, clave: string, n: number): string {
return patron.split('{clave}').join(clave).split('{n}').join(String(n))
}
/** Primeros `max` códigos del rango (vista previa antes de confirmar) */
export function generarCodigos(
patron: string,
clave: string,
desde: number,
hasta: number,
max = 5,
): string[] {
if (!patron.includes('{n}') || !Number.isInteger(desde) || !Number.isInteger(hasta) || hasta < desde) {
return []
}
const cantidad = Math.min(hasta - desde + 1, Math.max(0, max))
return Array.from({ length: cantidad }, (_, i) => aplicarPatron(patron, clave, desde + i))
}
/** Total de unidades que crearía el generador con el rango actual */
export function totalDelRango(desde: number, hasta: number): number {
if (!Number.isInteger(desde) || !Number.isInteger(hasta) || hasta < desde) return 0
return hasta - desde + 1
}
/** Devuelve el mensaje de error del generador o null si todo está bien */
export function validarGenerador(patron: string, desde: number, hasta: number): string | null {
if (patron.trim() === '') return 'Escribe el patrón de código de la unidad.'
if (!patron.includes('{n}')) return 'El patrón debe incluir {n} para numerar las unidades.'
if (/[^A-Za-z0-9{}_-]/.test(patron)) {
return 'El patrón solo admite letras, números, guiones, {clave} y {n}.'
}
if (!Number.isInteger(desde) || !Number.isInteger(hasta)) return 'El rango debe usar números enteros.'
if (hasta < desde) return 'El valor "hasta" debe ser mayor o igual que "desde".'
if (hasta - desde + 1 > MAX_UNIDADES_GENERADOR) {
return `El rango no puede superar ${MAX_UNIDADES_GENERADOR} unidades por vez.`
}
return null
}

View File

@@ -0,0 +1,164 @@
import type { BasePrecio, ModoMensualidades, PropuestaReglas, ReglasCotizador } from '../../services/desarrollos'
import { ETIQUETA_BASE, ETIQUETA_MODO_MENSUALIDADES } from './comun'
/**
* Los 4 esquemas comerciales que maneja el cotizador. `tipo` y `claveDescuento`
* son las claves que espera `apps/cotizador` (no cambiarlas sin cambiarlas allá).
*/
export const PROPUESTAS_BASE: { tipo: string; claveDescuento: string; titulo: string }[] = [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario' },
{
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
claveDescuento: 'creditoInfonavit',
titulo: 'Crédito Infonavit-Cofinavit',
},
{ tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado' },
{ tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado' },
]
/** Valores de partida de cada esquema (los del cotizador de Diagonal) */
export const PROPUESTAS_POR_DEFECTO: Record<string, PropuestaReglas> = {
CREDITO_BANCARIO: {
tipo: 'CREDITO_BANCARIO',
claveDescuento: 'creditoBancario',
titulo: 'Crédito Bancario',
descuentoDefault: 0,
pctEnganche: 0.025,
engancheBase: 'lista',
pctPagoFinal: 0.9,
pagoFinalBase: 'final',
modoMensualidades: 'remanente',
},
CREDITO_INFONAVIT_COFINAVIT: {
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
claveDescuento: 'creditoInfonavit',
titulo: 'Crédito Infonavit-Cofinavit',
descuentoDefault: 0,
pctEnganche: 0.025,
engancheBase: 'lista',
pctPagoFinal: 0.95,
pagoFinalBase: 'final',
modoMensualidades: 'remanenteLista',
},
CONTADO: {
tipo: 'CONTADO',
claveDescuento: 'contado',
titulo: 'Contado',
descuentoDefault: 0.02,
pctEnganche: 0.9,
engancheBase: 'final',
pctPagoFinal: 0.1,
pagoFinalBase: 'final',
modoMensualidades: 'ninguna',
},
SEMI_CONTADO: {
tipo: 'SEMI_CONTADO',
claveDescuento: 'semiContado',
titulo: 'Semi Contado',
descuentoDefault: 0.01,
pctEnganche: 0.5,
engancheBase: 'final',
pctPagoFinal: 0.1,
pagoFinalBase: 'final',
modoMensualidades: 'porcentaje',
},
}
export const REGLAS_POR_DEFECTO: ReglasCotizador = {
precioUnico: false,
fuenteDeN: 'fechaEntrega',
nDefault: 12,
cuotaMantenimiento: 0,
prefijoUnidadPdf: 'CASA',
propuestas: [
PROPUESTAS_POR_DEFECTO.CREDITO_BANCARIO!,
PROPUESTAS_POR_DEFECTO.CREDITO_INFONAVIT_COFINAVIT!,
PROPUESTAS_POR_DEFECTO.CONTADO!,
PROPUESTAS_POR_DEFECTO.SEMI_CONTADO!,
],
}
/** Los porcentajes se guardan como fracción (0.025 = 2.5%), igual que el cotizador */
export function pctDeFraccion(fraccion: number): number {
if (!Number.isFinite(fraccion)) return 0
return Number((fraccion * 100).toFixed(4))
}
export function fraccionDePct(pct: number): number {
if (!Number.isFinite(pct)) return 0
return Number((pct / 100).toFixed(6))
}
/** Completa las reglas que llegan del API con los valores por defecto */
export function completarReglas(reglas: ReglasCotizador | null): ReglasCotizador {
if (!reglas) return REGLAS_POR_DEFECTO
return {
precioUnico: reglas.precioUnico ?? REGLAS_POR_DEFECTO.precioUnico,
fuenteDeN: reglas.fuenteDeN ?? REGLAS_POR_DEFECTO.fuenteDeN,
...(reglas.nFijo != null ? { nFijo: reglas.nFijo } : {}),
nDefault: reglas.nDefault ?? REGLAS_POR_DEFECTO.nDefault,
cuotaMantenimiento: reglas.cuotaMantenimiento ?? REGLAS_POR_DEFECTO.cuotaMantenimiento,
prefijoUnidadPdf: reglas.prefijoUnidadPdf ?? REGLAS_POR_DEFECTO.prefijoUnidadPdf,
propuestas: (reglas.propuestas ?? []).map((p) => ({ ...p })),
}
}
/** Completa una propuesta guardada con los valores por defecto de su tipo */
export function completarPropuesta(propuesta: PropuestaReglas): PropuestaReglas {
const base = PROPUESTAS_POR_DEFECTO[propuesta.tipo]
if (!base) return propuesta
return {
...base,
...propuesta,
titulo: propuesta.titulo || base.titulo,
modoMensualidades: propuesta.modoMensualidades ?? base.modoMensualidades,
}
}
const ETIQUETA_MODO_CORTO: Record<ModoMensualidades, string> = {
remanente: 'sobre el remanente',
remanenteLista: 'sobre el remanente del precio de lista',
porcentaje: 'sobre el remanente en porcentaje',
ninguna: 'sin mensualidades',
}
const nombreBase = (base: BasePrecio) => ETIQUETA_BASE[base] ?? base
/** pct legible: 2.5 → "2.5", 90 → "90", 0.125 → "0.125" */
export function formatearPct(valor: number): string {
return String(Number.isFinite(valor) ? Number(valor.toFixed(4)) : 0)
}
/** Resumen legible de una propuesta, p. ej. "Enganche 2.5% del precio de lista, pago final 90%…" */
export function resumenPropuesta(p: PropuestaReglas): string {
const partes = [
`Enganche ${formatearPct(pctDeFraccion(p.pctEnganche))}% del ${nombreBase(p.engancheBase)}`,
`pago final ${formatearPct(pctDeFraccion(p.pctPagoFinal))}% del ${nombreBase(p.pagoFinalBase)}`,
`mensualidades ${ETIQUETA_MODO_CORTO[p.modoMensualidades] ?? ETIQUETA_MODO_MENSUALIDADES[p.modoMensualidades] ?? p.modoMensualidades}`,
]
if (p.descuentoDefault) {
partes.push(`descuento por defecto ${formatearPct(pctDeFraccion(p.descuentoDefault))}%`)
}
return partes.join(', ') + '.'
}
/** Propuestas habilitadas a partir de las reglas guardadas */
export function propuestasHabilitadas(reglas: ReglasCotizador | null): Record<string, boolean> {
const guardadas = new Set((reglas?.propuestas ?? []).map((p) => p.tipo))
const habilitadas: Record<string, boolean> = {}
for (const base of PROPUESTAS_BASE) habilitadas[base.tipo] = guardadas.has(base.tipo)
return habilitadas
}
/** Estado inicial del editor: las 4 propuestas, habilitadas según lo guardado */
export function propuestasEditables(reglas: ReglasCotizador | null): Record<string, PropuestaReglas> {
const porTipo = new Map((reglas?.propuestas ?? []).map((p) => [p.tipo, p]))
const editables: Record<string, PropuestaReglas> = {}
for (const base of PROPUESTAS_BASE) {
const guardada = porTipo.get(base.tipo)
editables[base.tipo] = guardada
? completarPropuesta(guardada)
: { ...PROPUESTAS_POR_DEFECTO[base.tipo]! }
}
return editables
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react'
import { ArrowRight, CalendarDays, MapPin, MessageCircle } from 'lucide-react'
import type { EstadoDesarrollo, TipoDesarrollo } from '../../services/publico'
import { etiquetaEstado, mesAnio, normalizarTexto } from './formato'
interface Props {
nombre: string
zona?: string | null
direccion?: string | null
estado: EstadoDesarrollo
tipo: TipoDesarrollo
fechaEntregaProgramada?: string | null
imagenUrl?: string | null
logoUrl?: string | null
/** URL de WhatsApp ya armada (ver components/publico/contacto.ts) */
urlWhatsApp: string
onVerDisponibilidad: () => void
}
const ETIQUETA_TIPO: Record<TipoDesarrollo, string> = {
VERTICAL: 'Desarrollo vertical',
HORIZONTAL: 'Desarrollo horizontal',
}
const CLASE_ESTADO: Record<EstadoDesarrollo, string> = {
PREVENTA: 'border-white/30 bg-[#F59E0B]/20 text-[#FDE68A]',
ACTIVO: 'border-white/30 bg-[#10B981]/20 text-[#A7F3D0]',
CERRADO: 'border-white/25 bg-white/10 text-white/80',
}
/** Encabezado tipo hero: imagen del desarrollo, identidad, estado y CTA principal */
export function EncabezadoDesarrollo({
nombre,
zona,
direccion,
estado,
tipo,
fechaEntregaProgramada,
imagenUrl,
logoUrl,
urlWhatsApp,
onVerDisponibilidad,
}: Props) {
const [imagenRota, setImagenRota] = useState(false)
const mostrarImagen = Boolean(imagenUrl) && !imagenRota
const entrega = mesAnio(fechaEntregaProgramada)
const ubicacion = [zona, direccion].filter((v) => v && normalizarTexto(v)).join(' · ')
return (
<header className="relative isolate overflow-hidden bg-[#0B1220] text-white">
{mostrarImagen ? (
<>
<img
src={imagenUrl as string}
alt={`Vista de ${nombre}`}
onError={() => setImagenRota(true)}
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-t from-[#0B1220] via-[#0B1220]/85 to-[#0B1220]/45"
/>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-r from-[#0B1220]/85 via-[#0B1220]/40 to-transparent"
/>
</>
) : (
<>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-br from-[#0F172A] via-[#1E3A8A] to-[#2563EB]"
/>
<div
aria-hidden="true"
className="absolute inset-0 opacity-40 [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.18),transparent_55%),radial-gradient(circle_at_80%_0%,rgba(255,255,255,0.12),transparent_45%)]"
/>
</>
)}
<div className="relative mx-auto flex min-h-[540px] w-full max-w-6xl flex-col justify-end px-4 pb-14 pt-16 sm:px-6 sm:pb-20 sm:pt-24 lg:min-h-[620px] lg:px-8">
{logoUrl ? (
<img
src={logoUrl}
alt={`Logotipo de ${nombre}`}
className="mb-6 h-10 w-auto max-w-[180px] object-contain object-left sm:h-12"
/>
) : null}
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold tracking-wide uppercase ${CLASE_ESTADO[estado]}`}
>
{etiquetaEstado(estado)}
</span>
{entrega ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-medium text-white/90 backdrop-blur-sm">
<CalendarDays aria-hidden="true" className="h-3.5 w-3.5" />
Entrega: {entrega}
</span>
) : null}
<span className="inline-flex items-center rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs font-medium text-white/75">
{ETIQUETA_TIPO[tipo]}
</span>
</div>
<h1 className="mt-5 max-w-3xl text-3xl leading-tight font-semibold tracking-tight text-white sm:text-4xl lg:text-5xl">
{nombre}
</h1>
{ubicacion ? (
<p className="mt-4 flex items-center gap-2 text-sm text-white/80 sm:text-base">
<MapPin aria-hidden="true" className="h-4 w-4 shrink-0 text-accent" />
<span>{ubicacion}</span>
</p>
) : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center">
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#25D366] px-6 py-3.5 text-sm font-semibold text-[#06281A] shadow-lg shadow-black/20 transition-colors duration-150 ease-out hover:bg-[#1FBE5A]"
>
<MessageCircle aria-hidden="true" className="h-4 w-4" />
Escríbenos por WhatsApp
</a>
<button
type="button"
onClick={onVerDisponibilidad}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/35 bg-white/10 px-6 py-3.5 text-sm font-semibold text-white backdrop-blur-sm transition-colors duration-150 ease-out hover:bg-white/20"
>
Ver disponibilidad
<ArrowRight aria-hidden="true" className="h-4 w-4" />
</button>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,45 @@
import Skeleton from '../Skeleton'
/** Estado de carga del micrositio: mismos bloques que la página real */
export function EsqueletoDesarrollo() {
return (
<div aria-busy="true" aria-live="polite" role="status">
<span className="sr-only">Cargando información del desarrollo…</span>
<div className="bg-[#0B1220]">
<div className="mx-auto flex min-h-[420px] w-full max-w-6xl flex-col justify-end px-4 pb-16 sm:px-6 lg:px-8">
<Skeleton className="h-6 w-28 bg-white/10" />
<Skeleton className="mt-6 h-10 w-3/4 max-w-xl bg-white/10" />
<Skeleton className="mt-4 h-4 w-1/2 max-w-sm bg-white/10" />
<Skeleton className="mt-8 h-12 w-full max-w-md bg-white/10" />
</div>
</div>
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="-mt-10 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-[#E5E7EB] bg-[#E5E7EB] sm:-mt-12 lg:grid-cols-4 dark:border-[#1E293B] dark:bg-[#1E293B]">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-white p-5 sm:p-6 dark:bg-[#0F172A]">
<Skeleton className="h-3 w-24" />
<Skeleton className="mt-3 h-7 w-16" />
</div>
))}
</div>
<div className="grid gap-10 py-16 lg:grid-cols-2">
<div className="space-y-4">
<Skeleton className="h-8 w-52" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-4/5" />
</div>
<Skeleton className="h-48 w-full rounded-2xl" />
</div>
<div className="grid gap-5 pb-16 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-56 w-full rounded-2xl" />
))}
</div>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More