Files
FRASA-CAS/api-gateway/src/controllers/cotizaciones.controller.test.ts
Consult AS Technology 4a3ddd2f35 feat(cotizador): 4 opciones de credito sobre el esquema FRASA (saldo 84.5% con mensualidad estimada)
- Nuevo modulo puro lib/opciones-credito.ts: Bancario, Infonavit-Cofinavit,
  Semi Contado (1%) y Contado (2%), con descuentos del Excel del cliente.
  Cada opcion recalcula el esquema FRASA 2026 sobre el precio final y
  estima la mensualidad del credito (anualidad con tasa y plazo ajustables;
  % de contado para la Semi). Ajustes validados (tasa 0-50%, plazo 1-35).
- Nuevo componente OpcionesCredito: grid de 4 tarjetas con tasa/plazo/% de
  contado editables, mensualidad estimada y boton 'Usar en el PDF'.
- CotizadorPage: estado de ajustes y opcion elegida, memo con try/catch
  (defaults de respaldo), restauracion defensiva al retomar cotizacion.
- PDF: seccion 'PROPUESTA DE FINANCIAMIENTO ELEGIDA' con renglones del
  credito (o pago de contado y ahorro para Contado) + disclaimer.
- API: columna Cotizacion.opcionesCredito (TEXT, JSON {elegida, ajustes})
  con migracion manual aplicada en SQLite; zod max 4000; se persiste crudo
  y los GET devuelven la fila completa.

Tests: cotizador 149/149 (19 nuevos en opciones-credito, 2 en pdf),
API 746/746 (3 nuevos en cotizaciones). Smoke real POST+GET en produccion
local con unidad ATELIER-34 (201 con opcionesCredito; fila de prueba borrada).
2026-09-28 19:56:37 +00:00

