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

@@ -0,0 +1,14 @@
-- Esquema de cotización único FRASA 2026 (aprobado por el cliente el 28 sep 2026):
-- apartado libre + enganche de firma 2.5% + enganche 7.5% diferible +
-- escrituración 5.5% (a la firma o diferida con el enganche). Las columnas
-- legacy (tipoPropuesta, descuento, precioFinal, enganche, mensualidad,
-- pagoFinal) se siguen llenando para compatibilidad de reportes.
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
ALTER TABLE "Cotizacion" ADD COLUMN "esquema" TEXT NOT NULL DEFAULT 'FRASA_2026';
ALTER TABLE "Cotizacion" ADD COLUMN "saldoFirma" REAL;
ALTER TABLE "Cotizacion" ADD COLUMN "pctEngancheFirma" REAL;
ALTER TABLE "Cotizacion" ADD COLUMN "engancheDiferible" REAL;
ALTER TABLE "Cotizacion" ADD COLUMN "pctEscrituracion" REAL;
ALTER TABLE "Cotizacion" ADD COLUMN "escrituracionDiferida" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Cotizacion" ADD COLUMN "escrituracion" REAL;

View File

@@ -21,7 +21,7 @@ model Cotizacion {
clienteNombre String clienteNombre String
clienteEmail String? clienteEmail String?
clienteTelefono String? clienteTelefono String?
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO | FRASA_2026 (esquema único)
acabados Boolean acabados Boolean
apartado Float // TODO prod: Decimal en Postgres apartado Float // TODO prod: Decimal en Postgres
descuento Float // fracción (0.02 = 2%) descuento Float // fracción (0.02 = 2%)
@@ -31,6 +31,16 @@ model Cotizacion {
numMensualidades Int numMensualidades Int
mensualidad Float? // null => N/A (Contado) mensualidad Float? // null => N/A (Contado)
pagoFinal Float pagoFinal Float
// Esquema único FRASA 2026 (apartado + firma 2.5% + enganche 7.5% +
// escrituración 5.5%). Las filas legacy tienen esquema='FRASA_2026' por el
// DEFAULT pero saldoFirma/engancheDiferible/escrituracion null.
esquema String @default("FRASA_2026")
saldoFirma Float? // lo que se paga a la firma por el enganche de firma (>= 0)
pctEngancheFirma Float? // 0.025 (constante del esquema; se persiste por si cambia)
engancheDiferible Float? // enganche del 7.5%, neto del excedente del apartado
pctEscrituracion Float? // 0.055
escrituracionDiferida Boolean @default(false)
escrituracion Float? // gastos de escrituración (5.5% del precio)
mondayItemId String? mondayItemId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@@ -69,7 +69,28 @@ const bodyBase = {
acabados: false, acabados: false,
clienteNombre: 'Cliente Demo', clienteNombre: 'Cliente Demo',
clienteTelefono: '4771234567', 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 { function reqFake(body: unknown): Request {
@@ -110,10 +131,54 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
expect(status).toHaveBeenCalledWith(201) expect(status).toHaveBeenCalledWith(201)
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown> const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null }) expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
expect(respuesta['propuesta']).toBeDefined() expect(respuesta['esquema']).toBeDefined()
expect(respuesta['folio']).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 () => { it('la unidad no existe en el espejo → 404 sin crear cotización', async () => {
prismaMock.unidad.findUnique.mockResolvedValue(null) prismaMock.unidad.findUnique.mockResolvedValue(null)
const { res } = resFake() const { res } = resFake()
@@ -195,7 +260,7 @@ 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 () => { it('sin numMensualidades usa el default del esquema (14)', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([ prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' }, { 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 { const respuesta = json.mock.calls[0]?.[0] as {
numMensualidades: number numMensualidades: number
mensualidad: 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(14)
expect(respuesta.numMensualidades).toBe(respuesta.propuesta.numMensualidades) expect(respuesta.numMensualidades).toBe(respuesta.esquema.numMensualidades)
expect(respuesta.propuesta.mensualidad).toBeGreaterThan(0) expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / 14, 6)
expect(respuesta.propuesta.mensualidadesTotal).toBeGreaterThan(0)
}) })
it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => { 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) 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({ expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }), 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.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 () => { 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() const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res) await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201) expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ 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( prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }), reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
) )
const { res, status } = resFake() const { res, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res) await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201) expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ 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( prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }), reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
) )
const { res } = resFake() const { res, status } = resFake()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ await crearCotizacion(reqFake(bodyBase), res)
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) expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalled()
}) })
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => { it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {

View File

@@ -1,12 +1,16 @@
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 { obtenerPrecioLista } from '../services/cotizacion.service.js'
import { import {
calcularPropuesta, calcularEsquemaFrasa,
obtenerPrecioLista, MENSUALIDADES_DEFAULT,
propuestasVigentes, MENSUALIDADES_MAX,
TIPO_PROPUESTA_API, MENSUALIDADES_MIN,
} from '../services/cotizacion.service.js' PCT_ENGANCHE_FIRMA,
PCT_ESCRITURACION,
type EsquemaFrasaResultado,
} from '../services/cotizacion-esquema.service.js'
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js' import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
import { import {
procesarAlertasInventario, procesarAlertasInventario,
@@ -27,19 +31,24 @@ const crearCotizacionSchema = z.object({
clienteNombre: z.string().min(1), clienteNombre: z.string().min(1),
clienteEmail: z.string().email().optional(), clienteEmail: z.string().email().optional(),
clienteTelefono: z.string().min(7).optional(), clienteTelefono: z.string().min(7).optional(),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']), // Esquema único FRASA 2026: mensualidades del diferimiento (elección del
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%) // vendedor, 1..36; sin ellas va el default de 14) y si la escrituración
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a // (5.5%) se difiere con el enganche o se paga a la firma
// esquemas con crédito (bancario/Infonavit). El Excel permite 2.5%–5%. numMensualidades: z
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(), .number()
// Mensualidades elegidas por el asesor; sin él se resuelve con la fecha de .int('Las mensualidades deben ser un número entero')
// entrega de la unidad (o el plazo por defecto del desarrollo) .min(MENSUALIDADES_MIN, `Mínimo ${MENSUALIDADES_MIN} mensualidad`)
numMensualidades: z.number().int().min(1).max(120).optional(), .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 // 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(),
}) })
/** 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) */ /** Unidad cotizable: solo inmuebles DISPONIBLES (apartar/vender no se puede cotizar dos veces) */
function validarUnidadDisponible(unidad: { codigo: string; status: string }): void { function validarUnidadDisponible(unidad: { codigo: string; status: string }): void {
const status = unidad.status.trim().toUpperCase() const status = unidad.status.trim().toUpperCase()
@@ -83,8 +92,9 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
validarUnidadDisponible(unidad) validarUnidadDisponible(unidad)
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con // Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
// respaldo en las constantes históricas. Un desarrollo o una propuesta // respaldo en las constantes históricas. Un desarrollo desactivado se
// desactivados se rechazan con el mensaje que muestra el cotizador. // 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) const reglas = await getReglasDesarrollo(unidad.desarrollo)
if (reglas.activoEnCotizador === false) { if (reglas.activoEnCotizador === false) {
throw new AppError( 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.`, `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 // El API RECALCULA el esquema server-side con el precio de lista de la
const propuesta = calcularPropuesta( // unidad: no se confía en los montos que manda el cliente.
unidad, const apartado = body.apartado ?? APARTADO_DEFAULT
body.tipoPropuesta, const numMensualidades = body.numMensualidades ?? MENSUALIDADES_DEFAULT
body.acabados, const escrituracionDiferida = body.escrituracionDiferida ?? false
apartado,
body.descuento,
body.enganchePct,
body.numMensualidades,
reglas,
)
const precioLista = obtenerPrecioLista(unidad, body.acabados) const precioLista = obtenerPrecioLista(unidad, body.acabados)
const esquema: EsquemaFrasaResultado = calcularEsquemaFrasa({
precio: precioLista,
apartado,
numMensualidades,
escrituracionDiferida,
})
const cotizacion = await prisma.cotizacion.create({ const cotizacion = await prisma.cotizacion.create({
data: { data: {
@@ -121,18 +124,27 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
clienteNombre: body.clienteNombre, clienteNombre: body.clienteNombre,
clienteEmail: body.clienteEmail ?? null, clienteEmail: body.clienteEmail ?? null,
clienteTelefono: body.clienteTelefono ?? null, clienteTelefono: body.clienteTelefono ?? null,
tipoPropuesta: body.tipoPropuesta,
acabados: body.acabados, acabados: body.acabados,
apartado, apartado,
descuento: propuesta.descuento,
precioLista, precioLista,
precioFinal: propuesta.precioFinal, // Columnas nuevas del esquema único
enganche: propuesta.enganche, esquema: 'FRASA_2026',
// 0 => no se pudo resolver el plazo (no debería ocurrir: los desarrollos saldoFirma: esquema.saldoFirma,
// tienen plazo por defecto). Si no, el N elegido por el vendedor. pctEngancheFirma: PCT_ENGANCHE_FIRMA,
numMensualidades: propuesta.numMensualidades ?? 0, engancheDiferible: esquema.enganche,
mensualidad: propuesta.mensualidad, pctEscrituracion: PCT_ESCRITURACION,
pagoFinal: propuesta.pagoFinal, 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) 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), folio: folioCotizacion(cotizacion.id),
desarrollo: cotizacion.desarrollo, desarrollo: cotizacion.desarrollo,
unidadCodigo: cotizacion.unidadCodigo, unidadCodigo: cotizacion.unidadCodigo,
// Esquema único FRASA 2026 (filas nuevas) + legacy (filas BANCARIO, …)
esquema: cotizacion.esquema,
tipoPropuesta: cotizacion.tipoPropuesta, tipoPropuesta: cotizacion.tipoPropuesta,
precioLista: cotizacion.precioLista,
apartado: cotizacion.apartado,
saldoFirma: cotizacion.saldoFirma,
engancheDiferible: cotizacion.engancheDiferible,
escrituracion: cotizacion.escrituracion,
escrituracionDiferida: cotizacion.escrituracionDiferida,
precioFinal: cotizacion.precioFinal, precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche, enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades, numMensualidades: cotizacion.numMensualidades,

View File

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
calcularEsquemaFrasa,
validarEsquemaInput,
PCT_ENGANCHE_FIRMA,
PCT_ENGANCHE,
PCT_ESCRITURACION,
MENSUALIDADES_DEFAULT,
MENSUALIDADES_MIN,
MENSUALIDADES_MAX,
} from './cotizacion-esquema.service.js'
/**
* Esquema único FRASA 2026: apartado libre + enganche de firma 2.5% +
* enganche 7.5% diferible + escrituración 5.5% (firma o diferida). Servicio
* puro (sin prisma): el API recalcula server-side y no confía en los montos
* del cliente. Debe dar EXACTAMENTE los mismos números que
* apps/cotizador/src/lib/esquema-frasa.ts (caso dorado del cliente).
*/
/** Caso dorado del cliente (28 sep 2026): casa de $1,000,000 */
const CASO = { precio: 1000000, apartado: 15000, numMensualidades: 14 }
describe('calcularEsquemaFrasa — caso dorado $1,000,000 / apartado $15,000', () => {
it('escrituración de contado: firma $65,000 y mensualidades de $75,000/14', () => {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: false })
expect(r.engancheFirmaTotal).toBe(25000) // 2.5%
expect(r.saldoFirma).toBe(10000) // 25,000 − 15,000 apartado acreditado
expect(r.excedenteApartado).toBe(0)
expect(r.enganche).toBe(75000) // 7.5%
expect(r.escrituracion).toBe(55000) // 5.5%
expect(r.pagoUnicoFirma).toBe(65000) // 10,000 + 55,000
expect(r.baseDiferible).toBe(75000)
expect(r.mensualidad).toBeCloseTo(75000 / 14, 2) // 5,357.14
expect(r.totalPagado).toBeCloseTo(1000000 * (PCT_ENGANCHE_FIRMA + PCT_ENGANCHE + PCT_ESCRITURACION), 2)
})
it('escrituración diferida: firma $10,000 y mensualidades de $130,000/14', () => {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: true })
expect(r.saldoFirma).toBe(10000)
expect(r.pagoUnicoFirma).toBe(10000)
expect(r.baseDiferible).toBe(130000) // 75,000 + 55,000
expect(r.mensualidad).toBeCloseTo(130000 / 14, 2) // 9,285.71
})
it('el total siempre cuadra: apartado + firma + diferible', () => {
for (const dif of [false, true]) {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: dif })
expect(r.apartado + r.pagoUnicoFirma + r.baseDiferible).toBeCloseTo(r.totalPagado, 6)
expect(r.totalPagado).toBeCloseTo(155000, 2) // 15.5% del precio
}
})
})
describe('calcularEsquemaFrasa — apartado vs enganche de firma', () => {
it('apartado exactamente el 2.5%: saldo a firma 0', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 25000, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(0)
expect(r.excedenteApartado).toBe(0)
expect(r.enganche).toBe(75000)
})
it('apartado mayor al 2.5%: el excedente se acredita al enganche (no se cobra dos veces)', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 30000, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(0)
expect(r.excedenteApartado).toBe(5000)
expect(r.enganche).toBe(70000) // 75,000 − 5,000
expect(r.totalPagado).toBeCloseTo(155000, 2) // 30k + 70k + 55k
})
it('apartado $0: el cliente paga todo el 2.5% a la firma', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 0, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(25000)
expect(r.enganche).toBe(75000)
})
})
describe('validarEsquemaInput — guardas del esquema', () => {
const valido = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
it('acepta el caso dorado', () => {
expect(validarEsquemaInput(valido)).toBeNull()
})
it('rechaza precio no positivo, apartado negativo y apartado mayor al precio', () => {
expect(validarEsquemaInput({ ...valido, precio: 0 })).toContain('precio')
expect(validarEsquemaInput({ ...valido, apartado: -1 })).toContain('apartado')
expect(validarEsquemaInput({ ...valido, apartado: 2000000 })).toContain('superar')
})
it('rechaza mensualidades fuera de rango o no enteras', () => {
expect(validarEsquemaInput({ ...valido, numMensualidades: 0 })).toContain('Mínimo')
expect(validarEsquemaInput({ ...valido, numMensualidades: 37 })).toContain('Máximo')
expect(validarEsquemaInput({ ...valido, numMensualidades: 2.5 })).toContain('entero')
})
it('calcularEsquemaFrasa lanza el mensaje de validación', () => {
expect(() => calcularEsquemaFrasa({ ...valido, numMensualidades: 0 })).toThrow('Mínimo')
})
it('constantes del esquema (globales para todos los desarrollos)', () => {
expect(PCT_ENGANCHE_FIRMA).toBe(0.025)
expect(PCT_ENGANCHE).toBe(0.075)
expect(PCT_ESCRITURACION).toBe(0.055)
expect(MENSUALIDADES_DEFAULT).toBe(14)
expect(MENSUALIDADES_MIN).toBe(1)
expect(MENSUALIDADES_MAX).toBe(36)
})
})

