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:
144
apps/cotizador/src/lib/calculations.test.ts
Normal file
144
apps/cotizador/src/lib/calculations.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
176
apps/cotizador/src/lib/calculations.ts
Normal file
176
apps/cotizador/src/lib/calculations.ts
Normal 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),
|
||||
]
|
||||
}
|
||||
44
apps/cotizador/src/lib/filtros.test.ts
Normal file
44
apps/cotizador/src/lib/filtros.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
21
apps/cotizador/src/lib/filtros.ts
Normal file
21
apps/cotizador/src/lib/filtros.ts
Normal 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
|
||||
}
|
||||
36
apps/cotizador/src/lib/formatters.ts
Normal file
36
apps/cotizador/src/lib/formatters.ts
Normal 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}`
|
||||
}
|
||||
30
apps/cotizador/src/lib/pdf.test.ts
Normal file
30
apps/cotizador/src/lib/pdf.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
179
apps/cotizador/src/lib/pdf.ts
Normal file
179
apps/cotizador/src/lib/pdf.ts
Normal 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)
|
||||
}
|
||||
16
apps/cotizador/src/lib/vigencia.test.ts
Normal file
16
apps/cotizador/src/lib/vigencia.test.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
9
apps/cotizador/src/lib/vigencia.ts
Normal file
9
apps/cotizador/src/lib/vigencia.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user