feat: esquema unico de cotizacion FRASA 2026 (reemplaza las 4 propuestas)

Flujo aprobado por el cliente (28 sep 2026), porcentajes globales para
todos los desarrollos:
- Apartado: cantidad libre (sin minimo), se acredita dentro del enganche
  de firma; el excedente se descuenta del enganche
- Enganche de firma: 2.5% del precio (saldo a firma = max(0, 2.5% - apartado))
- Enganche: 7.5%, diferible en N mensualidades (N a eleccion del vendedor, 1-36)
- Gastos de escrituracion: 5.5%, pago unico a la firma o diferido con el
  enganche (toggle)

- lib/esquema-frasa.ts (frontend) + cotizacion-esquema.service.ts (backend):
  modulos puros espejo, caso dorado M/apartado 5k/N=14 en tests de ambos
- Migracion 20260928120000: 7 columnas nuevas en Cotizacion (esquema,
  saldoFirma, engancheDiferible, escrituracion, etc.); legacy se rellena
  para compatibilidad de reportes (tipoPropuesta=FRASA_2026)
- UI: panel de linea de tiempo (Hoy -> Firma -> Mensualidades) sustituye a
  propuestas/comparadores; PDF y WhatsApp con el esquema unico
- API recalcula server-side; smoke end-to-end verificado contra inventario
- 743 tests API + 128 cotizador en verde
This commit is contained in:
2026-09-28 13:00:22 +00:00
parent 23d288d0aa
commit 61d1b84fc3
23 changed files with 1160 additions and 1051 deletions

View File