View File

@@ -0,0 +1,103 @@
/**
* Esquema de cotización FRASA 2026 (flujo único, aprobado por el cliente):
*
* 1. APARTADO: cantidad libre (sin mínimo), aparta la unidad. Hoy.
* 2. ENGANCHE DE FIRMA: 2.5% del precio. El apartado se acredita DENTRO de
* este concepto → lo que se paga a la firma es el saldo restante
* (si el apartado supera el 2.5%, el excedente pasa al enganche).
* 3. ENGANCHE: 7.5% del precio, diferible en N mensualidades (N a elección
* del vendedor).
* 4. GASTOS DE ESCRITURACIÓN: 5.5% del precio. El cliente los salda en un
* pago a la firma O los difiere junto con el enganche.
*
* Espejo en TypeScript de apps/cotizador/src/lib/esquema-frasa.ts: las fórmulas
* deben mantenerse idénticas en ambos lados (el API recalcula server-side y no
* confía en los montos que manda el cliente). Servicio puro: no toca prisma.
* Porcentajes globales para todos los desarrollos (confirmado por el cliente
* el 28 sep 2026).
*/
export const PCT_ENGANCHE_FIRMA = 0.025
export const PCT_ENGANCHE = 0.075
export const PCT_ESCRITURACION = 0.055
export const MENSUALIDADES_DEFAULT = 14
export const MENSUALIDADES_MIN = 1
export const MENSUALIDADES_MAX = 36
export interface EsquemaFrasaInput {
/** Precio de la unidad (ya resuelto acabados/precio único) */
precio: number
/** Apartado: cantidad libre (>= 0) */
apartado: number
/** Número de mensualidades del diferimiento (elección del vendedor) */
numMensualidades: number
/** true = la escrituración se difiere con el enganche; false = se paga a la firma */
escrituracionDiferida: boolean
}
export interface EsquemaFrasaResultado {
precio: number
apartado: number
/** 2.5% del precio (concepto completo) */
engancheFirmaTotal: number
/** Lo que se paga a la firma por el enganche de firma (>= 0) */
saldoFirma: number
/** Apartado que excedió el 2.5% y se acredita al enganche (>= 0) */
excedenteApartado: number
/** Enganche del 7.5%, neto del excedente del apartado */
enganche: number
escrituracion: number
escrituracionDiferida: boolean
/** Todo lo que se liquida a la firma: saldoFirma + escrituración si no se difiere */
pagoUnicoFirma: number
/** Lo que entra al diferimiento: enganche (+ escrituración si se difiere) */
baseDiferible: number
numMensualidades: number
mensualidad: number
/** apartado + firma + diferible — debe cuadrar con el precio saldado */
totalPagado: number
}
/** Error de validación de entrada; null si es válida */
export function validarEsquemaInput(input: EsquemaFrasaInput): string | null {
if (!Number.isFinite(input.precio) || input.precio <= 0) return 'El precio debe ser mayor a cero'
if (!Number.isFinite(input.apartado) || input.apartado < 0) return 'El apartado no puede ser negativo'
if (input.apartado > input.precio) return 'El apartado no puede superar el precio de la propiedad'
if (!Number.isInteger(input.numMensualidades)) return 'Las mensualidades deben ser un número entero'
if (input.numMensualidades < MENSUALIDADES_MIN) return `Mínimo ${MENSUALIDADES_MIN} mensualidad`
if (input.numMensualidades > MENSUALIDADES_MAX) return `Máximo ${MENSUALIDADES_MAX} mensualidades`
return null
}
export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResultado {
const error = validarEsquemaInput(input)
if (error) throw new Error(error)
const { precio, apartado, numMensualidades, escrituracionDiferida } = input
const engancheFirmaTotal = precio * PCT_ENGANCHE_FIRMA
const saldoFirma = Math.max(0, engancheFirmaTotal - apartado)
const excedenteApartado = Math.max(0, apartado - engancheFirmaTotal)
const enganche = precio * PCT_ENGANCHE - excedenteApartado
const escrituracion = precio * PCT_ESCRITURACION
const pagoUnicoFirma = saldoFirma + (escrituracionDiferida ? 0 : escrituracion)
const baseDiferible = enganche + (escrituracionDiferida ? escrituracion : 0)
const mensualidad = baseDiferible / numMensualidades
const totalPagado = apartado + pagoUnicoFirma + baseDiferible
return {
precio,
apartado,
engancheFirmaTotal,
saldoFirma,
excedenteApartado,
enganche,
escrituracion,
escrituracionDiferida,
pagoUnicoFirma,
baseDiferible,
numMensualidades,
mensualidad,
totalPagado,
}
}

View File

