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).
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
-- Opciones de credito del saldo (84.5%) sobre el esquema FRASA 2026: las 4
|
||||
-- propuestas del Excel del cliente (Bancario, Infonavit-Cofinavit, Semi
|
||||
-- Contado, Contado) con la opcion elegida y los ajustes del vendedor.
|
||||
-- Se guarda como JSON crudo {elegida, ajustes}; null en filas anteriores.
|
||||
-- Patron manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
ALTER TABLE "Cotizacion" ADD COLUMN "opcionesCredito" TEXT;
|
||||
@@ -41,6 +41,9 @@ model Cotizacion {
|
||||
pctEscrituracion Float? // 0.055
|
||||
escrituracionDiferida Boolean @default(false)
|
||||
escrituracion Float? // gastos de escrituración (5.5% del precio)
|
||||
// Opciones de credito del saldo (84.5%): JSON {elegida, ajustes} serializado;
|
||||
// el cotizador manda el ajuste del vendedor y se persiste crudo.
|
||||
opcionesCredito String?
|
||||
mondayItemId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
@@ -299,6 +299,47 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
|
||||
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()
|
||||
|
||||
@@ -41,6 +41,9 @@ const crearCotizacionSchema = z.object({
|
||||
.max(MENSUALIDADES_MAX, `Máximo ${MENSUALIDADES_MAX} mensualidades`)
|
||||
.optional(),
|
||||
escrituracionDiferida: z.boolean().optional(),
|
||||
// Opciones de credito del saldo (84.5%): JSON serializado {elegida, ajustes}
|
||||
// que manda el cotizador; se persiste crudo y se devuelve en los GET
|
||||
opcionesCredito: z.string().max(4000).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(),
|
||||
@@ -135,6 +138,8 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
||||
pctEscrituracion: PCT_ESCRITURACION,
|
||||
escrituracionDiferida,
|
||||
escrituracion: esquema.escrituracion,
|
||||
// Opciones de credito elegidas por el vendedor (JSON crudo)
|
||||
opcionesCredito: body.opcionesCredito ?? null,
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user