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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user