@@ -1,158 +0,0 @@
import { useState } from 'react'
import type { PropuestaResultado } from '../lib/calculations'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
precioLista: number
apartado: number
propuestas: PropuestaResultado[]
titulos: string[]
}
interface Fila {
etiqueta: string
obtener: (p: PropuestaResultado, i: number) => string
destacada?: boolean
}
/** Sección colapsable con la tabla comparativa de las 4 propuestas */
export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulos }: Props) {
const [abierto, setAbierto] = useState(false)
const totales = propuestas.map((p) => totalPagado(p, apartado))
// null (plazo desconocido) nunca gana el menor total
const ganador = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
const filas: Fila[] = [
{ etiqueta: 'Precio de lista', obtener: () => formatoMoneda(precioLista) },
{
etiqueta: 'Descuento',
obtener: (p) => `${formatoMoneda(p.montoDescuento)} (${formatoPorcentaje(p.descuento)})`,
},
{ etiqueta: 'Precio final c/desc.', obtener: (p) => formatoMoneda(p.precioFinal) },
{ etiqueta: 'Enganche', obtener: (p) => formatoMoneda(p.enganche) },
{
etiqueta: 'Mensualidad',
obtener: (p) =>
p.notaMensualidades
? `— (${p.notaMensualidades})`
: p.mensualidad === null
? 'N/A'
: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
},
{
etiqueta: 'Total mensualidades',
obtener: (p) =>
p.notaMensualidades
? '—'
: p.mensualidadesTotal === null
? 'N/A'
: formatoMoneda(p.mensualidadesTotal),
},
{ etiqueta: 'Pago final', obtener: (p) => formatoMoneda(p.pagoFinal) },
{
etiqueta: 'Total pagado',
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i])),
destacada: true,
},
{
etiqueta: 'Diferencia vs precio de lista',
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i] - precioLista)),
},
]
/** Borde acento que enmarca la columna con el menor total pagado */
const bordeColumna = (i: number, posicion: 'inicio' | 'media' | 'fin'): string => {
if (i !== ganador) return ''
const laterales = 'border-x-2 border-x-accent'
if (posicion === 'inicio') return `${laterales} border-t-2 border-t-accent`
if (posicion === 'fin') return `${laterales} border-b-2 border-b-accent`
return laterales
}
return (
<section aria-labelledby="comparador-titulo">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Comparar propuestas
</h2>
<button
type="button"
aria-expanded={abierto}
aria-controls="comparador-contenido"
onClick={() => setAbierto((v) => !v)}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{abierto ? 'Ocultar comparación' : 'Comparar propuestas'}
</button>
</div>
{abierto && (
<div id="comparador-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
<th scope="col" className="w-48 p-3 text-left align-bottom">
<span className="sr-only">Concepto</span>
</th>
{propuestas.map((p, i) => (
<th
key={p.tipo}
scope="col"
className={`p-3 text-right align-bottom ${bordeColumna(i, 'inicio')}`}
>
<span className="block text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
{titulos[i] ?? p.tipo}
</span>
{i === ganador && (
<span className="mt-2 inline-block rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
Menor total
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{filas.map((fila, indiceFila) => (
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<th
scope="row"
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
>
{fila.etiqueta}
</th>
{propuestas.map((p, i) => (
<td
key={p.tipo}
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
fila.destacada
? `font-semibold ${i === ganador ? 'text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'}`
: 'text-[#111827] dark:text-[#E2E8F0]'
} ${bordeColumna(
i,
indiceFila === filas.length - 1 ? 'fin' : 'media',
)}`}
>
{fila.obtener(p, i)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
El enganche ya incluye la deducción del apartado. El total pagado suma apartado +
enganche + mensualidades + pago final.
</p>
</div>
)}
</section>
)
}

View File

@@ -1,203 +0,0 @@
import { useMemo, useState } from 'react'
import type { Unidad, TipoPropuesta } from '../types'
import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades'
import { formatoMoneda } from '../lib/formatters'
import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas'
interface Props {
unidades: Unidad[]
/** Tipo de propuesta activa en el cotizador (se aplica a ambas unidades) */
tipo: TipoPropuesta
apartado: number
acabados: boolean
/** Reglas comerciales por desarrollo (vacío => constantes) */
reglas?: ReglasDesarrollo
}
const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`)
interface Fila {
etiqueta: string
obtener: (c: ColumnaComparador) => string
destacada?: boolean
}
/**
* 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
* 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, reglas = SIN_REGLAS }: Props) {
const [abierto, setAbierto] = useState(false)
const [codigoA, setCodigoA] = 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 m = new Map<string, Unidad>()
for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
return m
}, [disponibles])
const unidadA = porCodigo.get(codigoA) ?? null
const unidadB = porCodigo.get(codigoB) ?? null
const comparacion = useMemo(
() =>
unidadA && unidadB
? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas)
: null,
[unidadA, unidadB, tipo, apartado, acabados, reglas],
)
const filas: Fila[] = [
{ etiqueta: 'Desarrollo', obtener: (c) => c.desarrolloNombre },
{ etiqueta: 'Modelo', obtener: (c) => c.unidad.modelo },
{ etiqueta: 'Estatus', obtener: (c) => c.unidad.status },
{ etiqueta: 'Sup. construcción', obtener: (c) => formatoM2(c.supConstruccion) },
{ etiqueta: 'Precio de lista', obtener: (c) => formatoMoneda(c.precioLista) },
{ etiqueta: 'Precio final c/desc.', obtener: (c) => formatoMoneda(c.propuesta.precioFinal) },
{ etiqueta: 'Enganche', obtener: (c) => formatoMoneda(c.propuesta.enganche) },
{
etiqueta: 'Mensualidad',
obtener: (c) =>
c.propuesta.notaMensualidades
? `— (${c.propuesta.notaMensualidades})`
: c.propuesta.mensualidad === null
? 'N/A'
: `${c.propuesta.numMensualidades} × ${formatoMoneda(c.propuesta.mensualidad)}`,
},
{ etiqueta: 'Pago final', obtener: (c) => formatoMoneda(c.propuesta.pagoFinal) },
{
etiqueta: 'Total pagado',
obtener: (c) => (c.total === null ? '—' : formatoMoneda(c.total)),
destacada: true,
},
]
const selector = (id: string, valor: string, onChange: (v: string) => void, etiqueta: string) => (
<div>
<label htmlFor={id} className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
{etiqueta}
</label>
<select
id={id}
value={valor}
onChange={(e) => onChange(e.target.value)}
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>
{desarrollos.map((dev) => {
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
if (delDesarrollo.length === 0) return null
return (
<optgroup key={dev} label={desarrolloEfectivo(reglas, dev).nombre}>
{delDesarrollo.map((u) => (
<option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}>
{u.codigo} — {u.modelo}
</option>
))}
</optgroup>
)
})}
</select>
</div>
)
return (
<section aria-labelledby="comparador-unidades-titulo">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-unidades-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Comparar unidades
</h2>
<button
type="button"
aria-expanded={abierto}
aria-controls="comparador-unidades-contenido"
onClick={() => setAbierto((v) => !v)}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{abierto ? 'Ocultar comparación' : 'Comparar 2 unidades'}
</button>
</div>
{abierto && (
<div id="comparador-unidades-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="grid gap-6 md:grid-cols-2">
{selector('comparador-a', codigoA, setCodigoA, 'Unidad A')}
{selector('comparador-b', codigoB, setCodigoB, 'Unidad B')}
</div>
{!comparacion ? (
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">
Selecciona dos unidades para ver la comparación lado a lado.
</p>
) : (
<div className="mt-6 overflow-x-auto">
<table className="w-full min-w-[480px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
<th scope="col" className="w-48 p-3 text-left">
<span className="sr-only">Concepto</span>
</th>
{[comparacion.a, comparacion.b].map((c) => (
<th
key={`${c.desarrolloId}|${c.unidad.codigo}`}
scope="col"
className="p-3 text-right text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]"
>
{c.unidad.codigo}
</th>
))}
</tr>
</thead>
<tbody>
{filas.map((fila) => (
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<th
scope="row"
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
>
{fila.etiqueta}
</th>
{[comparacion.a, comparacion.b].map((c) => (
<td
key={`${c.desarrolloId}|${c.unidad.codigo}`}
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
fila.destacada ? 'font-semibold text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'
}`}
>
{fila.obtener(c)}
</td>
))}
</tr>
))}
</tbody>
</table>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
</p>
</div>
)}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,164 @@
import {
MENSUALIDADES_MAX,
MENSUALIDADES_MIN,
PCT_ENGANCHE,
PCT_ENGANCHE_FIRMA,
PCT_ESCRITURACION,
type EsquemaFrasaResultado,
} from '../lib/esquema-frasa'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
/** Esquema FRASA 2026 calculado (siempre presente: solo se renderiza con unidad) */
esquema: EsquemaFrasaResultado
numMensualidades: number
onNumMensualidades: (n: number) => void
escrituracionDiferida: boolean
onEscrituracionDiferida: (v: boolean) => void
}
const pasoBase =
'flex-1 rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#0F172A]'
const pasoTitulo = 'text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]'
const pasoMonto = 'mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]'
/**
* Panel único del esquema FRASA 2026 (reemplaza a las 4 propuestas):
* línea de tiempo Hoy → A la firma → Mensualidades, el detalle de conceptos
* (enganche de firma 2.5%, enganche 7.5%, escrituración 5.5%) y los controles
* del diferimiento: número de mensualidades (1..36) y si la escrituración se
* difiere con el enganche o se paga a la firma.
*/
export function EsquemaPanel({
esquema,
numMensualidades,
onNumMensualidades,
escrituracionDiferida,
onEscrituracionDiferida,
}: Props) {
return (
<div className="space-y-6">
{/* Línea de tiempo del esquema */}
<div className="flex flex-col gap-4 sm:flex-row">
<div className={pasoBase}>
<p className={pasoTitulo}>Hoy</p>
<p className={pasoMonto}>{formatoMoneda(esquema.apartado)}</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">Apartado (aparta la unidad)</p>
</div>
<div className={pasoBase}>
<p className={pasoTitulo}>A la firma</p>
<p className={pasoMonto}>{formatoMoneda(esquema.pagoUnicoFirma)}</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Saldo de enganche de firma
{!escrituracionDiferida && ' + escrituración'}
</p>
</div>
<div className={pasoBase}>
<p className={pasoTitulo}>Después</p>
<p className={pasoMonto}>
{esquema.numMensualidades} × {formatoMoneda(esquema.mensualidad)}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Total diferible {formatoMoneda(esquema.baseDiferible)}
</p>
</div>
</div>
{/* Detalle de conceptos */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
Esquema FRASA 2026 — conceptos
</h2>
<dl className="divide-y divide-[#E5E7EB] dark:divide-[#334155]">
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Enganche de firma ({formatoPorcentaje(PCT_ENGANCHE_FIRMA)})
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.engancheFirmaTotal)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
− apartado acreditado → saldo a la firma {formatoMoneda(esquema.saldoFirma)}
</span>
</dd>
</div>
{esquema.excedenteApartado > 0 && (
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Apartado que excede el 2.5%
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.excedenteApartado)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
acreditado al enganche
</span>
</dd>
</div>
)}
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Enganche ({formatoPorcentaje(PCT_ENGANCHE)}) diferible
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.enganche)}
</dd>
</div>
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Gastos de escrituración ({formatoPorcentaje(PCT_ESCRITURACION)})
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.escrituracion)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{escrituracionDiferida ? 'diferidos con el enganche' : 'pagados a la firma'}
</span>
</dd>
</div>
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Total del esquema (15.5% del precio)
</dt>
<dd className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.totalPagado)}
</dd>
</div>
</dl>
</section>
{/* Controles del diferimiento */}
<section className="flex flex-col gap-4 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:flex-row sm:items-end">
<div className="flex-1">
<label htmlFor="mensualidades-esquema" className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Mensualidades del diferimiento
</label>
<select
id="mensualidades-esquema"
value={numMensualidades}
onChange={(e) => onNumMensualidades(Number(e.target.value))}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
>
{Array.from({ length: MENSUALIDADES_MAX - MENSUALIDADES_MIN + 1 }, (_, i) => MENSUALIDADES_MIN + i).map(
(n) => (
<option key={n} value={n}>
{n} mensualidades
</option>
),
)}
</select>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
De {MENSUALIDADES_MIN} a {MENSUALIDADES_MAX}; cada una de {formatoMoneda(esquema.mensualidad)}
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 pb-2.5 text-sm text-[#374151] dark:text-[#CBD5E1]">
<input
id="escrituracion-diferida"
type="checkbox"
checked={escrituracionDiferida}
onChange={(e) => onEscrituracionDiferida(e.target.checked)}
className="h-4 w-4 rounded border-[#D1D5DB] accent-accent"
/>
Diferir escrituración con el enganche
</label>
</section>
</div>
)
}

View File

@@ -11,6 +11,9 @@ interface Props {
} }
const TITULO_PROPUESTA: Record<string, string> = { const TITULO_PROPUESTA: Record<string, string> = {
// Esquema único FRASA 2026 (cotizaciones nuevas); las viejas muestran su
// tipoPropuesta legacy (BANCARIO, INFONAVIT, CONTADO, SEMI_CONTADO)
FRASA_2026: 'Esquema FRASA',
BANCARIO: 'Crédito Bancario', BANCARIO: 'Crédito Bancario',
INFONAVIT: 'Crédito Infonavit-Cofinavit', INFONAVIT: 'Crédito Infonavit-Cofinavit',
COFINAVIT: 'Crédito Infonavit-Cofinavit', COFINAVIT: 'Crédito Infonavit-Cofinavit',

View File

@@ -1,62 +0,0 @@
import type { PropuestaResultado } from '../lib/calculations'
import { formatoMoneda } from '../lib/formatters'
interface Props {
numero: number
titulo: string
propuesta: PropuestaResultado
}
function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; nota?: string }) {
return (
<div className="flex items-baseline justify-between gap-2 py-2">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{etiqueta}
{nota && (
<span className="mt-0.5 block text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{nota}
</span>
)}
</span>
<span className="text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{valor}</span>
</div>
)
}
/** Tarjeta de una propuesta comercial (el descuento ya no es editable: usa el del Excel) */
export function ProposalCard({ numero, titulo, propuesta }: Props) {
return (
<article className="flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-all duration-200 hover:shadow-md">
<header className="mb-6 border-b border-[#E5E7EB] pb-4">
<p className="text-xs font-semibold uppercase tracking-wider text-accent">
Propuesta comercial #{numero}
</p>
<h2 className="mt-1 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{titulo}</h2>
</header>
<div className="divide-y divide-[#E5E7EB]">
{propuesta.descuento > 0 && (
<Renglon etiqueta="Monto de descuento" valor={formatoMoneda(propuesta.montoDescuento)} />
)}
<Renglon etiqueta="Precio final c/desc." valor={formatoMoneda(propuesta.precioFinal)} />
<Renglon
etiqueta="Enganche"
nota="incluye deducción del apartado"
valor={formatoMoneda(propuesta.enganche)}
/>
{propuesta.notaMensualidades ? (
<Renglon etiqueta="Mensualidades" nota={propuesta.notaMensualidades} valor="—" />
) : propuesta.mensualidad !== null && propuesta.mensualidadesTotal !== null ? (
<Renglon
etiqueta="Mensualidades"
nota={`${propuesta.numMensualidades} × ${formatoMoneda(propuesta.mensualidad)}`}
valor={formatoMoneda(propuesta.mensualidadesTotal)}
/>
) : (
<Renglon etiqueta="Mensualidades" valor="N/A" />
)}
<Renglon etiqueta="Pago final" valor={formatoMoneda(propuesta.pagoFinal)} />
</div>
</article>
)
}

View File

@@ -1,31 +1,22 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react' import { act, cleanup, renderHook } from '@testing-library/react'
import { useCotizacion } from './useCotizacion' import { APARTADO_ESQUEMA_DEFAULT, useCotizacion } from './useCotizacion'
import { reglasDesdeApi, SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas' import { SIN_REGLAS } from '../lib/reglas'
import { DESARROLLOS } from '../data/desarrollos' import { MENSUALIDADES_DEFAULT, calcularEsquemaFrasa } from '../lib/esquema-frasa'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
/** /**
* Reglas comerciales configurables en el cotizador: las propuestas desactivadas * Estado central con el esquema único FRASA 2026: apartado libre (default
* por el admin dejan de ofrecerse y, si la elegida se desactiva, se avisa y se * $15,000), mensualidades del diferimiento (default 14) y escrituración a la
* cae a la primera vigente. * firma o diferida. Ya no hay propuestas: el resultado sale de
* calcularEsquemaFrasa (mismo motor que el API).
*/ */
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')! const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
function reglasDiagonales(activa: (tipo: string) => boolean): ReglasDesarrollo { function montar() {
return reglasDesdeApi({ const hook = renderHook(() => useCotizacion(SIN_REGLAS))
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(() => { act(() => {
hook.result.current.setUnidad(unidadDiagonal) hook.result.current.setUnidad(unidadDiagonal)
}) })
@@ -34,75 +25,128 @@ function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
afterEach(cleanup) afterEach(cleanup)
describe('useCotizacion y las reglas del admin', () => { describe('useCotizacion — esquema único FRASA 2026', () => {
it('sin reglas ofrece las 4 propuestas de siempre', () => { it('arranca con apartado $15,000, 14 mensualidades y escrituración a la firma', () => {
const { result } = montar() const { result } = montar()
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([ expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
'CREDITO_BANCARIO', expect(result.current.apartadoEfectivo).toBe(15000)
'CREDITO_INFONAVIT_COFINAVIT', expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
'CONTADO', expect(result.current.escrituracionDiferida).toBe(false)
'SEMI_CONTADO',
])
expect(result.current.avisoPropuestaDesactivada).toBeNull()
}) })
it('una propuesta desactivada no se ofrece ni se puede elegir', () => { it('el resultado cuadra con calcularEsquemaFrasa sobre el precio de lista', () => {
const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO')) const { result } = montar()
const { unidad, acabados } = result.current
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([ const esperado = calcularEsquemaFrasa({
'CREDITO_BANCARIO', precio: result.current.precioLista,
'CREDITO_INFONAVIT_COFINAVIT', apartado: 15000,
'SEMI_CONTADO', numMensualidades: 14,
]) escrituracionDiferida: false,
})
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') expect(result.current.resultado).toEqual(esperado)
// El esquema siempre liquida el 15.5% del precio (2.5 + 7.5 + 5.5)
// El admin la desactiva (llegan reglas nuevas) expect(result.current.resultado?.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
rerender({ reglas: reglasDiagonales((tipo) => tipo !== 'CONTADO') }) expect(unidad).toBe(unidadDiagonal)
expect(acabados).toBe(false)
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', () => { it('sin unidad no hay resultado (panel vacío)', () => {
const { result } = montar( const { result } = montar()
reglasDesdeApi({ act(() => {
DIAGONAL: { result.current.setUnidad(null)
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => })
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.06 } : p, expect(result.current.resultado).toBeNull()
), expect(result.current.precioLista).toBe(0)
},
}),
)
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', () => { it('el apartado libre alimenta el saldo a firma; el excedente acredita al enganche', () => {
const { result } = montar( const { result } = montar()
reglasDesdeApi({ const excedente = Math.round(result.current.precioLista * 0.025) + 5000
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') act(() => {
const precioLista = result.current.precioLista result.current.setApartado(excedente)
expect(bancario?.enganche).toBeCloseTo(0.05 * precioLista - result.current.apartadoEfectivo, 2) })
const r = result.current.resultado!
expect(r.excedenteApartado).toBeGreaterThan(0)
expect(r.saldoFirma).toBe(0)
// El total no cambia: el excedente se mueve de concepto, no se cobra dos veces
expect(r.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
})
it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => {
const { result } = montar()
act(() => {
result.current.setNumMensualidades(12)
})
const r = result.current.resultado!
expect(r.numMensualidades).toBe(12)
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / 12, 6)
})
it('escrituración diferida: sube el diferible y baja el pago a la firma', () => {
const { result } = montar()
act(() => {
result.current.setEscrituracionDiferida(true)
})
const r = result.current.resultado!
expect(r.escrituracionDiferida).toBe(true)
expect(r.baseDiferible).toBeCloseTo(r.enganche + r.escrituracion, 6)
expect(r.pagoUnicoFirma).toBeCloseTo(r.saldoFirma, 6)
})
it('restaurar carga una cotización guardada del esquema (N y escrituración)', () => {
const { result } = montar()
act(() => {
result.current.restaurar({
desarrollo: 'DIAGONAL',
unidad: unidadDiagonal,
acabados: true,
apartado: 20000,
numMensualidades: 24,
escrituracionDiferida: true,
})
})
expect(result.current.acabados).toBe(true)
expect(result.current.apartadoEfectivo).toBe(20000)
expect(result.current.numMensualidades).toBe(24)
expect(result.current.escrituracionDiferida).toBe(true)
expect(result.current.resultado?.numMensualidades).toBe(24)
})
it('restaurar una cotización legacy (sin N ni escrituración) cae a los defaults', () => {
const { result } = montar()
act(() => {
result.current.restaurar({
desarrollo: 'DIAGONAL',
unidad: unidadDiagonal,
acabados: false,
apartado: 20000,
})
})
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
})
it('nueva reinicia apartado, mensualidades y escrituración, y limpia la unidad', () => {
const { result } = montar()
act(() => {
result.current.setApartado(40000)
result.current.setNumMensualidades(6)
result.current.setEscrituracionDiferida(true)
})
act(() => {
result.current.nueva()
})
expect(result.current.unidad).toBeNull()
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
}) })
}) })

View File

@@ -1,25 +1,20 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useMemo, useState } from 'react'
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types' import type { Unidad, ParametrosDesarrollo } from '../types'
import { import { obtenerPrecioLista, resolverNumMensualidades } from '../lib/calculations'
APARTADO_DEFAULT,
calcularPropuestas,
obtenerPrecioLista,
resolverNumMensualidades,
validarEnganchePct,
type PropuestaResultado,
} from '../lib/calculations'
import { DESARROLLO_DEFAULT } from '../data/desarrollos' import { DESARROLLO_DEFAULT } from '../data/desarrollos'
import { import {
SIN_REGLAS, MENSUALIDADES_DEFAULT,
descuentosDefaultEfectivo, calcularEsquemaFrasa,
desarrolloEfectivo, type EsquemaFrasaResultado,
propuestasVigentes, } from '../lib/esquema-frasa'
type ReglasDesarrollo, import { SIN_REGLAS, desarrolloEfectivo, type ReglasDesarrollo } from '../lib/reglas'
} from '../lib/reglas'
/** Apartado con el que arranca (y se reinicia) el cotizador: cantidad libre */
export const APARTADO_ESQUEMA_DEFAULT = 15000
interface UseCotizacionResult { interface UseCotizacionResult {
desarrollo: string desarrollo: string
/** Cambia de desarrollo: limpia la unidad elegida y reinicia los descuentos */ /** Cambia de desarrollo: limpia la unidad elegida */
setDesarrollo: (id: string) => void setDesarrollo: (id: string) => void
params: ParametrosDesarrollo params: ParametrosDesarrollo
unidad: Unidad | null unidad: Unidad | null
@@ -30,25 +25,17 @@ interface UseCotizacionResult {
setApartado: (v: number) => void setApartado: (v: number) => void
/** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */ /** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */
apartadoEfectivo: number apartadoEfectivo: number
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */ /** Número de mensualidades del diferimiento (elección del vendedor, 1..36) */
enganchePct: number | null numMensualidades: number
setEnganchePct: (v: number | null) => void setNumMensualidades: (n: number) => void
/** Mensaje de error del enganche capturado; null si es válido */ /** true = la escrituración (5.5%) se difiere con el enganche; false = se paga a la firma */
errorEnganche: string | null escrituracionDiferida: boolean
descuentos: DescuentosPropuestas setEscrituracionDiferida: (v: boolean) => void
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void
/** Índice de la propuesta elegida (para guardar/compartir) */
propuestaIndex: number
setPropuestaIndex: (i: number) => void
precioLista: number precioLista: number
/** Número de mensualidades elegido por el vendedor; null = usa el calculado */ /** Esquema FRASA 2026 calculado; null mientras no hay unidad elegida */
numMensualidades: number | null resultado: EsquemaFrasaResultado | null
setNumMensualidades: (n: number | null) => void /** Meses restantes para la entrega de la unidad (ficha técnica); null si no se pueden resolver */
/** Número efectivo de mensualidades (la elegida o la calculada) */
numMeses: number | null numMeses: number | null
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") */
@@ -61,60 +48,34 @@ export interface EstadoRestaurar {
unidad: Unidad unidad: Unidad
acabados: boolean acabados: boolean
apartado: number apartado: number
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */ /** Mensualidades del diferimiento guardadas (default 14) */
descuento?: number numMensualidades?: number
propuestaIndex?: number /** Escrituración diferida guardada (default false) */
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */ escrituracionDiferida?: boolean
numMensualidades?: number | null
} }
/** /**
* Estado central de la cotización: desarrollo, unidad, acabados, apartado y * Estado central de la cotización con el esquema único FRASA 2026:
* descuentos. Las reglas comerciales llegan del API (con respaldo en las * desarrollo, unidad, acabados, apartado libre, mensualidades del
* constantes): si el admin desactiva una propuesta, deja de ofrecerse y, si era * diferimiento (1..36, default 14) y si la escrituración se difiere o se paga
* la elegida, se avisa y se cae a la primera vigente. * a la firma. El resultado sale de calcularEsquemaFrasa (módulo puro, mismo
* motor que el API). Ya no hay propuestas: descuentos, enganche en % e índice
* de propuesta desaparecieron con las 4 propuestas.
*/ */
export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult { 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_ESQUEMA_DEFAULT)
const [enganchePct, setEnganchePct] = useState<number | null>(null) const [numMensualidades, setNumMensualidades] = useState<number>(MENSUALIDADES_DEFAULT)
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>( const [escrituracionDiferida, setEscrituracionDiferida] = useState(false)
descuentosDefaultEfectivo(reglas, DESARROLLO_DEFAULT),
)
const [propuestaIndex, setPropuestaIndex] = useState(0)
const [numMensualidades, setNumMensualidades] = useState<number | null>(null)
const [avisoPropuestaDesactivada, setAvisoPropuestaDesactivada] = useState<string | null>(null)
const params = desarrolloEfectivo(reglas, 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(descuentosDefaultEfectivo(reglas, id))
setPropuestaIndex(0)
setNumMensualidades(null)
}
const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => {
setDescuentos((prev) => ({ ...prev, [clave]: valor }))
} }
const restaurar = (estado: EstadoRestaurar) => { const restaurar = (estado: EstadoRestaurar) => {
@@ -122,38 +83,23 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setUnidad(estado.unidad) setUnidad(estado.unidad)
setAcabados(estado.acabados) setAcabados(estado.acabados)
setApartado(estado.apartado) setApartado(estado.apartado)
const base = descuentosDefaultEfectivo(reglas, estado.desarrollo) setNumMensualidades(estado.numMensualidades ?? MENSUALIDADES_DEFAULT)
const paramsRestaurados = desarrolloEfectivo(reglas, estado.desarrollo) setEscrituracionDiferida(estado.escrituracionDiferida ?? false)
const vigentes = propuestasVigentes(paramsRestaurados)
if (estado.descuento !== undefined) {
const idx = estado.propuestaIndex ?? 0
const clave = vigentes[idx]?.claveDescuento
if (clave) base[clave] = estado.descuento
}
setDescuentos(base)
setPropuestaIndex(estado.propuestaIndex ?? 0)
setNumMensualidades(estado.numMensualidades ?? null)
} }
const nueva = () => { const nueva = () => {
setUnidad(null) setUnidad(null)
setAcabados(false) setAcabados(false)
setApartado(APARTADO_DEFAULT) setApartado(APARTADO_ESQUEMA_DEFAULT)
setEnganchePct(null) setNumMensualidades(MENSUALIDADES_DEFAULT)
setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo)) setEscrituracionDiferida(false)
setPropuestaIndex(0)
setNumMensualidades(null)
setAvisoPropuestaDesactivada(null)
} }
const numMesesCalculado = useMemo( const numMeses = useMemo(
() => (unidad ? resolverNumMensualidades(unidad, params) : null), () => (unidad ? resolverNumMensualidades(unidad, params) : null),
[unidad, params], [unidad, params],
) )
/** Número efectivo de mensualidades: la elegida por el vendedor, o la calculada */
const numMeses = numMensualidades ?? numMesesCalculado
const precioLista = useMemo( const precioLista = useMemo(
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0), () => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
[unidad, acabados], [unidad, acabados],
@@ -163,46 +109,15 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
// incluso $0; solo se acota a >= 0 como guarda contra capturas raras // incluso $0; solo se acota a >= 0 como guarda contra capturas raras
const apartadoEfectivo = Math.max(0, apartado) const apartadoEfectivo = Math.max(0, apartado)
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo const resultado = useMemo<EsquemaFrasaResultado | null>(() => {
const errorEnganche = validarEnganchePct(enganchePct) if (!unidad || precioLista <= 0) return null
const engancheEfectivo = errorEnganche === null && enganchePct !== null ? enganchePct / 100 : null return calcularEsquemaFrasa({
precio: precioLista,
const propuestas = useMemo(() => {
if (!unidad) return []
return calcularPropuestas({
unidad,
acabados,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
descuentos, numMensualidades,
desarrollo, escrituracionDiferida,
reglas: params,
enganchePct: engancheEfectivo,
numMeses: numMeses ?? undefined,
}) })
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, params, engancheEfectivo, numMeses]) }, [unidad, precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida])
// 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,
@@ -215,19 +130,13 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
apartado, apartado,
setApartado, setApartado,
apartadoEfectivo, apartadoEfectivo,
enganchePct,
setEnganchePct,
errorEnganche,
descuentos,
setDescuento,
propuestaIndex,
setPropuestaIndex,
precioLista,
numMensualidades, numMensualidades,
setNumMensualidades, setNumMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
precioLista,
resultado,
numMeses, numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar, restaurar,
nueva, nueva,
} }

View File

@@ -1,10 +1,11 @@
import type { PropuestaResultado } from './calculations' import type { EsquemaFrasaResultado } from './esquema-frasa'
import { formatoMoneda, formatoFechaLarga } from './formatters' import { formatoMoneda, formatoFechaLarga } from './formatters'
export interface DatosCompartir { export interface DatosCompartir {
unidadCodigo: string unidadCodigo: string
desarrolloNombre: string desarrolloNombre: string
propuesta: PropuestaResultado /** Esquema único FRASA 2026 calculado */
esquema: EsquemaFrasaResultado
apartado: number apartado: number
vigencia: Date vigencia: Date
/** Folio y URL pública solo existen si la cotización ya se guardó en el API */ /** Folio y URL pública solo existen si la cotización ya se guardó en el API */
@@ -13,8 +14,9 @@ export interface DatosCompartir {
} }
/** /**
* Texto resumen de la propuesta, listo para copiar/pegar en WhatsApp. * Texto resumen del esquema FRASA 2026, listo para copiar/pegar en WhatsApp.
* Incluye unidad, desarrollo, propuesta elegida, montos y vigencia. * Incluye unidad, desarrollo, línea de tiempo (hoy / firma / mensualidades)
* y vigencia.
*/ */
export function textoCompartir(d: DatosCompartir): string { export function textoCompartir(d: DatosCompartir): string {
const lineas: string[] = [] const lineas: string[] = []
@@ -23,20 +25,14 @@ export function textoCompartir(d: DatosCompartir): string {
) )
lineas.push(`Desarrollo: ${d.desarrolloNombre}`) lineas.push(`Desarrollo: ${d.desarrolloNombre}`)
lineas.push(`Unidad: ${d.unidadCodigo}`) lineas.push(`Unidad: ${d.unidadCodigo}`)
lineas.push(`Propuesta: ${d.propuesta.titulo}`) lineas.push(`Precio de lista: ${formatoMoneda(d.esquema.precio)}`)
lineas.push(`Precio final c/desc: ${formatoMoneda(d.propuesta.precioFinal)}`) lineas.push(`Apartado (hoy): ${formatoMoneda(d.apartado)}`)
lineas.push(`Apartado: ${formatoMoneda(d.apartado)}`) lineas.push(`Pago a la firma: ${formatoMoneda(d.esquema.pagoUnicoFirma)}`)
lineas.push(`Enganche: ${formatoMoneda(d.propuesta.enganche)}`) lineas.push(
if (d.propuesta.notaMensualidades) { `Mensualidades: ${d.esquema.numMensualidades} × ${formatoMoneda(d.esquema.mensualidad)} (total diferible ${formatoMoneda(d.esquema.baseDiferible)})`,
lineas.push(`Mensualidades: — (${d.propuesta.notaMensualidades})`) )
} else if (d.propuesta.mensualidad !== null) { lineas.push(`Escrituración 5.5%: ${d.esquema.escrituracionDiferida ? 'diferida con el enganche' : `pagada a la firma (${formatoMoneda(d.esquema.escrituracion)})`}`)
lineas.push( lineas.push(`Total del esquema: ${formatoMoneda(d.esquema.totalPagado)}`)
`Mensualidades: ${d.propuesta.numMensualidades} × ${formatoMoneda(d.propuesta.mensualidad)}`,
)
} else {
lineas.push('Mensualidades: N/A')
}
lineas.push(`Pago final: ${formatoMoneda(d.propuesta.pagoFinal)}`)
lineas.push(`Vigencia: hasta el ${formatoFechaLarga(d.vigencia)}`) lineas.push(`Vigencia: hasta el ${formatoFechaLarga(d.vigencia)}`)
if (d.urlPublica) lineas.push(`Consulta: ${d.urlPublica}`) if (d.urlPublica) lineas.push(`Consulta: ${d.urlPublica}`)
return lineas.join('\n') return lineas.join('\n')

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import {
calcularEsquemaFrasa,
validarEsquemaInput,
PCT_ENGANCHE_FIRMA,
PCT_ENGANCHE,
PCT_ESCRITURACION,
} from './esquema-frasa'
/** Caso dorado del cliente (28 sep 2026): casa de $1,000,000 */
const CASO = { precio: 1000000, apartado: 15000, numMensualidades: 14 }
describe('calcularEsquemaFrasa — caso dorado $1,000,000 / apartado $15,000', () => {
it('escrituración de contado: firma $65,000 y mensualidades de $75,000/14', () => {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: false })
expect(r.engancheFirmaTotal).toBe(25000) // 2.5%
expect(r.saldoFirma).toBe(10000) // 25,000 − 15,000 apartado acreditado
expect(r.excedenteApartado).toBe(0)
expect(r.enganche).toBe(75000) // 7.5%
expect(r.escrituracion).toBe(55000) // 5.5%
expect(r.pagoUnicoFirma).toBe(65000) // 10,000 + 55,000
expect(r.baseDiferible).toBe(75000)
expect(r.mensualidad).toBeCloseTo(75000 / 14, 2) // 5,357.14
expect(r.totalPagado).toBeCloseTo(1000000 * (PCT_ENGANCHE_FIRMA + PCT_ENGANCHE + PCT_ESCRITURACION) + 0, 2)
})
it('escrituración diferida: firma $10,000 y mensualidades de $130,000/14', () => {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: true })
expect(r.saldoFirma).toBe(10000)
expect(r.pagoUnicoFirma).toBe(10000)
expect(r.baseDiferible).toBe(130000) // 75,000 + 55,000
expect(r.mensualidad).toBeCloseTo(130000 / 14, 2) // 9,285.71
})
it('el total siempre cuadra: apartado + firma + diferible', () => {
for (const dif of [false, true]) {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: dif })
expect(r.apartado + r.pagoUnicoFirma + r.baseDiferible).toBeCloseTo(r.totalPagado, 6)
// y equivale al 15.5% del precio (2.5 + 7.5 + 5.5) salvo excedentes
expect(r.totalPagado).toBeCloseTo(155000, 2)
}
})
})
describe('calcularEsquemaFrasa — apartado vs enganche de firma', () => {
it('apartado exactamente el 2.5%: saldo a firma 0', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 25000, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(0)
expect(r.excedenteApartado).toBe(0)
expect(r.enganche).toBe(75000)
})
it('apartado mayor al 2.5%: el excedente se acredita al enganche', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 30000, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(0)
expect(r.excedenteApartado).toBe(5000)
expect(r.enganche).toBe(70000) // 75,000 − 5,000
expect(r.totalPagado).toBeCloseTo(155000, 2) // no se cobra dos veces: 30k + 70k + 55k
})
it('apartado mínimo (sin mínimo fijo): $1 aparta igual', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 1, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(24999)
})
})
describe('calcularEsquemaFrasa — mensualidades', () => {
it('N=1: todo el diferible en una sola mensualidad', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 15000, numMensualidades: 1, escrituracionDiferida: true })
expect(r.mensualidad).toBe(130000)
})
it('validación: rechaza entradas inválidas', () => {
expect(validarEsquemaInput({ precio: 0, apartado: 0, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/precio/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: -5, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/apartado/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 2000000, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/superar/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 0, escrituracionDiferida: false })).toMatch(/mensualidad/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14.5, escrituracionDiferida: false })).toMatch(/entero/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false })).toBeNull()
})
})