@@ -69,7 +69,28 @@ const bodyBase = {
acabados: false,
clienteNombre: 'Cliente Demo',
clienteTelefono: '4771234567',
tipoPropuesta: 'BANCARIO' as const,
}
/**
* Esquema único FRASA 2026 esperado para filaUnidad (precio $1,000,000) con
* los defaults del body: apartado $15,000, N=14, escrituración a la firma.
* Caso dorado del cliente: hoy $15,000 · firma $65,000 · 14 × $5,357.14.
*/
const esquemaDorado = {
esquema: 'FRASA_2026',
apartado: 15000,
saldoFirma: 10000,
engancheDiferible: 75000,
escrituracion: 55000,
escrituracionDiferida: false,
tipoPropuesta: 'FRASA_2026',
descuento: 0,
precioLista: 1_000_000,
precioFinal: 1_000_000,
enganche: 75000,
numMensualidades: 14,
mensualidad: 75000 / 14,
pagoFinal: 65000,
}
function reqFake(body: unknown): Request {
@@ -110,10 +131,54 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
expect(status).toHaveBeenCalledWith(201)
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
expect(respuesta['propuesta']).toBeDefined()
expect(respuesta['esquema']).toBeDefined()
expect(respuesta['folio']).toBeDefined()
})
it('caso dorado: recalcula el esquema FRASA 2026 server-side y persiste columnas nuevas y legacy', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining(esquemaDorado) })
const respuesta = json.mock.calls[0]?.[0] as {
esquema: { saldoFirma: number; pagoUnicoFirma: number; mensualidad: number; baseDiferible: number }
}
expect(respuesta.esquema.saldoFirma).toBe(10000)
expect(respuesta.esquema.pagoUnicoFirma).toBe(65000)
expect(respuesta.esquema.baseDiferible).toBe(75000)
expect(respuesta.esquema.mensualidad).toBeCloseTo(75000 / 14, 2)
})
it('escrituración diferida: entra al diferible y baja el pago a la firma', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, escrituracionDiferida: true }), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({
escrituracionDiferida: true,
pagoFinal: 10000,
mensualidad: 130000 / 14,
}),
})
const respuesta = json.mock.calls[0]?.[0] as { esquema: { baseDiferible: number } }
expect(respuesta.esquema.baseDiferible).toBe(130000)
})
it('apartado mayor al 2.5%: el excedente se acredita al enganche (no se cobra dos veces)', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, apartado: 30000 }), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ apartado: 30000, saldoFirma: 0, enganche: 70000, pagoFinal: 55000 }),
})
const respuesta = json.mock.calls[0]?.[0] as { esquema: { excedenteApartado: number; totalPagado: number } }
expect(respuesta.esquema.excedenteApartado).toBe(5000)
expect(respuesta.esquema.totalPagado).toBeCloseTo(155000, 2)
})
it('la unidad no existe en el espejo → 404 sin crear cotización', async () => {
prismaMock.unidad.findUnique.mockResolvedValue(null)
const { res } = resFake()
@@ -195,7 +260,7 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
})
it('toma la fecha de entrega de la captura: mensualidad y total ya no salen nulos', async () => {
it('sin numMensualidades usa el default del esquema (14)', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
])
@@ -206,12 +271,11 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
const respuesta = json.mock.calls[0]?.[0] as {
numMensualidades: number
mensualidad: number
propuesta: { numMensualidades: number; mensualidad: number; mensualidadesTotal: number }
esquema: { numMensualidades: number; mensualidad: number; baseDiferible: 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)
expect(respuesta.numMensualidades).toBe(14)
expect(respuesta.numMensualidades).toBe(respuesta.esquema.numMensualidades)
expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / 14, 6)
})
it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => {
@@ -219,13 +283,20 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res)
// DIAGONAL bancario sin acabados: remanente 75,000 en 12 pagos
// Enganche diferible $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 } }
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } }
expect(respuesta.numMensualidades).toBe(12)
expect(respuesta.propuesta.numMensualidades).toBe(12)
expect(respuesta.esquema.numMensualidades).toBe(12)
})
it('rechaza mensualidades fuera de rango (1..36) sin persistir', async () => {
const { res } = resFake()
await expect(crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 48 }), res)).rejects.toBeDefined()
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => {
@@ -266,47 +337,41 @@ describe('crearCotizacion — reglas comerciales del admin', () => {
]
}
it('sin reglas guardadas cotiza igual que siempre (respaldo en constantes)', async () => {
it('sin reglas guardadas cotiza con el esquema único (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 }),
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0, precioFinal: 1_000_000 }),
})
})
it('usa el descuento configurado por el admin', async () => {
it('los descuentos que configure el admin YA NO aplican: el esquema único no los usa', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
)
const { res, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res)
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0.1, precioFinal: 900_000 }),
data: expect.objectContaining({ descuento: 0, precioFinal: 1_000_000 }),
})
})
it('propuesta desactivada → 409 con el mensaje del servidor y sin persistir', async () => {
it('propuesta desactivada por el admin ya NO bloquea: el esquema único no usa propuestas', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
)
const { res } = resFake()
const { res, status } = 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()
await crearCotizacion(reqFake(bodyBase), res)
// Las otras propuestas siguen cotizándose
const { res: res2, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res2)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalled()
})
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {

View File

@@ -1,12 +1,16 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { getUnidadLocal } from '../services/unidades-local.service.js'
import { obtenerPrecioLista } from '../services/cotizacion.service.js'
import {
calcularPropuesta,
obtenerPrecioLista,
propuestasVigentes,
TIPO_PROPUESTA_API,
} from '../services/cotizacion.service.js'
calcularEsquemaFrasa,
MENSUALIDADES_DEFAULT,
MENSUALIDADES_MAX,
MENSUALIDADES_MIN,
PCT_ENGANCHE_FIRMA,
PCT_ESCRITURACION,
type EsquemaFrasaResultado,
} from '../services/cotizacion-esquema.service.js'
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
import {
procesarAlertasInventario,
@@ -27,19 +31,24 @@ const crearCotizacionSchema = z.object({
clienteNombre: z.string().min(1),
clienteEmail: z.string().email().optional(),
clienteTelefono: z.string().min(7).optional(),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
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
// 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(),
// 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(),
// Esquema único FRASA 2026: mensualidades del diferimiento (elección del
// vendedor, 1..36; sin ellas va el default de 14) y si la escrituración
// (5.5%) se difiere con el enganche o se paga a la firma
numMensualidades: z
.number()
.int('Las mensualidades deben ser un número entero')
.min(MENSUALIDADES_MIN, `Mínimo ${MENSUALIDADES_MIN} mensualidad`)
.max(MENSUALIDADES_MAX, `Máximo ${MENSUALIDADES_MAX} mensualidades`)
.optional(),
escrituracionDiferida: z.boolean().optional(),
// 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)
oportunidadId: z.string().trim().min(1).optional(),
})
/** Apartado por defecto del esquema único (el cotizador arranca con el mismo valor) */
const APARTADO_DEFAULT = 15000
/** 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()
@@ -83,8 +92,9 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
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.
// respaldo en las constantes históricas. Un desarrollo desactivado se
// rechaza con el mensaje que muestra el cotizador. El esquema único
// FRASA 2026 ya no depende de propuestas (las 4 propuestas desaparecieron).
const reglas = await getReglasDesarrollo(unidad.desarrollo)
if (reglas.activoEnCotizador === false) {
throw new AppError(
@@ -92,26 +102,19 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
`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 propuesta = calcularPropuesta(
unidad,
body.tipoPropuesta,
body.acabados,
apartado,
body.descuento,
body.enganchePct,
body.numMensualidades,
reglas,
)
// El API RECALCULA el esquema server-side con el precio de lista de la
// unidad: no se confía en los montos que manda el cliente.
const apartado = body.apartado ?? APARTADO_DEFAULT
const numMensualidades = body.numMensualidades ?? MENSUALIDADES_DEFAULT
const escrituracionDiferida = body.escrituracionDiferida ?? false
const precioLista = obtenerPrecioLista(unidad, body.acabados)
const esquema: EsquemaFrasaResultado = calcularEsquemaFrasa({
precio: precioLista,
apartado,
numMensualidades,
escrituracionDiferida,
})
const cotizacion = await prisma.cotizacion.create({
data: {
@@ -121,18 +124,27 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
clienteNombre: body.clienteNombre,
clienteEmail: body.clienteEmail ?? null,
clienteTelefono: body.clienteTelefono ?? null,
tipoPropuesta: body.tipoPropuesta,
acabados: body.acabados,
apartado,
descuento: propuesta.descuento,
precioLista,
precioFinal: propuesta.precioFinal,
enganche: propuesta.enganche,
// 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,
pagoFinal: propuesta.pagoFinal,
// Columnas nuevas del esquema único
esquema: 'FRASA_2026',
saldoFirma: esquema.saldoFirma,
pctEngancheFirma: PCT_ENGANCHE_FIRMA,
engancheDiferible: esquema.enganche,
pctEscrituracion: PCT_ESCRITURACION,
escrituracionDiferida,
escrituracion: esquema.escrituracion,
// Columnas legacy, rellenadas para compatibilidad de reportes:
// sin descuentos en el esquema único, el precio final es el de lista y
// el "pago final" es todo lo que se liquida a la firma.
tipoPropuesta: 'FRASA_2026',
descuento: 0,
precioFinal: precioLista,
enganche: esquema.enganche,
numMensualidades,
mensualidad: esquema.mensualidad,
pagoFinal: esquema.pagoUnicoFirma,
},
})
@@ -163,7 +175,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
}
}
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, esquema })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_COTIZACION', 'cotizacion', cotizacion.id)
}
@@ -219,7 +231,15 @@ export async function obtenerCotizacionPublica(req: Request, res: Response): Pro
folio: folioCotizacion(cotizacion.id),
desarrollo: cotizacion.desarrollo,
unidadCodigo: cotizacion.unidadCodigo,
// Esquema único FRASA 2026 (filas nuevas) + legacy (filas BANCARIO, …)
esquema: cotizacion.esquema,
tipoPropuesta: cotizacion.tipoPropuesta,
precioLista: cotizacion.precioLista,
apartado: cotizacion.apartado,
saldoFirma: cotizacion.saldoFirma,
engancheDiferible: cotizacion.engancheDiferible,
escrituracion: cotizacion.escrituracion,
escrituracionDiferida: cotizacion.escrituracionDiferida,
precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades,