feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing

- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador,
  combobox de unidades, vigencia, PDF ANEXO A
- Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE,
  checklist de expediente, firma digital, 5 PDFs legales
- Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF,
  bitacora, dashboard con filtros
- Landing (/): entrada al ecosistema
- Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps
- 123 tests vitest
This commit is contained in:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import {
calcularPropuestas,
calcularNumMensualidades,
obtenerPrecioLista,
totalPagado,
indiceMenorTotal,
APARTADO_DEFAULT,
} from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
import type { Unidad } from '../types'
// Fixture validado contra el Excel real: U-101, con acabados, apartado 20000, N = 14
const U101: Unidad = UNIDADES_SEED.find((u) => u.codigo === 'U-101')!
const N = 14
function calcular(descuentos = { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }) {
return calcularPropuestas({
unidad: U101,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos,
numMeses: N,
})
}
describe('fixture U-101 (validado contra Excel de producción)', () => {
it('precio de lista con acabados', () => {
expect(obtenerPrecioLista(U101, true)).toBe(4579000)
expect(obtenerPrecioLista(U101, false)).toBe(4415000)
})
it('Propuesta 1 — CRÉDITO BANCARIO (desc 0)', () => {
const [p] = calcular()
expect(p.enganche).toBeCloseTo(94475, 2)
expect(p.mensualidadesTotal).toBeCloseTo(343425, 2)
expect(p.mensualidad).toBeCloseTo(24530.357142, 4)
expect(p.pagoFinal).toBeCloseTo(4121100, 2)
})
it('Propuesta 2 — CRÉDITO INFONAVIT-COFINAVIT (desc 0)', () => {
const [, p] = calcular()
expect(p.enganche).toBeCloseTo(94475, 2)
expect(p.mensualidadesTotal).toBeCloseTo(114475, 2)
expect(p.mensualidad).toBeCloseTo(8176.785714, 4)
expect(p.pagoFinal).toBeCloseTo(4350050, 2)
})
it('Propuesta 3 — CONTADO (desc 2%)', () => {
const [, , p] = calcular()
expect(p.montoDescuento).toBeCloseTo(91580, 2)
expect(p.precioFinal).toBeCloseTo(4487420, 2)
expect(p.enganche).toBeCloseTo(4018678, 2)
expect(p.pagoFinal).toBeCloseTo(448742, 2)
expect(p.mensualidad).toBeNull()
expect(p.mensualidadesTotal).toBeNull()
})
it('Propuesta 4 — SEMI CONTADO (desc 1%)', () => {
const [, , , p] = calcular()
expect(p.montoDescuento).toBeCloseTo(45790, 2)
expect(p.precioFinal).toBeCloseTo(4533210, 2)
expect(p.enganche).toBeCloseTo(2246605, 2)
expect(p.mensualidadesTotal).toBeCloseTo(1813284, 2)
expect(p.mensualidad).toBeCloseTo(129520.285714, 4)
expect(p.pagoFinal).toBeCloseTo(453321, 2)
})
})
describe('comportamiento documentado del Excel', () => {
it('Bancario con descuento > 0: el enganche sigue calculándose sobre precio LISTA', () => {
const [p] = calcular({ creditoBancario: 0.05, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 })
// Enganche sobre precio lista (4579000), no sobre precio final (4350050)
expect(p.enganche).toBeCloseTo(0.025 * 4579000 - APARTADO_DEFAULT, 2)
expect(p.enganche).toBeCloseTo(94475, 2)
expect(p.montoDescuento).toBeCloseTo(228950, 2)
expect(p.precioFinal).toBeCloseTo(4350050, 2)
expect(p.pagoFinal).toBeCloseTo(0.9 * 4350050, 2)
// Mensualidades sí usan precio final
const total = 4350050 - APARTADO_DEFAULT - 94475 - 0.9 * 4350050
expect(p.mensualidadesTotal).toBeCloseTo(total, 2)
})
it('Infonavit usa precioLista (no precioFinal) en el total de mensualidades', () => {
const [, p] = calcular({ creditoBancario: 0, creditoInfonavit: 0.05, contado: 0.02, semiContado: 0.01 })
// Replicado tal cual del Excel: precioLista - pagoFinal - apartado - enganche
const total = 4579000 - p.pagoFinal - APARTADO_DEFAULT - p.enganche
expect(p.mensualidadesTotal).toBeCloseTo(total, 2)
})
})
describe('calcularNumMensualidades', () => {
it('calcula meses redondeados con 30.4 días por mes', () => {
const hoy = new Date('2026-08-20T00:00:00')
// 2027-10-08 son ~414 días => ~13.6 => 14 meses
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(14)
})
it('nunca devuelve menos de 1', () => {
const hoy = new Date('2030-01-01T00:00:00')
expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(1)
})
})
describe('totalPagado', () => {
it('suma apartado + enganche + mensualidades + pago final', () => {
const [bancario] = calcular()
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(
APARTADO_DEFAULT + 94475 + 343425 + 4121100,
2,
)
// En el Excel el total pagado del bancario coincide con el precio de lista (desc 0)
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(4579000, 2)
})
it('contado sin mensualidades: apartado + enganche + pago final = precio final c/desc', () => {
const [, , contado] = calcular()
expect(totalPagado(contado, APARTADO_DEFAULT)).toBeCloseTo(contado.precioFinal, 2)
expect(totalPagado(contado, APARTADO_DEFAULT)).toBeCloseTo(4487420, 2)
})
it('semi contado: incluye las mensualidades', () => {
const [, , , semi] = calcular()
expect(totalPagado(semi, APARTADO_DEFAULT)).toBeCloseTo(semi.precioFinal, 2)
expect(totalPagado(semi, APARTADO_DEFAULT)).toBeCloseTo(4533210, 2)
})
})
describe('indiceMenorTotal', () => {
it('devuelve el índice de la propuesta con menor total pagado', () => {
const propuestas = calcular()
const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT))
// Contado (2% desc) tiene el menor total pagado
expect(indiceMenorTotal(totales)).toBe(2)
})
it('en empate gana el primero', () => {
expect(indiceMenorTotal([100, 100, 200])).toBe(0)
})
it('lista vacía devuelve -1', () => {
expect(indiceMenorTotal([])).toBe(-1)
})
})