View File

@@ -0,0 +1,100 @@
/**
* Esquema de cotización FRASA 2026 (flujo único, aprobado por el cliente):
*
* 1. APARTADO: cantidad libre (sin mínimo), aparta la unidad. Hoy.
* 2. ENGANCHE DE FIRMA: 2.5% del precio. El apartado se acredita DENTRO de
* este concepto → lo que se paga a la firma es el saldo restante
* (si el apartado supera el 2.5%, el excedente pasa al enganche).
* 3. ENGANCHE: 7.5% del precio, diferible en N mensualidades (N a elección
* del vendedor).
* 4. GASTOS DE ESCRITURACIÓN: 5.5% del precio. El cliente los salda en un
* pago a la firma O los difiere junto con el enganche.
*
* Módulo puro (sin UI, sin API) para probarlo a fondo. Porcentajes globales
* para todos los desarrollos (confirmado por el cliente el 28 sep 2026).
*/
export const PCT_ENGANCHE_FIRMA = 0.025
export const PCT_ENGANCHE = 0.075
export const PCT_ESCRITURACION = 0.055
export const MENSUALIDADES_DEFAULT = 14
export const MENSUALIDADES_MIN = 1
export const MENSUALIDADES_MAX = 36
export interface EsquemaFrasaInput {
/** Precio de la unidad (ya resuelto acabados/precio único) */
precio: number
/** Apartado: cantidad libre (>= 0) */
apartado: number
/** Número de mensualidades del diferimiento (elección del vendedor) */
numMensualidades: number
/** true = la escrituración se difiere con el enganche; false = se paga a la firma */
escrituracionDiferida: boolean
}
export interface EsquemaFrasaResultado {
precio: number
apartado: number
/** 2.5% del precio (concepto completo) */
engancheFirmaTotal: number
/** Lo que se paga a la firma por el enganche de firma (>= 0) */
saldoFirma: number
/** Apartado que excedió el 2.5% y se acredita al enganche (>= 0) */
excedenteApartado: number
/** Enganche del 7.5%, neto del excedente del apartado */
enganche: number
escrituracion: number
escrituracionDiferida: boolean
/** Todo lo que se liquida a la firma: saldoFirma + escrituración si no se difiere */
pagoUnicoFirma: number
/** Lo que entra al diferimiento: enganche (+ escrituración si se difiere) */
baseDiferible: number
numMensualidades: number
mensualidad: number
/** apartado + firma + diferible — debe cuadrar con el precio saldado */
totalPagado: number
}
/** Error de validación de entrada; null si es válida */
export function validarEsquemaInput(input: EsquemaFrasaInput): string | null {
if (!Number.isFinite(input.precio) || input.precio <= 0) return 'El precio debe ser mayor a cero'
if (!Number.isFinite(input.apartado) || input.apartado < 0) return 'El apartado no puede ser negativo'
if (input.apartado > input.precio) return 'El apartado no puede superar el precio de la propiedad'
if (!Number.isInteger(input.numMensualidades)) return 'Las mensualidades deben ser un número entero'
if (input.numMensualidades < MENSUALIDADES_MIN) return `Mínimo ${MENSUALIDADES_MIN} mensualidad`
if (input.numMensualidades > MENSUALIDADES_MAX) return `Máximo ${MENSUALIDADES_MAX} mensualidades`
return null
}
export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResultado {
const error = validarEsquemaInput(input)
if (error) throw new Error(error)
const { precio, apartado, numMensualidades, escrituracionDiferida } = input
const engancheFirmaTotal = precio * PCT_ENGANCHE_FIRMA
const saldoFirma = Math.max(0, engancheFirmaTotal - apartado)
const excedenteApartado = Math.max(0, apartado - engancheFirmaTotal)
const enganche = precio * PCT_ENGANCHE - excedenteApartado
const escrituracion = precio * PCT_ESCRITURACION
const pagoUnicoFirma = saldoFirma + (escrituracionDiferida ? 0 : escrituracion)
const baseDiferible = enganche + (escrituracionDiferida ? escrituracion : 0)
const mensualidad = baseDiferible / numMensualidades
const totalPagado = apartado + pagoUnicoFirma + baseDiferible
return {
precio,
apartado,
engancheFirmaTotal,
saldoFirma,
excedenteApartado,
enganche,
escrituracion,
escrituracionDiferida,
pagoUnicoFirma,
baseDiferible,
numMensualidades,
mensualidad,
totalPagado,
}
}

