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:
@@ -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;
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
109
api-gateway/src/services/cotizacion-esquema.service.test.ts
Normal file
109
api-gateway/src/services/cotizacion-esquema.service.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
103
api-gateway/src/services/cotizacion-esquema.service.ts
Normal file
103
api-gateway/src/services/cotizacion-esquema.service.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
164
apps/cotizador/src/components/EsquemaPanel.tsx
Normal file
164
apps/cotizador/src/components/EsquemaPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
expect(result.current.resultado).toEqual(esperado)
|
||||||
|
// El esquema siempre liquida el 15.5% del precio (2.5 + 7.5 + 5.5)
|
||||||
|
expect(result.current.resultado?.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
|
||||||
|
expect(unidad).toBe(unidadDiagonal)
|
||||||
|
expect(acabados).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('si el admin desactiva la propuesta elegida, avisa y cae a la primera vigente', () => {
|
it('sin unidad no hay resultado (panel vacío)', () => {
|
||||||
const { result, rerender } = montar()
|
const { result } = montar()
|
||||||
|
|
||||||
// El vendedor elige Contado (índice 2)
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setPropuestaIndex(2)
|
result.current.setUnidad(null)
|
||||||
})
|
})
|
||||||
expect(result.current.propuestas[2]?.tipo).toBe('CONTADO')
|
expect(result.current.resultado).toBeNull()
|
||||||
|
expect(result.current.precioLista).toBe(0)
|
||||||
// El admin la desactiva (llegan reglas nuevas)
|
|
||||||
rerender({ reglas: reglasDiagonales((tipo) => tipo !== 'CONTADO') })
|
|
||||||
|
|
||||||
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('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 === 'CONTADO' ? { ...p, descuentoDefault: 0.06 } : p,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(result.current.descuentos.contado).toBe(0.06)
|
act(() => {
|
||||||
expect(result.current.descuentos.semiContado).toBe(0.01)
|
result.current.setApartado(excedente)
|
||||||
|
})
|
||||||
|
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 reglas cambian los números de las propuestas', () => {
|
it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => {
|
||||||
const { result } = montar(
|
const { result } = montar()
|
||||||
reglasDesdeApi({
|
|
||||||
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.setNumMensualidades(12)
|
||||||
expect(bancario?.enganche).toBeCloseTo(0.05 * precioLista - result.current.apartadoEfectivo, 2)
|
})
|
||||||
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}`)
|
|
||||||
if (d.propuesta.notaMensualidades) {
|
|
||||||
lineas.push(`Mensualidades: — (${d.propuesta.notaMensualidades})`)
|
|
||||||
} else if (d.propuesta.mensualidad !== null) {
|
|
||||||
lineas.push(
|
lineas.push(
|
||||||
`Mensualidades: ${d.propuesta.numMensualidades} × ${formatoMoneda(d.propuesta.mensualidad)}`,
|
`Mensualidades: ${d.esquema.numMensualidades} × ${formatoMoneda(d.esquema.mensualidad)} (total diferible ${formatoMoneda(d.esquema.baseDiferible)})`,
|
||||||
)
|
)
|
||||||
} else {
|
lineas.push(`Escrituración 5.5%: ${d.esquema.escrituracionDiferida ? 'diferida con el enganche' : `pagada a la firma (${formatoMoneda(d.esquema.escrituracion)})`}`)
|
||||||
lineas.push('Mensualidades: N/A')
|
lineas.push(`Total del esquema: ${formatoMoneda(d.esquema.totalPagado)}`)
|
||||||
}
|
|
||||||
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')
|
||||||
|
|||||||
81
apps/cotizador/src/lib/esquema-frasa.test.ts
Normal file
81
apps/cotizador/src/lib/esquema-frasa.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
100
apps/cotizador/src/lib/esquema-frasa.ts
Normal file
100
apps/cotizador/src/lib/esquema-frasa.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) => {
|
|
||||||
const mensualidades = p.notaMensualidades
|
|
||||||
? { pagos: `— (${p.notaMensualidades})`, total: '—' }
|
|
||||||
: p.mensualidad !== null && p.mensualidadesTotal !== null
|
|
||||||
? {
|
|
||||||
pagos: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
|
|
||||||
total: formatoMoneda(p.mensualidadesTotal),
|
|
||||||
}
|
|
||||||
: { pagos: 'N/A', total: 'N/A' }
|
|
||||||
|
|
||||||
// Con una sola propuesta (la elegida por el vendedor) no se numera
|
|
||||||
const encabezado =
|
|
||||||
propuestas.length === 1
|
|
||||||
? `PROPUESTA COMERCIAL — ${p.titulo.toUpperCase()}`
|
|
||||||
: `PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`
|
|
||||||
|
|
||||||
autoTable(doc, {
|
autoTable(doc, {
|
||||||
startY: y,
|
startY: y,
|
||||||
margin: { left: MARGEN, right: MARGEN },
|
margin: { left: MARGEN, right: MARGEN },
|
||||||
styles: { fontSize: 8 },
|
styles: { fontSize: 8 },
|
||||||
headStyles: { fillColor: [37, 99, 235] },
|
headStyles: { fillColor: [37, 99, 235] },
|
||||||
head: [[encabezado, '%', 'PAGOS MENSUALES', 'TOTAL']],
|
head: [['CONCEPTO', '%', 'PAGOS MENSUALES', 'TOTAL']],
|
||||||
body: [
|
body: [
|
||||||
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
|
['APARTADO (HOY)', '', '', formatoMoneda(apartado)],
|
||||||
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
|
[
|
||||||
['PRECIO FINAL C/DESC', '', '', formatoMoneda(p.precioFinal)],
|
`ENGANCHE DE FIRMA (${formatoPorcentaje(PCT_ENGANCHE_FIRMA)})`,
|
||||||
['ENGANCHE', '', '', formatoMoneda(p.enganche)],
|
formatoPorcentaje(PCT_ENGANCHE_FIRMA),
|
||||||
['MENSUALIDADES', '', mensualidades.pagos, mensualidades.total],
|
'',
|
||||||
['PAGO FINAL', '', '', formatoMoneda(p.pagoFinal)],
|
formatoMoneda(esquema.engancheFirmaTotal),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`SALDO A LA FIRMA (firma − apartado${esquema.excedenteApartado > 0 ? ' + excedente al enganche' : ''})`,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
formatoMoneda(esquema.saldoFirma),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
`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 = finalY(doc) + 6
|
||||||
|
|
||||||
// Salto de página antes de la siguiente tabla si no cabe
|
|
||||||
if (y > 240 && i < propuestas.length - 1) {
|
|
||||||
doc.addPage()
|
|
||||||
y = MARGEN
|
|
||||||
}
|
|
||||||
})
|
|
||||||
y += 2
|
y += 2
|
||||||
|
|
||||||
// 4. ¿Qué sigue?
|
// 4. ¿Qué sigue?
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo}
|
Esquema FRASA 2026 · Hoy {formatoMoneda(resultado.apartado)} · A la firma{' '}
|
||||||
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''}
|
{formatoMoneda(resultado.pagoUnicoFirma)} · {resultado.numMensualidades} ×{' '}
|
||||||
|
{formatoMoneda(resultado.mensualidad)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<select
|
|
||||||
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 && (
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user