View File

@@ -0,0 +1,176 @@
import type { Unidad, TipoPropuesta, DescuentosPropuestas } from '../types'
/**
* Cálculos replicados fielmente del Excel de producción "COTIZADOR" de
* Frasa Desarrollos (ya validado). NO "corregir" las fórmulas: algunas
* usan precio de lista donde intuitivamente iría precio final; así está
* en el Excel y así debe quedar.
*/
export const APARTADO_DEFAULT = 20000
// Descuentos por defecto de cada propuesta (fracción, no porcentaje)
export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
creditoBancario: 0,
creditoInfonavit: 0,
contado: 0.02,
semiContado: 0.01,
}
export interface PropuestaResultado {
tipo: TipoPropuesta
descuento: number // fracción (0.02 = 2%)
montoDescuento: number
precioFinal: number // precio lista - descuento
enganche: number // NETO del apartado (el apartado ya se pagó)
numMensualidades: number
mensualidad: number | null // null => N/A (Contado)
mensualidadesTotal: number | null // null => N/A (Contado)
pagoFinal: number
}
export interface CotizacionInput {
unidad: Unidad
acabados: boolean // true => incluye cocina y closet
apartado: number
descuentos: DescuentosPropuestas
/** Permite forzar N en tests; por defecto se calcula desde fechaEntrega */
numMeses?: number
}
const MS_POR_MES = 30.4 * 86400000
/** Meses (redondeados, mínimo 1) desde hoy hasta la terminación de obra */
export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new Date()): number {
const entrega = new Date(`${fechaEntrega}T00:00:00`)
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
}
function baseComun(precioLista: number, descuento: number) {
// Excel COTIZADOR: MONTO DE DESCUENTO y PRECIO FINAL C/DESC
const montoDescuento = precioLista * descuento
const precioFinal = precioLista - montoDescuento
return { montoDescuento, precioFinal }
}
/** PROPUESTA 1 — CRÉDITO BANCARIO */
function calcularBancario(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M17 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M19 — pago final 90% del precio final c/desc
const pagoFinal = 0.9 * precioFinal
// Excel COTIZADOR!M18 — remanente a mensualidades
const mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
return {
tipo: 'CREDITO_BANCARIO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 2 — CRÉDITO INFONAVIT-COFINAVIT */
function calcularInfonavit(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M26 — enganche 2.5% sobre precio LISTA, neto del apartado
const enganche = 0.025 * precioLista - apartado
// Excel COTIZADOR!M28 — pago final 95% del precio final c/desc
const pagoFinal = 0.95 * precioFinal
// Excel COTIZADOR!M27 — OJO: el Excel usa precioLista aquí, NO precioFinal. Replicar tal cual.
const mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
return {
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** PROPUESTA 3 — CONTADO (sin mensualidades) */
function calcularContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M36 — enganche 90% del precio final c/desc, neto del apartado
const enganche = 0.9 * precioFinal - apartado
// Excel COTIZADOR!M37 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: null, // N/A
mensualidadesTotal: null, // N/A
pagoFinal,
}
}
/** PROPUESTA 4 — SEMI CONTADO */
function calcularSemiContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado {
const { montoDescuento, precioFinal } = baseComun(precioLista, descuento)
// Excel COTIZADOR!M45 — enganche 50% del precio final c/desc, neto del apartado
const enganche = 0.5 * precioFinal - apartado
// Excel COTIZADOR!M46 — 40% del precio final a mensualidades (= 1 - 0.5 - 0.1)
const mensualidadesTotal = precioFinal * 0.4
// Excel COTIZADOR!M47 — pago final 10% del precio final c/desc
const pagoFinal = 0.1 * precioFinal
return {
tipo: 'SEMI_CONTADO',
descuento,
montoDescuento,
precioFinal,
enganche,
numMensualidades: n,
mensualidad: mensualidadesTotal / n,
mensualidadesTotal,
pagoFinal,
}
}
/** Precio de lista según si incluye cocina y closet (acabados) */
export function obtenerPrecioLista(unidad: Unidad, acabados: boolean): number {
return acabados ? unidad.precioConAcabados : unidad.precioSinAcabados
}
/**
* Total pagado por el cliente en una propuesta:
* apartado + enganche (neto del apartado) + mensualidades + pago final.
*/
export function totalPagado(p: PropuestaResultado, apartado: number): number {
return apartado + p.enganche + (p.mensualidadesTotal ?? 0) + p.pagoFinal
}
/** Índice del menor total pagado; en empate gana el primero. -1 si la lista está vacía */
export function indiceMenorTotal(totales: number[]): number {
if (totales.length === 0) return -1
let indice = 0
for (let i = 1; i < totales.length; i++) {
if (totales[i] < totales[indice]) indice = i
}
return indice
}
/** Calcula las 4 propuestas comerciales */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? calcularNumMensualidades(unidad.fechaEntrega)
return [
calcularBancario(precioLista, apartado, descuentos.creditoBancario, n),
calcularInfonavit(precioLista, apartado, descuentos.creditoInfonavit, n),
calcularContado(precioLista, apartado, descuentos.contado, n),
calcularSemiContado(precioLista, apartado, descuentos.semiContado, n),
]
}

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { filtrarUnidades } from './filtros'
import { UNIDADES_SEED } from '../data/unidades'
import type { Unidad } from '../types'
describe('filtrarUnidades', () => {
it('sin texto devuelve todas las unidades disponibles', () => {
expect(filtrarUnidades(UNIDADES_SEED, '')).toHaveLength(UNIDADES_SEED.length)
expect(filtrarUnidades(UNIDADES_SEED, ' ')).toHaveLength(UNIDADES_SEED.length)
})
it('excluye unidades no disponibles', () => {
const conApartada: Unidad[] = [
...UNIDADES_SEED,
{ ...UNIDADES_SEED[0], codigo: 'U-999', status: 'APARTADA' },
]
const resultado = filtrarUnidades(conApartada, '')
expect(resultado).toHaveLength(UNIDADES_SEED.length)
expect(resultado.every((u) => u.status === 'DISPONIBLE')).toBe(true)
})
it('filtra por código', () => {
const resultado = filtrarUnidades(UNIDADES_SEED, '101')
expect(resultado.map((u) => u.codigo)).toEqual(['U-101'])
})
it('filtra por modelo sin importar mayúsculas', () => {
const resultado = filtrarUnidades(UNIDADES_SEED, '2ra')
expect(resultado.length).toBeGreaterThan(0)
expect(resultado.every((u) => u.modelo === '2RA')).toBe(true)
})
it('filtra por precio', () => {
// '45' aparece en 4,579,000 (U-101, U-107, U-108, U-114) y 4,450,000 (U-303, U-304, U-307, U-308)
const resultado = filtrarUnidades(UNIDADES_SEED, '45')
expect(resultado.map((u) => u.codigo).sort()).toEqual(
['U-101', 'U-107', 'U-108', 'U-114', 'U-303', 'U-304', 'U-307', 'U-308'].sort(),
)
})
it('sin coincidencias devuelve lista vacía', () => {
expect(filtrarUnidades(UNIDADES_SEED, 'xyz-000')).toEqual([])
})
})

View File

@@ -0,0 +1,21 @@
import type { Unidad } from '../types'
/**
* Filtra las unidades DISPONIBLE por código, modelo o precio.
* El texto se compara en minúsculas contra código y modelo, y como
* subcadena contra los precios (sin y con acabados).
*/
export function filtrarUnidades(unidades: Unidad[], texto: string): Unidad[] {
const disponibles = unidades.filter((u) => u.status === 'DISPONIBLE')
const q = texto.trim().toLowerCase()
if (!q) return disponibles
return disponibles.filter((u) => coincideUnidad(u, q))
}
function coincideUnidad(u: Unidad, q: string): boolean {
if (u.codigo.toLowerCase().includes(q)) return true
if (u.modelo.toLowerCase().includes(q)) return true
if (String(u.precioSinAcabados).includes(q)) return true
if (String(u.precioConAcabados).includes(q)) return true
return false
}

View File

@@ -0,0 +1,36 @@
const formatoMXN = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
})
/** Formato monetario MXN, p. ej. $4,579,000.00 */
export function formatoMoneda(valor: number): string {
return formatoMXN.format(valor)
}
/** Fracción (0.02) a texto porcentaje (2%) */
export function formatoPorcentaje(fraccion: number): string {
return `${(fraccion * 100).toLocaleString('es-MX', { maximumFractionDigits: 2 })}%`
}
/** Fecha en formato largo es-MX: "20 de agosto de 2026" */
export function formatoFechaLarga(fecha: Date): string {
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })
}
/** ISO 'YYYY-MM-DD' a fecha legible corta: "08/10/2027" */
export function formatoFechaCorta(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
/** Fecha ISO del día (para nombre de archivo): 'YYYY-MM-DD' */
export function fechaISOHoy(fecha: Date = new Date()): string {
const y = fecha.getFullYear()
const m = String(fecha.getMonth() + 1).padStart(2, '0')
const d = String(fecha.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest'
import { crearDocumentoPdf } from './pdf'
import { calcularPropuestas, APARTADO_DEFAULT, DESCUENTOS_DEFAULT } from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
describe('crearDocumentoPdf', () => {
it('construye el documento ANEXO A sin errores y con el nombre correcto', () => {
const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})
const { doc, nombreArchivo } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
precioLista: unidad.precioConAcabados,
propuestas,
cliente: { nombre: 'Juan Pérez' },
})
expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/)
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
// El contenido incluye los textos clave del formato
const texto = doc.output('datauristring')
expect(texto.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,179 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { Unidad, Cliente } from '../types'
import type { PropuestaResultado } from './calculations'
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
export interface PdfInput {
unidad: Unidad
acabados: boolean
apartado: number
precioLista: number
propuestas: PropuestaResultado[]
cliente: Cliente
}
const TITULOS: Record<PropuestaResultado['tipo'], string> = {
CREDITO_BANCARIO: 'CREDITO BANCARIO',
CREDITO_INFONAVIT_COFINAVIT: 'CREDITO INFONAVIT-COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI CONTADO',
}
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.'
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'
const DOCUMENTOS =
'1. Identificación oficial (INE o Pasaporte)\n2. Constancia de situación fiscal (RFC/CIF)\n3. CURP, Acta de Nacimiento / Matrimonio\n4. Comprobante de domicilio (no mayor a 3 meses)\n5. Estado de cuenta de donde saldrán los depositos'
const MARGEN = 14
// jspdf-autotable expone finalY en doc.lastAutoTable en runtime; sus tipos no lo declaran
type DocConTabla = jsPDF & { lastAutoTable: { finalY: number } }
const finalY = (doc: jsPDF): number => (doc as DocConTabla).lastAutoTable.finalY
export interface DocumentoPdf {
doc: jsPDF
nombreArchivo: string
}
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente }: PdfInput): DocumentoPdf {
const doc = new jsPDF()
const hoy = new Date()
const limiteDocumentos = new Date(hoy.getTime() + 7 * 86400000)
let y = MARGEN
const parrafo = (texto: string, opciones?: { negritas?: boolean; tamano?: number }) => {
const tamano = opciones?.tamano ?? 8
doc.setFontSize(tamano)
doc.setFont('helvetica', opciones?.negritas ? 'bold' : 'normal')
const lineas = doc.splitTextToSize(texto, 210 - MARGEN * 2) as string[]
// Salto de página si el bloque no cabe
if (y + lineas.length * tamano * 0.4 > 285) {
doc.addPage()
y = MARGEN
}
doc.text(lineas, MARGEN, y)
y += lineas.length * tamano * 0.4 + 3
}
// 1. Encabezado
doc.setFontSize(16)
doc.setFont('helvetica', 'bold')
doc.text('ANEXO A', 105, y, { align: 'center' })
y += 7
doc.setFontSize(10)
doc.setFont('helvetica', 'normal')
doc.text(formatoFechaLarga(hoy), 105, y, { align: 'center' })
y += 8
// 2. Tabla de unidad
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7 },
head: [[
'UNIDAD', 'MODELO', 'COCINA Y CLOSET', 'PRECIO DE LISTA $ MN', 'ESTATUS',
'SUP. CONSTRUCCION', 'SUP. EXCEDENTE', 'SUP. ESTACIONAMIENTO',
'MONTO APARTADO $ MN', 'CUOTA MANTENIMIENTO',
]],
body: [[
`CASA ${unidad.codigo}`,
unidad.modelo,
acabados ? 'SI' : 'NO',
formatoMoneda(precioLista),
unidad.status,
`${unidad.supConstruccion} m2`,
`${unidad.supExcedente} m2`,
`${unidad.supEstacionamiento} m2`,
formatoMoneda(apartado),
'1,500 Aprox.',
]],
})
y = finalY(doc) + 8
// 3. Propuesta general — una tabla por propuesta
doc.setFontSize(12)
doc.setFont('helvetica', 'bold')
doc.text('PROPUESTA GENERAL', MARGEN, y)
y += 4
propuestas.forEach((p, i) => {
const mensualidades =
p.mensualidad !== null && p.mensualidadesTotal !== null
? {
pagos: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
total: formatoMoneda(p.mensualidadesTotal),
}
: { pagos: 'N/A', total: 'N/A' }
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8 },
headStyles: { fillColor: [4, 120, 87] },
head: [[`PROPUESTA COMERCIAL #${i + 1} — ${TITULOS[p.tipo]}`, '%', 'PAGOS MENSUALES', 'TOTAL']],
body: [
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
['PRECIO FINAL C/DESC', '', '', formatoMoneda(p.precioFinal)],
['ENGANCHE', '', '', formatoMoneda(p.enganche)],
['MENSUALIDADES', '', mensualidades.pagos, mensualidades.total],
['PAGO FINAL', '', '', formatoMoneda(p.pagoFinal)],
],
})
y = finalY(doc) + 6
// 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
// 4. ¿Qué sigue?
parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 })
parrafo(`ENTREGAR LOS SIGUIENTES DOCUMENTOS MÁXIMO EL DÍA: ${formatoFechaLarga(limiteDocumentos)}`, {
negritas: true,
})
parrafo(DOCUMENTOS)
y += 2
// 5. Disclaimer
parrafo(DISCLAIMER)
y += 2
// 6. Cuenta para depósito
parrafo('CUENTA PARA DEPOSITO:', { negritas: true, tamano: 10 })
parrafo(CUENTA_DEPOSITO)
y += 4
// 7. Aceptación y firmas
parrafo('PROPUESTA COMERCIAL ACEPTADA # ____', { negritas: true, tamano: 10 })
y += 12
doc.setFontSize(9)
doc.line(MARGEN, y, MARGEN + 80, y)
doc.line(115, y, 195, y)
y += 5
doc.setFont('helvetica', 'bold')
doc.text(`CLIENTE: ${cliente.nombre}`, MARGEN, y)
doc.text('LIC. DEYSI ALEMAN', 115, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.text('ADMVO. DE VENTAS', 115, y)
y += 5
doc.text('Cel. 664-399-8451', 115, y)
return { doc, nombreArchivo: `cotizacion_${unidad.codigo}_${fechaISOHoy(hoy)}.pdf` }
}
/** Genera y descarga el PDF "ANEXO A" de la cotización */
export function generarPdf(input: PdfInput): void {
const { doc, nombreArchivo } = crearDocumentoPdf(input)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { calcularVigencia, VIGENCIA_DIAS } from './vigencia'
describe('calcularVigencia', () => {
it('la vigencia es de 3 días naturales', () => {
expect(VIGENCIA_DIAS).toBe(3)
})
it('suma 3 días naturales a la fecha de emisión', () => {
expect(calcularVigencia(new Date('2026-08-20T12:00:00'))).toEqual(new Date('2026-08-23T12:00:00'))
})
it('cruza el fin de mes correctamente', () => {
expect(calcularVigencia(new Date('2026-01-30T12:00:00'))).toEqual(new Date('2026-02-02T12:00:00'))
})
})

View File

@@ -0,0 +1,9 @@
/** Días naturales de vigencia de la cotización (igual que en el PDF) */
export const VIGENCIA_DIAS = 3
/** Fecha límite de vigencia: fecha de emisión + 3 días naturales */
export function calcularVigencia(hoy: Date = new Date()): Date {
const limite = new Date(hoy)
limite.setDate(limite.getDate() + VIGENCIA_DIAS)
return limite
}