View File

@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import { folioCotizacion } from './folio' import { folioCotizacion } from './folio'
import { textoCompartir, telefonoWhatsApp, urlWhatsApp } from './compartir' import { textoCompartir, telefonoWhatsApp, urlWhatsApp } from './compartir'
import { compararUnidades } from './comparadorUnidades' import { compararUnidades } from './comparadorUnidades'
import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations' import { calcularEsquemaFrasa } from './esquema-frasa'
import { APARTADO_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 { DESARROLLOS } from '../data/desarrollos'
@@ -16,30 +17,31 @@ describe('folioCotizacion', () => {
}) })
describe('textoCompartir', () => { describe('textoCompartir', () => {
const propuesta = calcularPropuestas({ // Caso dorado del cliente: $1,000,000, apartado $15,000, N=14
unidad: UNIDADES_SEED[0], const esquema = calcularEsquemaFrasa({
acabados: true, precio: 1000000,
apartado: APARTADO_DEFAULT, apartado: 15000,
descuentos: { ...DESCUENTOS_DEFAULT }, numMensualidades: 14,
numMeses: 14, escrituracionDiferida: false,
})[0] })
const base = { const base = {
unidadCodigo: 'U-101', unidadCodigo: 'U-101',
desarrolloNombre: 'Diagonal', desarrolloNombre: 'Diagonal',
propuesta, esquema,
apartado: APARTADO_DEFAULT, apartado: 15000,
vigencia: new Date('2026-08-31T00:00:00'), vigencia: new Date('2026-08-31T00:00:00'),
} }
it('incluye unidad, desarrollo, propuesta, montos y vigencia', () => { it('incluye unidad, desarrollo, línea de tiempo del esquema y vigencia', () => {
const texto = textoCompartir({ ...base, folio: 'COT-3F8A1B', urlPublica: 'https://x/api/cotizaciones/1/publico' }) const texto = textoCompartir({ ...base, folio: 'COT-3F8A1B', urlPublica: 'https://x/api/cotizaciones/1/publico' })
expect(texto).toContain('Cotización COT-3F8A1B') expect(texto).toContain('Cotización COT-3F8A1B')
expect(texto).toContain('Desarrollo: Diagonal') expect(texto).toContain('Desarrollo: Diagonal')
expect(texto).toContain('Unidad: U-101') expect(texto).toContain('Unidad: U-101')
expect(texto).toContain(`Propuesta: ${propuesta.titulo}`) expect(texto).toContain(`Apartado (hoy): ${formatoMoneda(15000)}`)
expect(texto).toContain(`Enganche: ${formatoMoneda(propuesta.enganche)}`) expect(texto).toContain(`Pago a la firma: ${formatoMoneda(65000)}`)
expect(texto).toContain(`Pago final: ${formatoMoneda(propuesta.pagoFinal)}`) expect(texto).toContain(`Mensualidades: 14 × ${formatoMoneda(75000 / 14)}`)
expect(texto).toContain(`Total del esquema: ${formatoMoneda(155000)}`)
expect(texto).toContain('Vigencia: hasta el 31 de agosto de 2026') expect(texto).toContain('Vigencia: hasta el 31 de agosto de 2026')
expect(texto).toContain('Consulta: https://x/api/cotizaciones/1/publico') expect(texto).toContain('Consulta: https://x/api/cotizaciones/1/publico')
}) })
@@ -50,15 +52,16 @@ describe('textoCompartir', () => {
expect(texto).not.toContain('Consulta:') expect(texto).not.toContain('Consulta:')
}) })
it('contado (mensualidad null) muestra Mensualidades: N/A', () => { it('escrituración diferida: lo dice en el texto', () => {
const contado = calcularPropuestas({ const diferido = calcularEsquemaFrasa({
unidad: UNIDADES_SEED[0], precio: 1000000,
acabados: true, apartado: 15000,
apartado: APARTADO_DEFAULT, numMensualidades: 14,
descuentos: { ...DESCUENTOS_DEFAULT }, escrituracionDiferida: true,
numMeses: 14, })
}).find((p) => p.tipo === 'CONTADO')! const texto = textoCompartir({ ...base, esquema: diferido })
expect(textoCompartir({ ...base, propuesta: contado })).toContain('Mensualidades: N/A') expect(texto).toContain('Escrituración 5.5%: diferida con el enganche')
expect(texto).toContain(`Pago a la firma: ${formatoMoneda(10000)}`)
}) })
}) })

