feat: esquema unico de cotizacion FRASA 2026 (reemplaza las 4 propuestas)

Flujo aprobado por el cliente (28 sep 2026), porcentajes globales para
todos los desarrollos:
- Apartado: cantidad libre (sin minimo), se acredita dentro del enganche
  de firma; el excedente se descuenta del enganche
- Enganche de firma: 2.5% del precio (saldo a firma = max(0, 2.5% - apartado))
- Enganche: 7.5%, diferible en N mensualidades (N a eleccion del vendedor, 1-36)
- Gastos de escrituracion: 5.5%, pago unico a la firma o diferido con el
  enganche (toggle)

- lib/esquema-frasa.ts (frontend) + cotizacion-esquema.service.ts (backend):
  modulos puros espejo, caso dorado M/apartado 5k/N=14 en tests de ambos
- Migracion 20260928120000: 7 columnas nuevas en Cotizacion (esquema,
  saldoFirma, engancheDiferible, escrituracion, etc.); legacy se rellena
  para compatibilidad de reportes (tipoPropuesta=FRASA_2026)
- UI: panel de linea de tiempo (Hoy -> Firma -> Mensualidades) sustituye a
  propuestas/comparadores; PDF y WhatsApp con el esquema unico
- API recalcula server-side; smoke end-to-end verificado contra inventario
- 743 tests API + 128 cotizador en verde
This commit is contained in:
2026-09-28 13:00:22 +00:00
parent 23d288d0aa
commit 61d1b84fc3
23 changed files with 1160 additions and 1051 deletions

View File

@@ -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)
}