500 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Prisma mockeado (los tests no tocan BD ni red) ----------
const prismaMock = vi.hoisted(() => ({
unidad: {
findUnique: vi.fn(),
},
capturaDepartamento: {
findMany: vi.fn(),
},
leadCaptura: {
findFirst: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
leadOportunidad: {
findUnique: vi.fn(),
},
cotizacion: {
create: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
},
actividadLocal: {
create: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
// Reglas comerciales configurables por el admin (vacías = constantes)
reglaCotizadorDesarrollo: {
findMany: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
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
* cotización se persiste en SQLite y se liga a la oportunidad local (que pasa
* a etapa 'Cotizador').
*/
const filaUnidad = {
codigo: 'U-101',
desarrollo: 'DIAGONAL',
modelo: '2RA',
supConstruccion: 60,
supExcedente: 0,
supEstacionamiento: 0,
precioLista: 1_000_000,
precioAcabados: 1_100_000,
status: 'DISPONIBLE',
etapa: '',
}
const bodyBase = {
unidadCodigo: 'U-101',
acabados: false,
clienteNombre: 'Cliente Demo',
clienteTelefono: '4771234567',
}
/**
* 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 {
return { body, usuarioToken: { sub: 'u-1' } } as unknown as Request
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
return { res: { json, status } as unknown as Response, status, json }
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadOportunidad.findUnique.mockResolvedValue(null)
prismaMock.cotizacion.create.mockImplementation(async ({ data }) => ({ id: 'cot-1', ...data }))
prismaMock.cotizacion.update.mockResolvedValue({})
prismaMock.cotizacion.findMany.mockResolvedValue([])
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
it('cotiza con la unidad del espejo local (tabla Unidad)', async () => {
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(prismaMock.unidad.findUnique).toHaveBeenCalledWith({ where: { codigo: 'U-101' } })
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['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()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ status: 404 })
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('sin oportunidad mapeada para el teléfono: mondayItemId null (no crea oportunidades)', async () => {
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: null })
})
it('con teléfono de una afluencia local: LIGA esa oportunidad y la pasa a Cotizador', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({
leadId: 'loc_op_1',
board: 'OPORTUNIDADES',
telefono: '4771234567',
})
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_1', board: 'OPORTUNIDADES' })
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_1' })
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'loc_op_1' },
})
// La oportunidad pasa a etapa 'Cotizador' localmente
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_op_1' },
data: { etapa: 'Cotizador', pendienteSync: true },
})
})
it('con teléfono de un lead histórico: usa la oportunidad del mapping lead→oportunidad', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({
leadId: 'loc_lead_1',
board: 'LEADS',
telefono: '4771234567',
})
prismaMock.leadOportunidad.findUnique.mockResolvedValue({ oportunidadId: 'loc_op_mapping' })
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_op_mapping', board: 'OPORTUNIDADES' })
const { res, status, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'loc_op_mapping' })
})
it('con oportunidadId explícito (botón "Cotizar" de Mi día): liga ESE id sin buscar por teléfono', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'opp-midia-1', board: 'OPORTUNIDADES' })
const { res, status, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, oportunidadId: 'opp-midia-1' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayItemId: 'opp-midia-1' })
expect(prismaMock.cotizacion.update).toHaveBeenCalledWith({
where: { id: 'cot-1' },
data: { mondayItemId: 'opp-midia-1' },
})
})
it('sin teléfono en el body no busca oportunidades', async () => {
const { res } = resFake()
const { clienteTelefono: _omitido, ...sinTelefono } = bodyBase
await crearCotizacion(reqFake(sinTelefono), res)
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
})
it('sin numMensualidades usa el default del esquema (14)', 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
esquema: { numMensualidades: number; mensualidad: number; baseDiferible: number }
}
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 () => {
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res)
// 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; esquema: { numMensualidades: number } }
expect(respuesta.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('persiste y devuelve las opciones de crédito que manda el cotizador', async () => {
const opcionesCredito = JSON.stringify({
elegida: 'CONTADO',
ajustes: {
CREDITO_BANCARIO: { tasaAnual: 0.105, plazoAnos: 20 },
CREDITO_INFONAVIT_COFINAVIT: { tasaAnual: 0.105, plazoAnos: 25 },
SEMI_CONTADO: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 },
CONTADO: { tasaAnual: 0, plazoAnos: 1 },
},
})
const { res, status, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, opcionesCredito }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ opcionesCredito }),
})
// La respuesta del POST incluye el campo (los GET devuelven la fila completa)
expect(json.mock.calls[0]?.[0]).toMatchObject({ opcionesCredito })
})
it('sin opciones de crédito persiste null', async () => {
const { res } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ opcionesCredito: null }),
})
})
it('rechaza opciones de crédito que exceden 4000 caracteres', async () => {
const { res } = resFake()
await expect(
crearCotizacion(reqFake({ ...bodyBase, opcionesCredito: 'x'.repeat(4001) }), res),
).rejects.toBeDefined()
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
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 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, precioFinal: 1_000_000 }),
})
})
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), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0, precioFinal: 1_000_000 }),
})
})
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, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalled()
})
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', () => {
it('crea una ActividadLocal al vendedor de cada cotización reciente, con dedup', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])
const { res, json } = resFake()
await alertasInventarioEndpoint(
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request,
res,
)
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 1, omitidas: 0, fallidas: 0 })
expect(prismaMock.actividadLocal.create).toHaveBeenCalledWith({
data: expect.objectContaining({
titulo: 'La unidad U-101 que cotizaste pasó a APARTADA',
usuarioId: 'usr-1',
}),
})
expect(prismaMock.automatizacionState.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { clave: 'alerta-inv:U-101:cot-9:APARTADA' },
}),
)
})
it('sin vendedor en la cotización: omitida (sin actividad)', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: null }])
const { res, json } = resFake()
await alertasInventarioEndpoint(
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'VENDIDA' }] } } as unknown as Request,
res,
)
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 })
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
})
it('ya alertada (dedup persistente): omitida', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'x', valor: 'y' })
const { res, json } = resFake()
await alertasInventarioEndpoint(
{ body: { cambios: [{ unidadCodigo: 'U-101', statusNuevo: 'APARTADA' }] } } as unknown as Request,
res,
)
expect(json).toHaveBeenCalledWith({ cambiosProcesados: 1, alertasCreadas: 0, omitidas: 1, fallidas: 0 })
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
})
})