View File

@@ -1,26 +1,27 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf' import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf'
import { calcularPropuestas, APARTADO_DEFAULT, DESCUENTOS_DEFAULT } from './calculations' import { calcularEsquemaFrasa } from './esquema-frasa'
import { UNIDADES_SEED } from '../data/unidades' import { UNIDADES_SEED } from '../data/unidades'
import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos' import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters' import { formatoMoneda } from './formatters'
/** Esquema único FRASA 2026: caso dorado $1,000,000 / apartado $15,000 / N=14 */
const esquemaDorado = calcularEsquemaFrasa({
precio: 1000000,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: false,
})
describe('crearDocumentoPdf', () => { describe('crearDocumentoPdf', () => {
it('construye el documento ANEXO A sin errores y con el nombre correcto', () => { it('construye el documento ANEXO A sin errores y con el nombre correcto', () => {
const unidad = UNIDADES_SEED[0] const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})
const { doc, nombreArchivo } = crearDocumentoPdf({ const { doc, nombreArchivo } = crearDocumentoPdf({
unidad, unidad,
acabados: true, acabados: true,
apartado: APARTADO_DEFAULT, apartado: 15000,
precioLista: unidad.precioConAcabados, precioLista: unidad.precioConAcabados,
propuestas, esquema: { ...esquemaDorado, precio: unidad.precioConAcabados },
cliente: { nombre: 'Juan Pérez' }, cliente: { nombre: 'Juan Pérez' },
}) })
expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/) expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/)
@@ -30,24 +31,38 @@ describe('crearDocumentoPdf', () => {
expect(texto.length).toBeGreaterThan(0) expect(texto.length).toBeGreaterThan(0)
}) })
it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => { it('imprime la línea de tiempo del esquema (hoy / firma / mensualidades)', () => {
const unidad = UNIDADES_SEED[0] const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({ const { doc } = crearDocumentoPdf({
unidad, unidad,
acabados: true, acabados: true,
apartado: APARTADO_DEFAULT, apartado: 15000,
descuentos: { ...DESCUENTOS_DEFAULT }, precioLista: unidad.precioConAcabados,
numMeses: 14, esquema: esquemaDorado,
cliente: { nombre: 'Juan Pérez' },
}) })
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
expect(pdf).toContain('ESQUEMA FRASA 2026')
// jspdf escapa los paréntesis en el stream: se valida el texto sin ellos
expect(pdf).toContain('APARTADO')
expect(pdf).toContain('ENGANCHE DE FIRMA')
expect(pdf).toContain('GASTOS DE ESCRITURACIÓN')
expect(pdf).toContain(formatoMoneda(esquemaDorado.pagoUnicoFirma))
expect(pdf).toContain('14 × ')
expect(pdf).toContain(formatoMoneda(esquemaDorado.totalPagado))
})
it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => {
const unidad = UNIDADES_SEED[0]
// PNG 1x1 válido; en la app lo genera la librería qrcode // PNG 1x1 válido; en la app lo genera la librería qrcode
const png1x1 = const png1x1 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
const { doc } = crearDocumentoPdf({ const { doc } = crearDocumentoPdf({
unidad, unidad,
acabados: true, acabados: true,
apartado: APARTADO_DEFAULT, apartado: 15000,
precioLista: unidad.precioConAcabados, precioLista: unidad.precioConAcabados,
propuestas, esquema: esquemaDorado,
cliente: { nombre: 'Juan Pérez' }, cliente: { nombre: 'Juan Pérez' },
folio: 'COT-3F8A1B', folio: 'COT-3F8A1B',
qrDataUrl: png1x1, qrDataUrl: png1x1,
@@ -58,19 +73,12 @@ describe('crearDocumentoPdf', () => {
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => { it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
const unidad = UNIDADES_SEED[0] const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})
const { doc } = crearDocumentoPdf({ const { doc } = crearDocumentoPdf({
unidad, unidad,
acabados: true, acabados: true,
apartado: APARTADO_DEFAULT, apartado: 15000,
precioLista: unidad.precioConAcabados, precioLista: unidad.precioConAcabados,
propuestas, esquema: esquemaDorado,
cliente: { nombre: 'Juan Pérez' }, cliente: { nombre: 'Juan Pérez' },
folio: 'CTE-LOCAL-1757568000000', folio: 'CTE-LOCAL-1757568000000',
}) })

View File

@@ -1,7 +1,8 @@
import { jsPDF } from 'jspdf' import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable' import autoTable from 'jspdf-autotable'
import type { Unidad, Cliente, ParametrosDesarrollo } from '../types' import type { Unidad, Cliente, ParametrosDesarrollo } from '../types'
import type { PropuestaResultado } from './calculations' import type { EsquemaFrasaResultado } from './esquema-frasa'
import { PCT_ENGANCHE, PCT_ENGANCHE_FIRMA, PCT_ESCRITURACION } from './esquema-frasa'
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters' import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos' import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos'
@@ -10,8 +11,8 @@ export interface PdfInput {
acabados: boolean acabados: boolean
apartado: number apartado: number
precioLista: number precioLista: number
/** Propuestas a imprimir; desde la app llega SOLO la propuesta elegida */ /** Esquema único FRASA 2026 a imprimir */
propuestas: PropuestaResultado[] esquema: EsquemaFrasaResultado
cliente: Cliente cliente: Cliente
/** Parámetros del desarrollo; por defecto DIAGONAL */ /** Parámetros del desarrollo; por defecto DIAGONAL */
desarrollo?: ParametrosDesarrollo desarrollo?: ParametrosDesarrollo
@@ -30,7 +31,7 @@ export const textoCuotaMantenimiento = (cuota: number | null): string =>
cuota === null ? 'PENDIENTE' : formatoMoneda(cuota) cuota === null ? 'PENDIENTE' : formatoMoneda(cuota)
const DISCLAIMER = const DISCLAIMER =
'ESTA COTIZACION NO INCLUYE GASTOS DE ESCRITURACION. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL MONTO TOTAL DE ENGANCHE INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.' 'ESTA COTIZACION INCLUYE LOS GASTOS DE ESCRITURACION (5.5% DEL PRECIO), PAGADEROS A LA FIRMA O DIFERIDOS CON EL ENGANCHE SEGUN LO INDICADO. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL ENGANCHE DE FIRMA (2.5%) INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO; SI EL APARTADO LO EXCEDE, EL EXCEDENTE SE ACREDITA AL ENGANCHE.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.'
const CUENTA_DEPOSITO = const CUENTA_DEPOSITO =
'PARA CUENTAS EN MEXICO:\nTITULAR: FRASA DESARROLLOS, S.A.P.I. DE C.V.\n\nBBVA BANCOMER PAGO SOLO EN PESOS\nCUENTA: 0111525921 CLABE: 012028001115259210\n\nBBVA BANCOMER PAGO SOLO EN DOLARES\nCUENTA: 0111571737 CLABE: 012028001115717370\n\nPARA RECIBIR TRANSFERENCIAS DESDE EL EXTRANJERO:\nBANCO DEL BENEFICIARIO: BBVA Bancomer.\nSWIFT/BIC CODE: BCMRMXMMXXX\nBENEFICIARIO: FRASA DESARROLLOS, S.A.P.I. DE C.V.\nCUENTA: 01111571737\n(CLABE 18 DÍGITOS): 012 028 001 115 717 370\nSOLO PAGO EN DOLARES' 'PARA CUENTAS EN MEXICO:\nTITULAR: FRASA DESARROLLOS, S.A.P.I. DE C.V.\n\nBBVA BANCOMER PAGO SOLO EN PESOS\nCUENTA: 0111525921 CLABE: 012028001115259210\n\nBBVA BANCOMER PAGO SOLO EN DOLARES\nCUENTA: 0111571737 CLABE: 012028001115717370\n\nPARA RECIBIR TRANSFERENCIAS DESDE EL EXTRANJERO:\nBANCO DEL BENEFICIARIO: BBVA Bancomer.\nSWIFT/BIC CODE: BCMRMXMMXXX\nBENEFICIARIO: FRASA DESARROLLOS, S.A.P.I. DE C.V.\nCUENTA: 01111571737\n(CLABE 18 DÍGITOS): 012 028 001 115 717 370\nSOLO PAGO EN DOLARES'
@@ -50,7 +51,7 @@ export interface DocumentoPdf {
} }
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */ /** Construye el documento PDF "ANEXO A" (sin descargarlo) */
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf { export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esquema, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT] const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT]
const doc = new jsPDF() const doc = new jsPDF()
const hoy = new Date() const hoy = new Date()
@@ -128,51 +129,55 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
}) })
y = finalY(doc) + 8 y = finalY(doc) + 8
// 3. Propuesta general — una tabla por propuesta // 3. Esquema único FRASA 2026 — una sola tabla con la línea de tiempo
doc.setFontSize(12) doc.setFontSize(12)
doc.setFont('helvetica', 'bold') doc.setFont('helvetica', 'bold')
doc.text('PROPUESTA GENERAL', MARGEN, y) doc.text('PROPUESTA GENERAL — ESQUEMA FRASA 2026', MARGEN, y)
y += 4 y += 4
propuestas.forEach((p, i) => { autoTable(doc, {
const mensualidades = p.notaMensualidades startY: y,
? { pagos: `— (${p.notaMensualidades})`, total: '—' } margin: { left: MARGEN, right: MARGEN },
: p.mensualidad !== null && p.mensualidadesTotal !== null styles: { fontSize: 8 },
? { headStyles: { fillColor: [37, 99, 235] },
pagos: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`, head: [['CONCEPTO', '%', 'PAGOS MENSUALES', 'TOTAL']],
total: formatoMoneda(p.mensualidadesTotal), body: [
} ['APARTADO (HOY)', '', '', formatoMoneda(apartado)],
: { pagos: 'N/A', total: 'N/A' } [
`ENGANCHE DE FIRMA (${formatoPorcentaje(PCT_ENGANCHE_FIRMA)})`,
// Con una sola propuesta (la elegida por el vendedor) no se numera formatoPorcentaje(PCT_ENGANCHE_FIRMA),
const encabezado = '',
propuestas.length === 1 formatoMoneda(esquema.engancheFirmaTotal),
? `PROPUESTA COMERCIAL — ${p.titulo.toUpperCase()}`
: `PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8 },
headStyles: { fillColor: [37, 99, 235] },
head: [[encabezado, '%', 'PAGOS MENSUALES', 'TOTAL']],
body: [
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
['PRECIO FINAL C/DESC', '', '', formatoMoneda(p.precioFinal)],
['ENGANCHE', '', '', formatoMoneda(p.enganche)],
['MENSUALIDADES', '', mensualidades.pagos, mensualidades.total],
['PAGO FINAL', '', '', formatoMoneda(p.pagoFinal)],
], ],
}) [
y = finalY(doc) + 6 `SALDO A LA FIRMA (firma − apartado${esquema.excedenteApartado > 0 ? ' + excedente al enganche' : ''})`,
'',
// Salto de página antes de la siguiente tabla si no cabe '',
if (y > 240 && i < propuestas.length - 1) { formatoMoneda(esquema.saldoFirma),
doc.addPage() ],
y = MARGEN [
} `GASTOS DE ESCRITURACIÓN (${formatoPorcentaje(PCT_ESCRITURACION)}) — ${esquema.escrituracionDiferida ? 'DIFERIDOS CON EL ENGANCHE' : 'PAGADOS A LA FIRMA'}`,
formatoPorcentaje(PCT_ESCRITURACION),
'',
formatoMoneda(esquema.escrituracion),
],
['PAGO A LA FIRMA', '', '', formatoMoneda(esquema.pagoUnicoFirma)],
[
`ENGANCHE (${formatoPorcentaje(PCT_ENGANCHE)}) DIFERIBLE`,
formatoPorcentaje(PCT_ENGANCHE),
'',
formatoMoneda(esquema.enganche),
],
[
'MENSUALIDADES',
'',
`${esquema.numMensualidades} × ${formatoMoneda(esquema.mensualidad)}`,
formatoMoneda(esquema.baseDiferible),
],
['TOTAL DEL ESQUEMA', '', '', formatoMoneda(esquema.totalPagado)],
],
}) })
y = finalY(doc) + 6
y += 2 y += 2
// 4. ¿Qué sigue? // 4. ¿Qué sigue?

