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:
@@ -6,9 +6,9 @@ import CotizadorPage from './CotizadorPage'
|
||||
import { INVENTARIO_SEED } from '../data/unidades'
|
||||
|
||||
/**
|
||||
* Smoke del cierre de la cotización:
|
||||
* - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF
|
||||
* - preselección por defecto de la propuesta de menor total
|
||||
* Smoke del cierre de la cotización con el esquema único FRASA 2026:
|
||||
* - barra sticky con resumen del esquema + Compartir/Guardar/Generar PDF
|
||||
* - panel único (línea de tiempo Hoy → Firma → Mensualidades)
|
||||
* - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-*
|
||||
*/
|
||||
const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')!
|
||||
@@ -35,7 +35,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
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 url = String(input)
|
||||
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(screen.getByRole('button', { name: 'Guardar' })).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
|
||||
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.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) }))
|
||||
|
||||
// Preselección por defecto: la propuesta de menor total pagado
|
||||
const selectPropuesta = (await screen.findByLabelText(
|
||||
'Cambiar propuesta elegida',
|
||||
)) as HTMLSelectElement
|
||||
await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata
|
||||
expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy()
|
||||
// Panel único del esquema: línea de tiempo y conceptos
|
||||
expect(await screen.findByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
|
||||
expect(screen.getByText('Hoy')).toBeTruthy()
|
||||
expect(screen.getByText('A la firma')).toBeTruthy()
|
||||
expect(screen.getByRole('option', { name: '14 mensualidades' })).toBeTruthy()
|
||||
|
||||
// Guardar con el servidor caído → folio local persistido en el dispositivo
|
||||
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
|
||||
@@ -82,10 +81,20 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
||||
await waitFor(() => {
|
||||
const locales = JSON.parse(
|
||||
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[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/)
|
||||
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()
|
||||
})
|
||||
@@ -100,12 +109,17 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
||||
clienteNombre: 'Cliente Antiguo',
|
||||
clienteTelefono: null,
|
||||
clienteEmail: null,
|
||||
esquema: 'FRASA_2026',
|
||||
tipoPropuesta: 'BANCARIO',
|
||||
acabados: false,
|
||||
apartado: 20000,
|
||||
descuento: 0,
|
||||
precioLista: vendida.precioConAcabados,
|
||||
precioFinal: 0,
|
||||
saldoFirma: null,
|
||||
engancheDiferible: null,
|
||||
escrituracion: null,
|
||||
escrituracionDiferida: false,
|
||||
enganche: 0,
|
||||
numMensualidades: 12,
|
||||
mensualidad: 1000,
|
||||
@@ -139,6 +153,8 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
||||
const alerta = await screen.findByRole('alert')
|
||||
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()
|
||||
// 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']) {
|
||||
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user