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:
39
apps/alta-clientes/src/lib/pdf/avisoPrivacidad.ts
Normal file
39
apps/alta-clientes/src/lib/pdf/avisoPrivacidad.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import type { AltaCliente } from '../../types'
|
||||
import { formatoFechaLarga, nombreArchivoPdf } from '../formatters'
|
||||
import {
|
||||
AVISO_PRIVACIDAD_TITULO,
|
||||
AVISO_PRIVACIDAD_PARRAFOS,
|
||||
avisoPrivacidadParrafoFinal,
|
||||
} from '../../data/textosLegales'
|
||||
import { MARGEN, parrafo, titulo, lineaFirma, type DocumentoPdf } from './helpers'
|
||||
|
||||
/** Construye el PDF "AVISO DE PRIVACIDAD (SINÓPTICO)" (sin descargarlo) */
|
||||
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
|
||||
const { datosGenerales: g, firma } = alta
|
||||
const doc = new jsPDF()
|
||||
let y = titulo(doc, MARGEN + 4, AVISO_PRIVACIDAD_TITULO, 13)
|
||||
y += 2
|
||||
|
||||
for (const p of AVISO_PRIVACIDAD_PARRAFOS) {
|
||||
y = parrafo(doc, y, p)
|
||||
y += 1
|
||||
}
|
||||
y = parrafo(doc, y, avisoPrivacidadParrafoFinal(g.nombreCompleto))
|
||||
y += 4
|
||||
|
||||
const fecha = g.fecha ? new Date(`${g.fecha}T00:00:00`) : new Date()
|
||||
y = parrafo(doc, y, `Tijuana, Baja California, a ${formatoFechaLarga(fecha)}.`)
|
||||
y += 2
|
||||
y = parrafo(doc, y, 'ENTERADO-CONFORME\nAUTORIZO EL USO DE MIS DATOS', { negritas: true })
|
||||
y += 12
|
||||
lineaFirma(doc, y, MARGEN + 40, 100, g.nombreCompleto, firma.firmaDataUrl)
|
||||
|
||||
return { doc, nombreArchivo: nombreArchivoPdf('aviso_privacidad_firmado', g.nombreCompleto) }
|
||||
}
|
||||
|
||||
/** Genera y descarga el PDF del aviso de privacidad */
|
||||
export function descargar(alta: AltaCliente): void {
|
||||
const { doc, nombreArchivo } = crearDocumento(alta)
|
||||
doc.save(nombreArchivo)
|
||||
}
|
||||
54
apps/alta-clientes/src/lib/pdf/cartaAntilavado.ts
Normal file
54
apps/alta-clientes/src/lib/pdf/cartaAntilavado.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import type { AltaCliente } from '../../types'
|
||||
import { nombreArchivoPdf } from '../formatters'
|
||||
import {
|
||||
cartaAntilavadoCuerpo,
|
||||
CARTA_ANTILAVADO_CIERRE,
|
||||
CARTA_ANTILAVADO_DESPEDIDA,
|
||||
} from '../../data/textosLegales'
|
||||
import { MARGEN, parrafo, lineaFirma, type DocumentoPdf } from './helpers'
|
||||
|
||||
/** Construye la carta de no pagos en efectivo (anti-lavado) sin descargarla */
|
||||
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
|
||||
const { datosGenerales: g, viviendaCredito: v, firma } = alta
|
||||
const doc = new jsPDF()
|
||||
const fecha = g.fecha ? new Date(`${g.fecha}T00:00:00`) : new Date()
|
||||
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro ?? '' : v.proyecto
|
||||
|
||||
let y = MARGEN + 4
|
||||
doc.setFontSize(9)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
const dd = String(fecha.getDate()).padStart(2, '0')
|
||||
const mm = String(fecha.getMonth() + 1).padStart(2, '0')
|
||||
doc.text(`Tijuana, B.C a ${dd} de ${mm} de ${fecha.getFullYear()}`, MARGEN, y)
|
||||
y += 10
|
||||
|
||||
y = parrafo(doc, y, `Estimado (a) ${g.nombreCompleto}`, { negritas: true, tamano: 9 })
|
||||
y = parrafo(doc, y, 'P R E S E N T E', { negritas: true, tamano: 9 })
|
||||
y += 3
|
||||
|
||||
y = parrafo(doc, y, cartaAntilavadoCuerpo(proyecto), { tamano: 9 })
|
||||
y += 2
|
||||
y = parrafo(doc, y, CARTA_ANTILAVADO_CIERRE, { tamano: 9 })
|
||||
y += 2
|
||||
y = parrafo(doc, y, CARTA_ANTILAVADO_DESPEDIDA, { tamano: 9 })
|
||||
y += 6
|
||||
|
||||
y = parrafo(doc, y, 'A T E N T A M E N T E', { negritas: true, tamano: 9 })
|
||||
y += 12
|
||||
y = lineaFirma(doc, y, MARGEN + 40, 100, 'Frasa Desarrollos SAPI C.V.')
|
||||
y = parrafo(doc, y, 'Por medio de su representante legal', { tamano: 9 })
|
||||
y += 8
|
||||
y = parrafo(doc, y, 'DE CONFORMIDAD', { negritas: true, tamano: 9 })
|
||||
y += 12
|
||||
y = lineaFirma(doc, y, MARGEN + 40, 100, g.nombreCompleto, firma.firmaDataUrl)
|
||||
parrafo(doc, y, 'En mi carácter de “COMPRADOR”', { tamano: 9 })
|
||||
|
||||
return { doc, nombreArchivo: nombreArchivoPdf('carta_antilavado_firmada', g.nombreCompleto) }
|
||||
}
|
||||
|
||||
/** Genera y descarga la carta anti-lavado */
|
||||
export function descargar(alta: AltaCliente): void {
|
||||
const { doc, nombreArchivo } = crearDocumento(alta)
|
||||
doc.save(nombreArchivo)
|
||||
}
|
||||
116
apps/alta-clientes/src/lib/pdf/checklistExpediente.ts
Normal file
116
apps/alta-clientes/src/lib/pdf/checklistExpediente.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { AltaCliente } from '../../types'
|
||||
import { formatoMoneda, nombreArchivoPdf } from '../formatters'
|
||||
import {
|
||||
CHECKLIST_BASICO,
|
||||
CHECKLIST_ANTES_ESCRITURA,
|
||||
CHECKLIST_NOTAS,
|
||||
REQUISITOS_GENERALES,
|
||||
REQUISITOS_CONTACTO_ENTREGA,
|
||||
REQUISITOS_CONTACTO_CONTRATO,
|
||||
} from '../../data/textosLegales'
|
||||
import { MARGEN, finalY, parrafo, titulo, lineaFirma, numero, type DocumentoPdf } from './helpers'
|
||||
|
||||
const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '—')
|
||||
|
||||
/**
|
||||
* Construye el PDF "FORMATO DE RECEPCIÓN Y CONTROL DE EXPEDIENTE" +
|
||||
* página de "REQUISITOS GENERALES" (sin descargarlo)
|
||||
*/
|
||||
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
|
||||
const { datosGenerales: g, viviendaCredito: v, referencias, firma } = alta
|
||||
const doc = new jsPDF()
|
||||
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro ?? '' : v.proyecto
|
||||
|
||||
let y = titulo(doc, MARGEN + 4, 'FORMATO DE RECEPCIÓN Y\nCONTROL DE EXPEDIENTE', 13)
|
||||
y += 2
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 8, cellPadding: 1.5 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
body: [
|
||||
['CLIENTE', g.nombreCompleto],
|
||||
['PROYECTO', vacio(proyecto)],
|
||||
['UBICACIÓN / PROTOTIPO', `${vacio(v.ubicacion)} ${vacio(v.modelo)}`.replace('— —', '—')],
|
||||
['VALOR VIVIENDA', formatoMoneda(numero(v.valorVenta))],
|
||||
['ENGANCHE', formatoMoneda(numero(v.enganche))],
|
||||
['FECHA PAGO DE ENGANCHE', vacio(v.fechaPagoEnganche)],
|
||||
['FIRMA DE CONTRATO', vacio(v.firmaContrato)],
|
||||
],
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
// Checklists en dos columnas
|
||||
doc.setFontSize(9)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text('CHECK LIST EXPEDIENTE BÁSICO:', MARGEN, y)
|
||||
doc.text('ANTES DE ESCRITURA:', 115, y)
|
||||
y += 5
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
const filas = Math.max(CHECKLIST_BASICO.length, CHECKLIST_ANTES_ESCRITURA.length)
|
||||
for (let i = 0; i < filas; i++) {
|
||||
if (CHECKLIST_BASICO[i]) doc.text(`[ ] ${CHECKLIST_BASICO[i]}`, MARGEN, y)
|
||||
if (CHECKLIST_ANTES_ESCRITURA[i]) doc.text(`[ ] ${CHECKLIST_ANTES_ESCRITURA[i]}`, 115, y)
|
||||
y += 5
|
||||
}
|
||||
y += 4
|
||||
|
||||
for (const nota of CHECKLIST_NOTAS) {
|
||||
y = parrafo(doc, y, nota)
|
||||
y += 1
|
||||
}
|
||||
y += 14
|
||||
|
||||
// Firmas de recepción del expediente
|
||||
const yFirmas = y
|
||||
lineaFirma(doc, yFirmas, MARGEN, 55, 'GLADIS VALENZUELA\nFECHA DE RECEPCIÓN')
|
||||
lineaFirma(doc, yFirmas, 78, 55, 'DEYSI LORENA ALEMAN FIGUEROA\nCORPORATIVO')
|
||||
lineaFirma(doc, yFirmas, 142, 55, 'DANIEL ALVARADO LUQUE\nRECIBIÓ')
|
||||
|
||||
// Página 2: requisitos generales
|
||||
doc.addPage()
|
||||
y = titulo(doc, MARGEN + 4, 'REQUISITOS\nGENERALES', 13)
|
||||
y = parrafo(doc, y, `NOMBRE: ${g.nombreCompleto}`, { negritas: true, tamano: 9 })
|
||||
y = parrafo(doc, y, `FECHA: ${vacio(g.fecha)}`, { negritas: true, tamano: 9 })
|
||||
y += 2
|
||||
|
||||
for (const grupo of REQUISITOS_GENERALES) {
|
||||
y = parrafo(doc, y, grupo.titulo, { negritas: true, tamano: 9 })
|
||||
for (const item of grupo.items) {
|
||||
y = parrafo(doc, y, `• ${item}`)
|
||||
}
|
||||
y += 2
|
||||
}
|
||||
|
||||
// Referencias en U.S.A. (tabla vacía para llenar a mano)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['NOMBRE', 'TELÉFONO', 'PARENTESCO', 'DIRECCIÓN']],
|
||||
body: referencias.map((r) => [vacio(r.nombre), vacio(r.telefono), vacio(r.parentesco), vacio(r.direccion)]),
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
y = parrafo(doc, y, 'ENTREGA DE DOCUMENTOS:', { negritas: true, tamano: 9 })
|
||||
y = parrafo(doc, y, REQUISITOS_CONTACTO_ENTREGA)
|
||||
y += 2
|
||||
y = parrafo(doc, y, 'FIRMA DE CONTRATO:', { negritas: true, tamano: 9 })
|
||||
y = parrafo(doc, y, REQUISITOS_CONTACTO_CONTRATO)
|
||||
y += 12
|
||||
lineaFirma(doc, y, MARGEN + 10, 70, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl)
|
||||
lineaFirma(doc, y, 120, 70, `NOMBRE Y FIRMA DEL ASESOR\n${firma.asesorNombre}`)
|
||||
|
||||
return { doc, nombreArchivo: nombreArchivoPdf('checklist_expediente', g.nombreCompleto) }
|
||||
}
|
||||
|
||||
/** Genera y descarga el PDF del checklist de expediente */
|
||||
export function descargar(alta: AltaCliente): void {
|
||||
const { doc, nombreArchivo } = crearDocumento(alta)
|
||||
doc.save(nombreArchivo)
|
||||
}
|
||||
79
apps/alta-clientes/src/lib/pdf/helpers.ts
Normal file
79
apps/alta-clientes/src/lib/pdf/helpers.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
|
||||
export const MARGEN = 14
|
||||
const ANCHO_UTIL = 210 - MARGEN * 2
|
||||
const LIMITE_PAGINA = 285
|
||||
|
||||
export interface DocumentoPdf {
|
||||
doc: jsPDF
|
||||
nombreArchivo: string
|
||||
}
|
||||
|
||||
// jspdf-autotable expone finalY en doc.lastAutoTable en runtime; sus tipos no lo declaran
|
||||
type DocConTabla = jsPDF & { lastAutoTable: { finalY: number } }
|
||||
export const finalY = (doc: jsPDF): number => (doc as DocConTabla).lastAutoTable.finalY
|
||||
|
||||
export interface OpcionesParrafo {
|
||||
negritas?: boolean
|
||||
tamano?: number
|
||||
alinear?: 'left' | 'center' | 'right'
|
||||
}
|
||||
|
||||
/** Escribe un párrafo con salto de página automático. Devuelve la nueva Y. */
|
||||
export function parrafo(doc: jsPDF, y: number, texto: string, opciones: OpcionesParrafo = {}): number {
|
||||
const tamano = opciones.tamano ?? 8
|
||||
doc.setFontSize(tamano)
|
||||
doc.setFont('helvetica', opciones.negritas ? 'bold' : 'normal')
|
||||
const lineas = doc.splitTextToSize(texto, ANCHO_UTIL) as string[]
|
||||
if (y + lineas.length * tamano * 0.4 > LIMITE_PAGINA) {
|
||||
doc.addPage()
|
||||
y = MARGEN
|
||||
}
|
||||
const alinear = opciones.alinear ?? 'left'
|
||||
const x = alinear === 'center' ? 105 : alinear === 'right' ? 210 - MARGEN : MARGEN
|
||||
doc.text(lineas, x, y, { align: alinear })
|
||||
return y + lineas.length * tamano * 0.4 + 3
|
||||
}
|
||||
|
||||
/** Título centrado. Devuelve la nueva Y. */
|
||||
export function titulo(doc: jsPDF, y: number, texto: string, tamano = 12): number {
|
||||
doc.setFontSize(tamano)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
const lineas = doc.splitTextToSize(texto, ANCHO_UTIL) as string[]
|
||||
doc.text(lineas, 105, y, { align: 'center' })
|
||||
return y + lineas.length * tamano * 0.45 + 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Línea de firma con leyenda; si hay imagen de firma se incrusta sobre la línea.
|
||||
* Devuelve la nueva Y.
|
||||
*/
|
||||
export function lineaFirma(
|
||||
doc: jsPDF,
|
||||
y: number,
|
||||
x: number,
|
||||
ancho: number,
|
||||
leyenda: string,
|
||||
firmaDataUrl?: string,
|
||||
): number {
|
||||
if (y + 25 > LIMITE_PAGINA) {
|
||||
doc.addPage()
|
||||
y = MARGEN + 10
|
||||
}
|
||||
if (firmaDataUrl) {
|
||||
doc.addImage(firmaDataUrl, 'PNG', x + ancho / 2 - 15, y - 14, 30, 14)
|
||||
}
|
||||
doc.setLineWidth(0.3)
|
||||
doc.line(x, y, x + ancho, y)
|
||||
doc.setFontSize(8)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
const lineas = doc.splitTextToSize(leyenda, ancho) as string[]
|
||||
doc.text(lineas, x + ancho / 2, y + 4, { align: 'center' })
|
||||
return y + 4 + lineas.length * 8 * 0.4 + 4
|
||||
}
|
||||
|
||||
/** Parsea un texto numérico del formulario; vacío o inválido devuelve 0 */
|
||||
export function numero(valor: string): number {
|
||||
const n = Number.parseFloat(valor)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
233
apps/alta-clientes/src/lib/pdf/identificacionCliente.ts
Normal file
233
apps/alta-clientes/src/lib/pdf/identificacionCliente.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { AltaCliente } from '../../types'
|
||||
import { formatoFechaCorta, nombreArchivoPdf } from '../formatters'
|
||||
import {
|
||||
PLD_TEXTO_PROTECCION_DATOS,
|
||||
PLD_TEXTO_DOCUMENTOS_INTEGRAN,
|
||||
PLD_TEXTO_BENEFICIARIO,
|
||||
PLD_TEXTO_RESPONSABILIDAD,
|
||||
PLD_TEXTO_OTRO_FORMATO,
|
||||
beneficiarioControladorIntro,
|
||||
BENEFICIARIO_CONTROLADOR_ARTICULO3,
|
||||
BENEFICIARIO_CONTROLADOR_MANIFIESTO,
|
||||
BENEFICIARIO_CONTROLADOR_CIERRE,
|
||||
} from '../../data/textosLegales'
|
||||
import { MARGEN, finalY, parrafo, titulo, lineaFirma, type DocumentoPdf } from './helpers'
|
||||
|
||||
const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '')
|
||||
|
||||
/**
|
||||
* Construye el PDF "FORMATO DE IDENTIFICACION DE CLIENTES" (PLD) +
|
||||
* "FORMATO PARA LA IDENTIFICACIÓN DEL BENEFICIARIO CONTROLADOR" (sin descargarlo)
|
||||
*/
|
||||
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
|
||||
const { datosGenerales: g, viviendaCredito: v, firma } = alta
|
||||
const df = v.domicilioFiscal
|
||||
const doc = new jsPDF()
|
||||
let y = titulo(
|
||||
doc,
|
||||
MARGEN + 4,
|
||||
'FORMATO DE IDENTIFICACION DE CLIENTES\nPERSONA FÍSICA NACIONALIDAD MEXICANA O EXTRANJERA\nCON RESIDENCIA PERMANENTE O TEMPORAL',
|
||||
)
|
||||
doc.setFontSize(8)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text(`FECHA: ${formatoFechaCorta(g.fecha)}`, 210 - MARGEN, y, { align: 'right' })
|
||||
y += 5
|
||||
|
||||
// Campos 1 a 11 del formato PLD
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['1 - Nombre y apellidos del cliente']],
|
||||
body: [[g.nombreCompleto]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['2 - Fecha de Nacimiento', '3 - Lugar de Nacimiento']],
|
||||
body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['4 - Nacionalizado o Residente', '4 - País']],
|
||||
body: [[vacio(g.nacionalidad), 'MEXICO']],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['5 - Actividad, Ocupación o Profesión']],
|
||||
body: [[vacio(g.actividadOcupacion)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
// 6 - Domicilio particular
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['6 - Domicilio Particular', '']],
|
||||
body: [
|
||||
['Calle o Avenida / Dirección', vacio(g.direccion)],
|
||||
['Colonia o Zona Urbana / Municipio', [vacio(g.delegacionMunicipio)].join(' ')],
|
||||
['Entidad Federativa / Código Postal / País', `BAJA CALIFORNIA ${vacio(g.codigoPostal)} MEXICO`.trim()],
|
||||
],
|
||||
})
|
||||
y = finalY(doc)
|
||||
// 6 - Domicilio fiscal (opcional)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['6 - Domicilio Fiscal (Art 29 y 29A CFF)', '']],
|
||||
body: [
|
||||
['Calle o Avenida', vacio(df.calle)],
|
||||
['Número Exterior / Número Interior', `${vacio(df.numExt)} ${vacio(df.numInt)}`.trim()],
|
||||
['Colonia o Zona Urbana', vacio(df.colonia)],
|
||||
['Municipio o Demarcación Territorial', vacio(df.municipio)],
|
||||
['Entidad Federativa / Código Postal / País', `${vacio(df.estado)} ${vacio(df.cp)} ${vacio(df.pais)}`.trim()],
|
||||
],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['7 - Número Telefónico Casa', '7 - Número Telefónico Celular']],
|
||||
body: [[vacio(g.telCasa), vacio(g.celular)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['8 - Correo Electrónico Válido', '9 - CURP']],
|
||||
body: [[g.email, g.curp]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['10 - RFC (SAT)']],
|
||||
body: [[g.rfc]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['11 - Datos de la Identificación Oficial Vigente', '']],
|
||||
body: [
|
||||
['Nombre en Identificación', g.nombreCompleto],
|
||||
['Autoridad que la Emite', vacio(v.idAutoridadEmisora)],
|
||||
['Número de Identificación / Vigencia', `${vacio(v.idNumero)} ${formatoFechaCorta(v.idVigencia ?? '')}`.trim()],
|
||||
],
|
||||
})
|
||||
y = finalY(doc) + 5
|
||||
|
||||
// Textos legales PLD
|
||||
y = parrafo(doc, y, PLD_TEXTO_PROTECCION_DATOS)
|
||||
y = parrafo(doc, y, 'IV.- DOCUMENTOS QUE SE INTEGRAN AL PRESENTE', { negritas: true })
|
||||
y = parrafo(doc, y, PLD_TEXTO_DOCUMENTOS_INTEGRAN)
|
||||
y = parrafo(doc, y, 'Identificación Oficial con Fotografía Comprobante de Domicilio Actual RFC CURP', {
|
||||
negritas: true,
|
||||
})
|
||||
y = parrafo(doc, y, PLD_TEXTO_BENEFICIARIO)
|
||||
y = parrafo(doc, y, PLD_TEXTO_RESPONSABILIDAD)
|
||||
y = parrafo(doc, y, PLD_TEXTO_OTRO_FORMATO)
|
||||
y += 10
|
||||
y = lineaFirma(doc, y, MARGEN + 40, 100, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl)
|
||||
|
||||
// Página 2: beneficiario controlador
|
||||
doc.addPage()
|
||||
y = titulo(doc, MARGEN + 4, 'FORMATO PARA LA IDENTIFICACIÓN DEL\nBENEFICIARIO CONTROLADOR')
|
||||
const condominio = vacio(v.manzana) || '___'
|
||||
y = parrafo(doc, y, beneficiarioControladorIntro(g.nombreCompleto, vacio(v.ubicacion) || '___', condominio))
|
||||
y = parrafo(doc, y, BENEFICIARIO_CONTROLADOR_ARTICULO3)
|
||||
y = parrafo(doc, y, BENEFICIARIO_CONTROLADOR_MANIFIESTO)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['Nombre', 'Nacionalidad']],
|
||||
body: [
|
||||
[g.nombreCompleto, vacio(g.nacionalidad)],
|
||||
],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['Fecha Nacimiento', 'Lugar Nacimiento']],
|
||||
body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['Domicilio']],
|
||||
body: [[`${vacio(g.direccion)} ${vacio(g.codigoPostal)} ${vacio(g.delegacionMunicipio)} BC MEXICO`.trim()]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['Teléfono Fijo', 'Teléfono Celular']],
|
||||
body: [[vacio(g.telCasa), vacio(g.celular)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['Correo Electrónico', 'Ocupación']],
|
||||
body: [[g.email, vacio(g.actividadOcupacion)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['C.U.R.P', 'R.F.C']],
|
||||
body: [[g.curp, g.rfc]],
|
||||
})
|
||||
y = finalY(doc) + 5
|
||||
y = parrafo(doc, y, BENEFICIARIO_CONTROLADOR_CIERRE)
|
||||
y += 10
|
||||
lineaFirma(doc, y, MARGEN + 40, 100, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl)
|
||||
|
||||
return { doc, nombreArchivo: nombreArchivoPdf('formato_identificacion_cliente', g.nombreCompleto) }
|
||||
}
|
||||
|
||||
/** Genera y descarga el PDF del formato PLD de identificación */
|
||||
export function descargar(alta: AltaCliente): void {
|
||||
const { doc, nombreArchivo } = crearDocumento(alta)
|
||||
doc.save(nombreArchivo)
|
||||
}
|
||||
33
apps/alta-clientes/src/lib/pdf/pdf.test.ts
Normal file
33
apps/alta-clientes/src/lib/pdf/pdf.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fixtureAlta } from '../fixture'
|
||||
import * as perfilCliente from './perfilCliente'
|
||||
import * as identificacionCliente from './identificacionCliente'
|
||||
import * as avisoPrivacidad from './avisoPrivacidad'
|
||||
import * as cartaAntilavado from './cartaAntilavado'
|
||||
import * as checklistExpediente from './checklistExpediente'
|
||||
|
||||
const generadores = [
|
||||
['perfil_cliente_completo', perfilCliente],
|
||||
['formato_identificacion_cliente', identificacionCliente],
|
||||
['aviso_privacidad_firmado', avisoPrivacidad],
|
||||
['carta_antilavado_firmada', cartaAntilavado],
|
||||
['checklist_expediente', checklistExpediente],
|
||||
] as const
|
||||
|
||||
describe('generadores de PDF (smoke test con fixture)', () => {
|
||||
for (const [tipo, modulo] of generadores) {
|
||||
it(`construye ${tipo} sin lanzar error`, () => {
|
||||
const { doc, nombreArchivo } = modulo.crearDocumento(fixtureAlta)
|
||||
expect(doc).toBeDefined()
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThan(0)
|
||||
expect(nombreArchivo).toMatch(
|
||||
new RegExp(`^${tipo}_PASCUAL_MARTIN_TELLEZ_CHIGUINDO_\\d{4}-\\d{2}-\\d{2}\\.pdf$`),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
it('el formato PLD y el checklist agregan la segunda página', () => {
|
||||
expect(identificacionCliente.crearDocumento(fixtureAlta).doc.getNumberOfPages()).toBeGreaterThanOrEqual(2)
|
||||
expect(checklistExpediente.crearDocumento(fixtureAlta).doc.getNumberOfPages()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
245
apps/alta-clientes/src/lib/pdf/perfilCliente.ts
Normal file
245
apps/alta-clientes/src/lib/pdf/perfilCliente.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { AltaCliente } from '../../types'
|
||||
import { formatoMoneda, formatoFechaCorta, nombreArchivoPdf } from '../formatters'
|
||||
import { calcularTotalIngresos, calcularGastosTotal } from '../calculations'
|
||||
import { TIPOS_CREDITO } from '../../data/constantes'
|
||||
import { MARGEN, finalY, titulo, lineaFirma, numero, type DocumentoPdf } from './helpers'
|
||||
|
||||
const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '—')
|
||||
|
||||
function seccion(doc: jsPDF, y: number, texto: string): number {
|
||||
if (y > 260) {
|
||||
doc.addPage()
|
||||
y = MARGEN
|
||||
}
|
||||
doc.setFontSize(10)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text(texto, MARGEN, y)
|
||||
return y + 3
|
||||
}
|
||||
|
||||
/** Construye el PDF "PERFIL DEL CLIENTE" (sin descargarlo) */
|
||||
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
|
||||
const { datosGenerales: g, empleo: e, economicos: eco, coacreditado, referencias, viviendaCredito: v, firma } = alta
|
||||
const doc = new jsPDF()
|
||||
let y = titulo(doc, MARGEN + 4, 'PERFIL DEL CLIENTE', 14)
|
||||
|
||||
// PERSONALES
|
||||
y = seccion(doc, y, 'PERSONALES')
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['FECHA', 'NOMBRE', 'RFC']],
|
||||
body: [
|
||||
[formatoFechaCorta(g.fecha), g.nombreCompleto, g.rfc],
|
||||
],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['FECHA NACIMIENTO', 'LUGAR DE NACIMIENTO', 'NACIONALIDAD']],
|
||||
body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento), vacio(g.nacionalidad)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['CURP', 'CORREO', 'TELÉFONO']],
|
||||
body: [[g.curp, g.email, g.celular]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['ESTADO CIVIL', 'RÉGIMEN BIENES', 'PESO', 'ESTATURA']],
|
||||
body: [[vacio(g.estadoCivil), vacio(g.regimenBienes), vacio(g.peso), vacio(g.estatura)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['NIVEL DE ESTUDIOS', 'DIRECCIÓN']],
|
||||
body: [[vacio(g.nivelEstudios), vacio(g.direccion)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['DELEG./MUNICIPIO', 'C.P.', 'ANTIGÜEDAD', 'TIPO DE VIVIENDA', 'TIPO DE COMPRA']],
|
||||
body: [
|
||||
[vacio(g.delegacionMunicipio), vacio(g.codigoPostal), vacio(g.antiguedadDomicilio), vacio(g.tipoVivienda), vacio(g.tipoCompra)],
|
||||
],
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
// EMPLEO
|
||||
y = seccion(doc, y, 'EMPLEO')
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['TIPO DE EMPLEO', 'TIPO DE ECONOMÍA', 'COMPAÑÍA', 'ANTIGÜEDAD']],
|
||||
body: [
|
||||
[
|
||||
vacio(e.tipoEmpleo),
|
||||
vacio(e.tipoEconomia),
|
||||
vacio(e.compania),
|
||||
`${vacio(e.antiguedadAnios)} AÑOS ${vacio(e.antiguedadMeses)} MESES`,
|
||||
],
|
||||
],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['DIRECCIÓN', 'C.P.', 'PUESTO', 'TELÉFONO', 'EXT.']],
|
||||
body: [
|
||||
[
|
||||
[vacio(e.direccion), vacio(e.colonia), vacio(e.numero)].filter((p) => p !== '—').join(' ') || '—',
|
||||
vacio(e.codigoPostal),
|
||||
vacio(e.puesto),
|
||||
vacio(e.telefono),
|
||||
vacio(e.ext),
|
||||
],
|
||||
],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['INGRESO MENSUAL', 'OTROS INGRESOS', 'TOTAL INGRESOS', 'GASTOS APROXIMADOS', 'DEPENDIENTES ECONÓMICOS']],
|
||||
body: [
|
||||
[
|
||||
formatoMoneda(eco.ingresosMensuales),
|
||||
formatoMoneda(eco.otrosIngresos),
|
||||
formatoMoneda(calcularTotalIngresos(eco)),
|
||||
formatoMoneda(calcularGastosTotal(eco)),
|
||||
vacio(eco.dependientes.personas),
|
||||
],
|
||||
],
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
// CO-ACREDITADO O CÓNYUGE
|
||||
if (coacreditado.tieneCoacreditado) {
|
||||
const cg = coacreditado.generales
|
||||
const ce = coacreditado.empleo
|
||||
const ceco = coacreditado.economicos
|
||||
y = seccion(doc, y, 'CO-ACREDITADO O CÓNYUGE')
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['NOMBRE', 'RFC', 'FECHA NACIMIENTO', 'CURP']],
|
||||
body: [[cg.nombreCompleto, vacio(cg.rfc), formatoFechaCorta(cg.fechaNacimiento ?? '') || '—', vacio(cg.curp)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['CORREO', 'TELÉFONO', 'ESTADO CIVIL', 'COMPAÑÍA', 'PUESTO']],
|
||||
body: [[vacio(cg.email), vacio(cg.celular), vacio(cg.estadoCivil), vacio(ce.compania), vacio(ce.puesto)]],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['INGRESO MENSUAL', 'OTROS INGRESOS', 'TOTAL INGRESOS', 'GASTOS APROXIMADOS']],
|
||||
body: [
|
||||
[
|
||||
formatoMoneda(ceco.ingresosMensuales),
|
||||
formatoMoneda(ceco.otrosIngresos),
|
||||
formatoMoneda(calcularTotalIngresos(ceco)),
|
||||
formatoMoneda(calcularGastosTotal(ceco)),
|
||||
],
|
||||
],
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
}
|
||||
|
||||
// REFERENCIAS PERSONALES
|
||||
y = seccion(doc, y, 'REFERENCIAS PERSONALES')
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['NOMBRE', 'TELÉFONO', 'PARENTESCO', 'DIRECCIÓN']],
|
||||
body: referencias.map((r) => [vacio(r.nombre), vacio(r.telefono), vacio(r.parentesco), vacio(r.direccion)]),
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
// DESARROLLO
|
||||
y = seccion(doc, y, 'DESARROLLO')
|
||||
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro ?? '' : v.proyecto
|
||||
const creditos = v.tiposCredito
|
||||
.map((t) => TIPOS_CREDITO.find((tc) => tc.valor === t)?.etiqueta ?? t)
|
||||
.join(', ')
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['PROYECTO', 'UBICACIÓN', 'PROTOTIPO']],
|
||||
body: [
|
||||
[
|
||||
[`${vacio(v.manzana)} ${vacio(v.lote)}`.replace(/—/g, '').trim(), proyecto, vacio(v.modelo)].join(' ').trim(),
|
||||
vacio(v.ubicacion),
|
||||
vacio(v.modelo),
|
||||
],
|
||||
],
|
||||
})
|
||||
y = finalY(doc)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 7, cellPadding: 1 },
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
head: [['VALOR VIVIENDA', 'ENGANCHE', 'TIPO DE CRÉDITO', 'MONTO CRÉDITO INFO/FOVI']],
|
||||
body: [
|
||||
[
|
||||
formatoMoneda(numero(v.valorVenta)),
|
||||
formatoMoneda(numero(v.enganche)),
|
||||
creditos || '—',
|
||||
formatoMoneda(numero(v.montoCreditoInfoFovi)),
|
||||
],
|
||||
],
|
||||
})
|
||||
y = finalY(doc) + 18
|
||||
|
||||
// Firmas
|
||||
lineaFirma(doc, y, MARGEN + 10, 70, g.nombreCompleto, firma.firmaDataUrl)
|
||||
lineaFirma(doc, y, 120, 70, firma.asesorNombre || 'ASESOR')
|
||||
|
||||
return { doc, nombreArchivo: nombreArchivoPdf('perfil_cliente_completo', g.nombreCompleto) }
|
||||
}
|
||||
|
||||
/** Genera y descarga el PDF del perfil del cliente */
|
||||
export function descargar(alta: AltaCliente): void {
|
||||
const { doc, nombreArchivo } = crearDocumento(alta)
|
||||
doc.save(nombreArchivo)
|
||||
}
|
||||
Reference in New Issue
Block a user