View File

@@ -9,7 +9,8 @@ import { INVENTARIO_SEED } from '../data/unidades'
/** /**
* Reglas configurables por el admin en la página del cotizador: * Reglas configurables por el admin en la página del cotizador:
* - un desarrollo con activoEnCotizador: false no se ofrece * - un desarrollo con activoEnCotizador: false no se ofrece
* - una propuesta desactivada no se muestra ni se puede elegir * - una propuesta desactivada ya NO afecta: el esquema único FRASA 2026 no
* usa las 4 propuestas (sigue cotizando igual)
*/ */
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')! const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
@@ -60,7 +61,7 @@ afterEach(() => {
}) })
describe('CotizadorPage con reglas del admin', () => { describe('CotizadorPage con reglas del admin', () => {
it('no ofrece el desarrollo desactivado ni la propuesta desactivada', async () => { it('no ofrece el desarrollo desactivado; una propuesta desactivada ya no bloquea el esquema', async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<CotizadorPage /> <CotizadorPage />
@@ -74,16 +75,18 @@ describe('CotizadorPage con reglas del admin', () => {
expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy() expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy()
expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy() expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy()
// Cargar las 4 propuestas de DIAGONAL: solo las 3 vigentes // Cargar la unidad de DIAGONAL (con la propuesta Contado desactivada por
// el admin): el esquema único se calcula igual, sin propuestas
fireEvent.focus(inputUnidad) fireEvent.focus(inputUnidad)
fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } }) fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } })
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) })) fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) }))
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Crédito Bancario')).toBeTruthy() expect(screen.getByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
}) })
expect(screen.getByText('Crédito Infonavit-Cofinavit')).toBeTruthy() // La línea de tiempo del esquema (Hoy / A la firma / Después)
expect(screen.getByText('Semi Contado')).toBeTruthy() expect(screen.getByText('Hoy')).toBeTruthy()
expect(screen.queryByText('Contado')).toBeNull() expect(screen.getByText('A la firma')).toBeTruthy()
expect(screen.getByText('Después')).toBeTruthy()
}) })
}) })

View File

@@ -6,9 +6,9 @@ import CotizadorPage from './CotizadorPage'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
/** /**
* Smoke del cierre de la cotización: * Smoke del cierre de la cotización con el esquema único FRASA 2026:
* - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF * - barra sticky con resumen del esquema + Compartir/Guardar/Generar PDF
* - preselección por defecto de la propuesta de menor total * - panel único (línea de tiempo Hoy → Firma → Mensualidades)
* - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-* * - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-*
*/ */
const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')! const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')!
@@ -35,7 +35,7 @@ afterEach(() => {
}) })
describe('CotizadorPage — barra sticky y guardado local', () => { describe('CotizadorPage — barra sticky y guardado local', () => {
it('preselecciona la propuesta más barata y guarda local con folio CTE-LOCAL cuando el API no responde', async () => { it('muestra el esquema único y guarda local con folio CTE-LOCAL cuando el API no responde', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input) const url = String(input)
const metodo = init?.method ?? 'GET' const metodo = init?.method ?? 'GET'
@@ -58,7 +58,7 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy() expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy()
expect(screen.getByText(/Selecciona una unidad para elegir propuesta/)).toBeTruthy() expect(screen.getByText(/Selecciona una unidad para ver el esquema de cotización\./)).toBeTruthy()
// Elegir una unidad del inventario // Elegir una unidad del inventario
const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement
@@ -66,12 +66,11 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
fireEvent.change(inputUnidad, { target: { value: unidad.codigo } }) fireEvent.change(inputUnidad, { target: { value: unidad.codigo } })
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) })) fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) }))
// Preselección por defecto: la propuesta de menor total pagado // Panel único del esquema: línea de tiempo y conceptos
const selectPropuesta = (await screen.findByLabelText( expect(await screen.findByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
'Cambiar propuesta elegida', expect(screen.getByText('Hoy')).toBeTruthy()
)) as HTMLSelectElement expect(screen.getByText('A la firma')).toBeTruthy()
await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata expect(screen.getByRole('option', { name: '14 mensualidades' })).toBeTruthy()
expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy()
// Guardar con el servidor caído → folio local persistido en el dispositivo // Guardar con el servidor caído → folio local persistido en el dispositivo
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), { fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
@@ -82,10 +81,20 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
await waitFor(() => { await waitFor(() => {
const locales = JSON.parse( const locales = JSON.parse(
almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]', almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]',
) as { folio?: string; clienteNombre?: string }[] ) as {
folio?: string
clienteNombre?: string
esquema?: string
tipoPropuesta?: string
saldoFirma?: number | null
}[]
expect(locales).toHaveLength(1) expect(locales).toHaveLength(1)
expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/) expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/)
expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local') expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local')
// La instantánea local ya vive en el esquema único
expect(locales[0]?.esquema).toBe('FRASA_2026')
expect(locales[0]?.tipoPropuesta).toBe('FRASA_2026')
expect(locales[0]?.saldoFirma).not.toBeNull()
}) })
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy() expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
}) })
@@ -100,12 +109,17 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
clienteNombre: 'Cliente Antiguo', clienteNombre: 'Cliente Antiguo',
clienteTelefono: null, clienteTelefono: null,
clienteEmail: null, clienteEmail: null,
esquema: 'FRASA_2026',
tipoPropuesta: 'BANCARIO', tipoPropuesta: 'BANCARIO',
acabados: false, acabados: false,
apartado: 20000, apartado: 20000,
descuento: 0, descuento: 0,
precioLista: vendida.precioConAcabados, precioLista: vendida.precioConAcabados,
precioFinal: 0, precioFinal: 0,
saldoFirma: null,
engancheDiferible: null,
escrituracion: null,
escrituracionDiferida: false,
enganche: 0, enganche: 0,
numMensualidades: 12, numMensualidades: 12,
mensualidad: 1000, mensualidad: 1000,
@@ -139,6 +153,8 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
const alerta = await screen.findByRole('alert') const alerta = await screen.findByRole('alert')
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`) 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() expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
// Es una cotización del esquema anterior (legacy): lo dice al retomarla
expect(screen.getByText(/Usa el esquema anterior \(BANCARIO\)/)).toBeTruthy()
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) { for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true) expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
} }

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
@@ -10,9 +10,7 @@ import { SkeletonList } from "../components/Skeleton"
import toast from "react-hot-toast" import toast from "react-hot-toast"
import { UnitSelector } from '../components/UnitSelector' import { UnitSelector } from '../components/UnitSelector'
import { UnitInfoCard } from '../components/UnitInfoCard' import { UnitInfoCard } from '../components/UnitInfoCard'
import { ProposalCard } from '../components/ProposalCard' import { EsquemaPanel } from '../components/EsquemaPanel'
import { ComparadorPropuestas } from '../components/ComparadorPropuestas'
import { ComparadorUnidades } from '../components/ComparadorUnidades'
import { MisCotizaciones } from '../components/MisCotizaciones' import { MisCotizaciones } from '../components/MisCotizaciones'
import { ModalCliente } from '../components/ModalCliente' import { ModalCliente } from '../components/ModalCliente'
import { BuscadorCliente } from '../components/BuscadorCliente' import { BuscadorCliente } from '../components/BuscadorCliente'
@@ -21,16 +19,13 @@ 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, ENGANCHE_MAXIMO_PCT } from '../lib/calculations' import { desarrolloEfectivo, idsCotizables } from '../lib/reglas'
import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas'
import { import {
getMisCotizaciones, getMisCotizaciones,
getCotizacion, getCotizacion,
guardarCotizacion, guardarCotizacion,
urlPublicaCotizacion, urlPublicaCotizacion,
ErrorApiCotizacion, ErrorApiCotizacion,
TIPO_A_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'
@@ -39,6 +34,10 @@ import type { Cliente, Unidad } from '../types'
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */ /** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
/** Una cotización guardada ya vive en el esquema único (filas nuevas) */
const esEsquemaNuevo = (c: CotizacionGuardada): boolean =>
c.esquema === 'FRASA_2026' && c.saldoFirma != null
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)'),
email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(), email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(),
@@ -70,17 +69,13 @@ export default function CotizadorPage() {
apartado, apartado,
setApartado, setApartado,
apartadoEfectivo, apartadoEfectivo,
enganchePct,
setEnganchePct,
errorEnganche,
propuestaIndex,
setPropuestaIndex,
precioLista,
numMensualidades, numMensualidades,
setNumMensualidades, setNumMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
precioLista,
resultado,
numMeses, numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar, restaurar,
nueva, nueva,
} = cotizacion } = cotizacion
@@ -121,11 +116,6 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [desarrollos, desarrollo]) }, [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 */
@@ -241,33 +231,9 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cotizacionPendiente, cargando, unidades]) }, [cotizacionPendiente, cargando, unidades])
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
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
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones // Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
const unidadNoDisponible = unidad !== null && !esDisponible(unidad) const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !guardando
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
// más barata; la elección manual del vendedor se respeta hasta el siguiente
// cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada.
const unidadPrevia = useRef<string | null | undefined>(undefined)
const saltarAuto = useRef(false)
useEffect(() => {
const codigo = unidad?.codigo ?? null
const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo
unidadPrevia.current = codigo
if (!cambioUnidad || saltarAuto.current) {
saltarAuto.current = false
return
}
const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo))
const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
if (indiceMenor >= 0) setPropuestaIndex(indiceMenor)
}, [unidad?.codigo, propuestas, apartadoEfectivo])
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */ /** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-') const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
@@ -275,24 +241,30 @@ export default function CotizadorPage() {
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */ /** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => { const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
const timestamp = Date.now() const timestamp = Date.now()
if (!unidad || !resultado) throw new Error('No hay esquema para guardar localmente')
return { return {
id: `local-${timestamp}`, id: `local-${timestamp}`,
folio: `CTE-LOCAL-${timestamp}`, folio: `CTE-LOCAL-${timestamp}`,
unidadCodigo: unidad!.codigo, unidadCodigo: unidad.codigo,
desarrollo, desarrollo,
clienteNombre: cliente.nombre, clienteNombre: cliente.nombre,
clienteTelefono: cliente.telefono ?? null, clienteTelefono: cliente.telefono ?? null,
clienteEmail: cliente.email ?? null, clienteEmail: cliente.email ?? null,
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo], esquema: 'FRASA_2026',
tipoPropuesta: 'FRASA_2026',
acabados, acabados,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento, descuento: 0,
precioLista, precioLista,
precioFinal: propuestaActiva.precioFinal, precioFinal: precioLista,
enganche: propuestaActiva.enganche, saldoFirma: resultado.saldoFirma,
numMensualidades: propuestaActiva.numMensualidades ?? 0, engancheDiferible: resultado.enganche,
mensualidad: propuestaActiva.mensualidad, escrituracion: resultado.escrituracion,
pagoFinal: propuestaActiva.pagoFinal, escrituracionDiferida,
enganche: resultado.enganche,
numMensualidades,
mensualidad: resultado.mensualidad,
pagoFinal: resultado.pagoUnicoFirma,
createdAt: new Date(timestamp).toISOString(), createdAt: new Date(timestamp).toISOString(),
} }
} }
@@ -305,19 +277,17 @@ export default function CotizadorPage() {
* devuelve null: se avisa y no se guarda nada. * 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 || !resultado) return null
try { try {
const guardada = await guardarCotizacion({ const guardada = await guardarCotizacion({
unidadCodigo: unidad.codigo, unidadCodigo: unidad.codigo,
clienteNombre: cliente.nombre, clienteNombre: cliente.nombre,
...(cliente.email ? { clienteEmail: cliente.email } : {}), ...(cliente.email ? { clienteEmail: cliente.email } : {}),
...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}), ...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}),
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
acabados, acabados,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento, numMensualidades,
...(numMeses !== null ? { numMensualidades: numMeses } : {}), escrituracionDiferida,
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
...(oportunidadId !== null ? { oportunidadId } : {}), ...(oportunidadId !== null ? { oportunidadId } : {}),
}) })
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) } const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
@@ -344,7 +314,7 @@ export default function CotizadorPage() {
} }
const generarPdfConCliente = async (cliente: Cliente) => { const generarPdfConCliente = async (cliente: Cliente) => {
if (!unidad) return if (!unidad || !resultado) return
setGuardando(true) setGuardando(true)
try { try {
const guardada = await guardarEnApi(cliente) const guardada = await guardarEnApi(cliente)
@@ -365,8 +335,8 @@ export default function CotizadorPage() {
acabados, acabados,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
precioLista, precioLista,
// El PDF imprime SOLO la propuesta elegida por el vendedor // El PDF imprime SOLO el esquema único FRASA 2026
propuestas: propuestaActiva ? [propuestaActiva] : [], esquema: resultado,
cliente, cliente,
desarrollo: params, desarrollo: params,
folio, folio,
@@ -432,11 +402,11 @@ export default function CotizadorPage() {
} }
const handleCompartir = async () => { const handleCompartir = async () => {
if (!unidad || !propuestaActiva) return if (!unidad || !resultado) return
const texto = textoCompartir({ const texto = textoCompartir({
unidadCodigo: unidad.codigo, unidadCodigo: unidad.codigo,
desarrolloNombre: params.nombre, desarrolloNombre: params.nombre,
propuesta: propuestaActiva, esquema: resultado,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
vigencia: calcularVigencia(), vigencia: calcularVigencia(),
...(ultimaGuardada ...(ultimaGuardada
@@ -468,32 +438,32 @@ export default function CotizadorPage() {
setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`) setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`)
return return
} }
const tipo = TIPO_DE_API[c.tipoPropuesta] const nueva = esEsquemaNuevo(c)
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
restaurar({ restaurar({
desarrollo: c.desarrollo, desarrollo: c.desarrollo,
unidad: u, unidad: u,
acabados: c.acabados, acabados: c.acabados,
apartado: c.apartado, apartado: c.apartado,
descuento: c.descuento, // Las cotizaciones del esquema anterior retoman con los defaults del
propuestaIndex: idx >= 0 ? idx : 0, // nuevo esquema (14 mensualidades, escrituración a la firma)
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null, ...(nueva
? {
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : undefined,
escrituracionDiferida: c.escrituracionDiferida,
}
: {}),
}) })
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)
const avisoEsquema = nueva
? ''
: ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.`
setAviso( setAviso(
esDisponible(u) esDisponible(u)
? `Cotización ${c.folio ?? ''} retomada.` ? `Cotización ${c.folio ?? ''} retomada.${avisoEsquema}`
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`, : `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.${avisoEsquema}`,
) )
} }
@@ -639,67 +609,6 @@ export default function CotizadorPage() {
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p> <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
</div> </div>
<div>
<label htmlFor="enganche" className={etiquetaCampo}>
Enganche (%) — opcional
</label>
<input
id="enganche"
type="number"
min={2.5}
max={ENGANCHE_MAXIMO_PCT}
step={0.5}
placeholder="Según propuesta"
value={enganchePct ?? ''}
onChange={(e) => {
const crudo = e.target.value
if (crudo === '') {
setEnganchePct(null)
return
}
const v = Number(crudo)
setEnganchePct(Number.isFinite(v) ? v : null)
}}
className={errorEnganche ? campoInputError : campoInput}
/>
{errorEnganche ? (
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
) : (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
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)})` : ''}
</p>
)}
</div>
<div>
<label htmlFor="mensualidades" className={etiquetaCampo}>
Mensualidades
</label>
<select
id="mensualidades"
value={numMensualidades ?? ''}
onChange={(e) => {
const v = e.target.value
setNumMensualidades(v === '' ? null : Number(v))
}}
className={campoInput}
>
<option value="">Calcular automáticamente</option>
{[6, 12, 14, 18, 24].map((n) => (
<option key={n} value={n}>
{n} mensualidades
</option>
))}
</select>
{numMeses !== null && (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Se usarán {numMeses} mensualidades para el cálculo
</p>
)}
</div>
<BuscadorCliente <BuscadorCliente
onSeleccion={(lead) => { onSeleccion={(lead) => {
setValue('nombre', lead.nombre, { shouldValidate: true }) setValue('nombre', lead.nombre, { shouldValidate: true })
@@ -783,33 +692,22 @@ export default function CotizadorPage() {
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />} {unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
</aside> </aside>
{/* Panel de propuestas */} {/* Panel del esquema único FRASA 2026 */}
<section className="min-w-0"> <section className="min-w-0">
{!unidad ? ( {!unidad || !resultado ? (
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]"> <div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]"> <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para ver las propuestas comerciales. Selecciona una unidad para ver el esquema de cotización.
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-8"> <EsquemaPanel
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> esquema={resultado}
{propuestas.map((p, i) => ( numMensualidades={numMensualidades}
<ProposalCard onNumMensualidades={setNumMensualidades}
key={p.tipo} escrituracionDiferida={escrituracionDiferida}
numero={i + 1} onEscrituracionDiferida={setEscrituracionDiferida}
titulo={p.titulo} />
propuesta={p}
/>
))}
</div>
<ComparadorPropuestas
precioLista={precioLista}
apartado={apartadoEfectivo}
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
</div>
)} )}
</section> </section>
</div> </div>
@@ -822,44 +720,24 @@ export default function CotizadorPage() {
error={errorMias} error={errorMias}
onRetomar={handleRetomar} onRetomar={handleRetomar}
/> />
<ComparadorUnidades
unidades={unidades}
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
apartado={apartadoEfectivo}
acabados={acabados}
reglas={reglas}
/>
</div> </div>
</main> </main>
{/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */} {/* Barra fija de cierre: resumen del esquema + acciones, siempre visibles */}
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95"> <div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8"> <div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
{propuestaActiva ? ( {resultado ? (
<> <span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent"> <span className="truncate">
<span className="truncate"> Esquema FRASA 2026 · Hoy {formatoMoneda(resultado.apartado)} · A la firma{' '}
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo} {formatoMoneda(resultado.pagoUnicoFirma)} · {resultado.numMensualidades} ×{' '}
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''} {formatoMoneda(resultado.mensualidad)}
</span>
</span> </span>
<select </span>
aria-label="Cambiar propuesta elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</>
) : ( ) : (
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]"> <p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para elegir propuesta, guardar y generar el PDF. Selecciona una unidad para ver el esquema, guardar y generar el PDF.
</p> </p>
)} )}
{aviso && ( {aviso && (

View File

@@ -1,4 +1,4 @@
import type { Unidad, TipoPropuesta } from '../types' import type { Unidad } from '../types'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas' import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas'
import { conAuth, manejar401 } from './auth' import { conAuth, manejar401 } from './auth'
@@ -72,22 +72,12 @@ export async function getReglasCotizador(): Promise<ReglasDesarrollo> {
// ---------- Cotizaciones guardadas (historial del vendedor) ---------- // ---------- Cotizaciones guardadas (historial del vendedor) ----------
/** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */ /**
export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO' * Tipo de propuesta tal como viene del API. Las cotizaciones nuevas usan el
* esquema único FRASA_2026; los valores legacy (BANCARIO, …) quedan en las
export const TIPO_A_API: Record<TipoPropuesta, TipoPropuestaApi> = { * filas viejas para no romper reportes ni el historial.
CREDITO_BANCARIO: 'BANCARIO', */
CREDITO_INFONAVIT_COFINAVIT: 'INFONAVIT', export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO' | 'FRASA_2026'
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI_CONTADO',
}
export const TIPO_DE_API: Record<TipoPropuestaApi, TipoPropuesta> = {
BANCARIO: 'CREDITO_BANCARIO',
INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI_CONTADO',
}
export interface CotizacionGuardada { export interface CotizacionGuardada {
id: string id: string
@@ -97,12 +87,22 @@ export interface CotizacionGuardada {
clienteNombre: string clienteNombre: string
clienteTelefono: string | null clienteTelefono: string | null
clienteEmail: string | null clienteEmail: string | null
/** Esquema único FRASA 2026 (las viejas traen su tipoPropuesta legacy) */
esquema?: string
tipoPropuesta: TipoPropuestaApi tipoPropuesta: TipoPropuestaApi
acabados: boolean acabados: boolean
apartado: number apartado: number
descuento: number descuento: number
precioLista: number precioLista: number
precioFinal: number precioFinal: number
/** Lo que se paga a la firma por el enganche de firma (esquema nuevo) */
saldoFirma?: number | null
/** Enganche del 7.5%, neto del excedente del apartado (esquema nuevo) */
engancheDiferible?: number | null
/** Gastos de escrituración 5.5% (esquema nuevo) */
escrituracion?: number | null
/** true = escrituración diferida con el enganche (esquema nuevo) */
escrituracionDiferida?: boolean
enganche: number enganche: number
numMensualidades: number numMensualidades: number
mensualidad: number | null mensualidad: number | null
@@ -115,12 +115,13 @@ export interface GuardarCotizacionPayload {
clienteNombre: string clienteNombre: string
clienteEmail?: string clienteEmail?: string
clienteTelefono?: string clienteTelefono?: string
tipoPropuesta: TipoPropuestaApi
acabados: boolean acabados: boolean
/** Apartado: cantidad libre (sin él, el API usa su default) */
apartado?: number apartado?: number
descuento?: number /** Mensualidades del diferimiento (1..36; sin ellas, el API usa 14) */
/** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */
numMensualidades?: number numMensualidades?: number
/** true = la escrituración (5.5%) se difiere con el enganche */
escrituracionDiferida?: boolean
/** 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
} }