diff --git a/api-gateway/knowledge/catalogo-senda.md b/api-gateway/knowledge/catalogo-senda.md index 4bf0d65..c3e5329 100644 --- a/api-gateway/knowledge/catalogo-senda.md +++ b/api-gateway/knowledge/catalogo-senda.md @@ -1,6 +1,6 @@ # Catálogo de unidades — Desarrollo SENDA -SENDA es un desarrollo de **casas** de Frasa Desarrollos. Los códigos de unidad tienen la forma `manzana-lote-casa` (ej. 411-1-7 = manzana 411, lote 1, casa 7). Inventario: **139 casas**; al 19-ago-2026 hay **43 DISPONIBLES**, 1 apartada, 56 vendidas y 39 bloqueadas. +SENDA es un desarrollo de **casas** de Frasa Desarrollos. Los códigos de unidad tienen la forma `manzana-lote-casa` (ej. 411-1-7 = manzana 411, lote 1, casa 7). Inventario: **142 casas** (139 del cotizador + G102, G201 y L41 que solo aparecían en el reporte de estatus de venta); al 19-ago-2026 hay **43 DISPONIBLES**, 1 apartada, 56 vendidas y 39 bloqueadas. ## Modelos diff --git a/apps/alta-clientes/src/lib/pdf/checklistExpediente.ts b/apps/alta-clientes/src/lib/pdf/checklistExpediente.ts index 1b88abf..ccf7a4f 100644 --- a/apps/alta-clientes/src/lib/pdf/checklistExpediente.ts +++ b/apps/alta-clientes/src/lib/pdf/checklistExpediente.ts @@ -10,7 +10,7 @@ import { REQUISITOS_CONTACTO_ENTREGA, REQUISITOS_CONTACTO_CONTRATO, } from '../../data/textosLegales' -import { MARGEN, finalY, parrafo, titulo, lineaFirma, numero, type DocumentoPdf } from './helpers' +import { MARGEN, finalY, parrafo, titulo, lineaFirma, numero, asegurarEspacio, type DocumentoPdf } from './helpers' const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '—') @@ -97,9 +97,12 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { }) y = finalY(doc) + 6 + // Encabezado + bloque de contacto van juntos: si no caben, saltan a página nueva + y = asegurarEspacio(doc, y, 30) y = parrafo(doc, y, 'ENTREGA DE DOCUMENTOS:', { negritas: true, tamano: 9 }) y = parrafo(doc, y, REQUISITOS_CONTACTO_ENTREGA) y += 2 + y = asegurarEspacio(doc, y, 30) y = parrafo(doc, y, 'FIRMA DE CONTRATO:', { negritas: true, tamano: 9 }) y = parrafo(doc, y, REQUISITOS_CONTACTO_CONTRATO) y += 12 diff --git a/apps/alta-clientes/src/lib/pdf/helpers.ts b/apps/alta-clientes/src/lib/pdf/helpers.ts index 3ade335..da37a52 100644 --- a/apps/alta-clientes/src/lib/pdf/helpers.ts +++ b/apps/alta-clientes/src/lib/pdf/helpers.ts @@ -19,6 +19,15 @@ export interface OpcionesParrafo { alinear?: 'left' | 'center' | 'right' } +/** Si no caben `altoNecesario` mm antes del pie, salta de página. Devuelve la Y utilizable. */ +export function asegurarEspacio(doc: jsPDF, y: number, altoNecesario: number): number { + if (y + altoNecesario > LIMITE_PAGINA) { + doc.addPage() + return MARGEN + } + return y +} + /** 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 diff --git a/apps/alta-clientes/src/lib/pdf/identificacionCliente.ts b/apps/alta-clientes/src/lib/pdf/identificacionCliente.ts index 312a144..53cb4f7 100644 --- a/apps/alta-clientes/src/lib/pdf/identificacionCliente.ts +++ b/apps/alta-clientes/src/lib/pdf/identificacionCliente.ts @@ -39,7 +39,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['1 - Nombre y apellidos del cliente']], body: [[g.nombreCompleto]], @@ -48,7 +48,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['2 - Fecha de Nacimiento', '3 - Lugar de Nacimiento']], body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento)]], @@ -57,7 +57,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['4 - Nacionalizado o Residente', '4 - País']], body: [[vacio(g.nacionalidad), 'MEXICO']], @@ -66,7 +66,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['5 - Actividad, Ocupación o Profesión']], body: [[vacio(g.actividadOcupacion)]], @@ -76,7 +76,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['6 - Domicilio Particular', '']], body: [ @@ -90,7 +90,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['6 - Domicilio Fiscal (Art 29 y 29A CFF)', '']], body: [ @@ -105,7 +105,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, 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)]], @@ -114,7 +114,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['8 - Correo Electrónico Válido', '9 - CURP']], body: [[g.email, g.curp]], @@ -123,7 +123,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['10 - RFC (SAT)']], body: [[g.rfc]], @@ -132,7 +132,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['11 - Datos de la Identificación Oficial Vigente', '']], body: [ @@ -153,7 +153,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { 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 += 6 y = lineaFirma(doc, y, MARGEN + 40, 100, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl) // Página 2: beneficiario controlador @@ -166,7 +166,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['Nombre', 'Nacionalidad']], body: [ @@ -177,7 +177,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['Fecha Nacimiento', 'Lugar Nacimiento']], body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento)]], @@ -186,7 +186,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['Domicilio']], body: [[`${vacio(g.direccion)} ${vacio(g.codigoPostal)} ${vacio(g.delegacionMunicipio)} BC MEXICO`.trim()]], @@ -195,7 +195,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['Teléfono Fijo', 'Teléfono Celular']], body: [[vacio(g.telCasa), vacio(g.celular)]], @@ -204,7 +204,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['Correo Electrónico', 'Ocupación']], body: [[g.email, vacio(g.actividadOcupacion)]], @@ -213,7 +213,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { autoTable(doc, { startY: y, margin: { left: MARGEN, right: MARGEN }, - styles: { fontSize: 7, cellPadding: 1 }, + styles: { fontSize: 7, cellPadding: 0.7 }, headStyles: { fillColor: [4, 120, 87] }, head: [['C.U.R.P', 'R.F.C']], body: [[g.curp, g.rfc]], diff --git a/apps/alta-clientes/src/lib/pdf/pdf.test.ts b/apps/alta-clientes/src/lib/pdf/pdf.test.ts index ba182a7..8aedd52 100644 --- a/apps/alta-clientes/src/lib/pdf/pdf.test.ts +++ b/apps/alta-clientes/src/lib/pdf/pdf.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from 'vitest' +import { jsPDF } from 'jspdf' +import type { AltaCliente } from '../../types' import { fixtureAlta } from '../fixture' import * as perfilCliente from './perfilCliente' import * as identificacionCliente from './identificacionCliente' @@ -31,3 +33,297 @@ describe('generadores de PDF (smoke test con fixture)', () => { expect(checklistExpediente.crearDocumento(fixtureAlta).doc.getNumberOfPages()).toBeGreaterThanOrEqual(2) }) }) + +// --------------------------------------------------------------------------- +// Tests de contenido: jsPDF genera los streams sin comprimir, así que el texto +// se puede extraer del output crudo (cadenas "(...) Tj" en cada página). +// --------------------------------------------------------------------------- + +// Bytes 0x80-0x9F de WinAnsiEncoding que no coinciden con latin1 +const WINANSI: Record = { + 0x80: '€', 0x85: '…', 0x88: 'ˆ', 0x8c: 'Œ', 0x91: '‘', 0x92: '’', + 0x93: '“', 0x94: '”', 0x95: '•', 0x96: '–', 0x97: '—', 0x98: '˜', + 0x99: '™', 0x9c: 'œ', +} + +/** Decodifica una cadena literal del PDF: escapes octales y WinAnsi */ +function decodificarCadenaPdf(cadena: string): string { + return cadena + .replace(/\\([0-7]{1,3})/g, (_, oct: string) => String.fromCharCode(Number.parseInt(oct, 8))) + .replace(/\\([\\()nrt])/g, (_, c: string) => ({ n: '\n', r: '\r', t: '\t' })[c as string] ?? c) + .replace(/[\x80-\x9f]/g, (c) => WINANSI[c.charCodeAt(0)] ?? c) +} + +interface Captura { + /** Texto por página (en orden) */ + paginas: string[] + /** Todo el texto del documento */ + texto: string + /** Imágenes XObject embebidas (p. ej. la firma) */ + numImagenes: number +} + +/** Extrae el contenido textual del PDF generado, página por página. */ +function capturar(doc: jsPDF): Captura { + const bin = new TextDecoder('latin1').decode(doc.output('arraybuffer')) + const streams = [...bin.matchAll(/stream\r?\n([\s\S]*?)\r?\nendstream/g)] + .map((m) => m[1]) + .filter((s) => s.includes(' Tj')) + const paginas = streams.map((stream) => { + const partes: string[] = [] + for (const m of stream.matchAll(/\((?:\\.|[^\\()])*\)/g)) { + partes.push(decodificarCadenaPdf(m[0].slice(1, -1))) + } + return partes.join(' ') + }) + return { + paginas, + texto: paginas.join('\n'), + numImagenes: (bin.match(/\/Subtype\s*\/Image/g) ?? []).length, + } +} + +const capturarAlta = (crear: (alta: AltaCliente) => { doc: jsPDF }, alta: AltaCliente): Captura => + capturar(crear(alta).doc) + +const PATRON_DEFECTO = /\bundefined\b|\bnull\b|\bNaN\b|\[object Object\]/ + +const clonar = (a: AltaCliente): AltaCliente => JSON.parse(JSON.stringify(a)) as AltaCliente + +function conCoacreditado(): AltaCliente { + const a = clonar(fixtureAlta) + a.datosGenerales.nombreCompleto = 'MARÍA FERNANDA ÑÚÑEZ GÜEMES' + a.coacreditado.tieneCoacreditado = true + a.coacreditado.generales = { + ...a.coacreditado.generales, + nombreCompleto: 'JOSÉ ÁNGEL MÜLLER IBARGÜENGOITIA', + rfc: 'MUIJ850101ABC', + curp: 'MUIJ850101HBCLRN09', + fechaNacimiento: '1985-01-01', + } + a.coacreditado.economicos = { + ingresosMensuales: 40000.25, + otrosIngresos: 500.5, + gastosLuz: 300.33, + gastosOtros: 1200.67, + dependientes: { personas: '2', parentesco: 'HIJOS', edad: '5 Y 8' }, + } + a.referencias = [ + { + nombre: 'REFERENCIA CON NOMBRE EXTREMADAMENTE LARGO PARA PROBAR ANCHO DE CELDA', + telefono: '6641887799', + parentesco: 'HERMANO', + direccion: 'PRIVADA DE LAS BUGAMBILIAS MANZANA 27 LOTE 114 COLONIA PLAYAS DE TIJUANA CÓDIGO POSTAL 22504', + }, + ] + return a +} + +function minimos(): AltaCliente { + const a = clonar(fixtureAlta) + a.datosGenerales.telCasa = '' + a.empleo.ext = '' + a.viviendaCredito.enganche = '' + a.viviendaCredito.idVigencia = '' + a.viviendaCredito.proyecto = 'OTRO' + a.viviendaCredito.proyectoOtro = 'RESIDENCIAL LAS ÁGUILAS' + a.viviendaCredito.tiposCredito = [] + a.referencias = [] + a.economicos = { + ingresosMensuales: 0, + otrosIngresos: 0, + gastosLuz: 0, + gastosOtros: 0, + dependientes: { personas: '', parentesco: '', edad: '' }, + } + a.firma = { firmaDataUrl: '', asesorNombre: '' } + return a +} + +describe('contenido de los PDFs', () => { + it('ningún generador emite undefined/null/NaN/[object Object] (fixture ni casos borde)', () => { + for (const alta of [fixtureAlta, conCoacreditado(), minimos()]) { + for (const [, modulo] of generadores) { + expect(capturarAlta(modulo.crearDocumento, alta).texto).not.toMatch(PATRON_DEFECTO) + } + } + }) + + it('perfil: datos personales, empleo, económicos, referencias y desarrollo con formato moneda', () => { + const t = capturarAlta(perfilCliente.crearDocumento, fixtureAlta).texto + for (const frag of [ + 'PASCUAL MARTIN TELLEZ CHIGUINDO', + 'TECP891124HBCLHS07', + 'TECP8911246B4', + '24/11/1989', + 'RIO EUFRATES EDIF 1 7145 203', + 'FISCALIA GENERAL DE LA REPUBLICA', + '12 AÑOS 10 MESES', + '$54,988.00', + '$5,500.00', + '$2,700,000.00', + 'JOSE ARNOLDO MENDOZA', + 'CYNTHIA ANAHI GARCIA', + 'CAÑON OASIS', + '409-1-11-11', + 'LAUREL', + 'Bancario', + 'ESMERALDA BECERRA DOMINGUEZ', + ]) { + expect(t).toContain(frag) + } + }) + + it('perfil: catálogos con etiqueta legible y modelo sin duplicar en PROYECTO', () => { + const t = capturarAlta(perfilCliente.crearDocumento, fixtureAlta).texto + for (const crudo of ['VIVIR_AHI', 'E_MEXICANA', 'ASALARIADO', 'SOCIEDAD_CONYUGAL']) { + expect(t).not.toContain(crudo) + } + for (const etiqueta of ['Vivir ahí', 'Economía mexicana', 'Asalariado', 'Propia']) { + expect(t).toContain(etiqueta) + } + expect(t).toContain('11 11 SENDA') + expect(t).not.toContain('11 11 SENDA LAUREL') + }) + + it('perfil: sección co-acreditado solo cuando tieneCoacreditado', () => { + const sin = capturarAlta(perfilCliente.crearDocumento, fixtureAlta).texto + expect(sin).not.toContain('CO-ACREDITADO') + + const con = capturarAlta(perfilCliente.crearDocumento, conCoacreditado()).texto + for (const frag of [ + 'CO-ACREDITADO O CÓNYUGE', + 'JOSÉ ÁNGEL MÜLLER IBARGÜENGOITIA', + 'MUIJ850101HBCLRN09', + 'MUIJ850101ABC', + '$40,500.75', + '$1,501.00', + 'MARÍA FERNANDA ÑÚÑEZ GÜEMES', + ]) { + expect(con).toContain(frag) + } + }) + + it('perfil: textos largos de referencias no se truncan', () => { + const t = capturarAlta(perfilCliente.crearDocumento, conCoacreditado()).texto + for (const palabra of ['EXTREMADAMENTE', 'ANCHO DE CELDA', 'BUGAMBILIAS', 'MANZANA 27', 'LOTE 114', '22504']) { + expect(t).toContain(palabra) + } + }) + + it('identificación PLD: campos, textos legales completos y firma no huérfana', () => { + const { doc } = identificacionCliente.crearDocumento(fixtureAlta) + const c = capturar(doc) + for (const frag of [ + 'FORMATO DE IDENTIFICACION DE CLIENTES', + 'PASCUAL MARTIN TELLEZ CHIGUINDO', + 'TECP891124HBCLHS07', + 'TECP8911246B4', + 'AGENTE DEL MINISTERIO PUBLICO FEDERAL', + 'FRAY PEDRO DE GANTE', + 'TIJUANA OTAY', + 'BAJA CALIFORNIA 22508 MEXICO', + 'INSTITUTO NACIONAL ELECTORAL', + 'IDMEX2311049617', + '31/12/2032', + 'Artículos 8, 15, 16, 17', + 'Artículo 62 de la Ley Federal', + 'FORMATO PARA LA IDENTIFICACIÓN DEL', + 'BENEFICIARIO CONTROLADOR', + 'soy el único Beneficiario Controlador', + 'en la ciudad de Tijuana, B.C.', + ]) { + expect(c.texto).toContain(frag) + } + // La firma del formato PLD no debe quedar sola en una página + expect(doc.getNumberOfPages()).toBe(2) + expect(c.paginas[0]).toContain('Llenar otro formato') + expect(c.paginas[0]).toContain('NOMBRE Y FIRMA DEL CLIENTE') + // Firma embebida en ambas páginas + expect(c.numImagenes).toBeGreaterThanOrEqual(2) + }) + + it('aviso de privacidad: texto íntegro hasta el último párrafo y firma embebida', () => { + const c = capturarAlta(avisoPrivacidad.crearDocumento, fixtureAlta) + for (const frag of [ + 'AVISO DE PRIVACIDAD', + 'Ley Federal de Protección de Datos Personales en Posesión de los Particulares', + 'Información Confidencial', + 'cancelación u oposición (ARCO)', + 'www.frasa.com.mx/aviso-de-privacidad/', + 'revisar periódicamente el contenido del mismo.', + 'El suscrito (a) PASCUAL MARTIN TELLEZ CHIGUINDO', + 'AUTORIZO EL USO DE MIS DATOS', + ]) { + expect(c.texto).toContain(frag) + } + expect(c.numImagenes).toBeGreaterThanOrEqual(1) + }) + + it('carta antilavado: cuerpo completo, límite de efectivo y fundamento legal', () => { + const c = capturarAlta(cartaAntilavado.crearDocumento, fixtureAlta) + for (const frag of [ + 'Estimado (a) PASCUAL MARTIN TELLEZ CHIGUINDO', + 'proyecto SENDA', + '$600,000.00', + 'SEISCIENTOS MIL PESOS', + 'Artículo 32, fracción I', + 'A T E N T A M E N T E', + 'Frasa Desarrollos SAPI C.V.', + 'DE CONFORMIDAD', + 'En mi carácter de “COMPRADOR”', + ]) { + expect(c.texto).toContain(frag) + } + expect(c.numImagenes).toBeGreaterThanOrEqual(1) + }) + + it('checklist: listas, firmas de recepción y contactos sin encabezados huérfanos', () => { + const c = capturarAlta(checklistExpediente.crearDocumento, fixtureAlta) + for (const frag of [ + 'FORMATO DE RECEPCIÓN Y', + 'CONTROL DE EXPEDIENTE', + 'PASCUAL MARTIN TELLEZ CHIGUINDO', + '$2,700,000.00', + 'Formatos Generales***', + 'Buró de crédito', + 'GLADIS VALENZUELA', + 'DEYSI LORENA ALEMAN FIGUEROA', + 'DANIEL ALVARADO LUQUE', + 'REQUISITOS', + 'GENERALES', + 'Solicitud de crédito hipotecario', + '3 Referencias en U.S.A.', + 'Graciela Bautista Velasco', + 'ALEJANDRA GUERRERO SANCHEZ', + 'gbautista@frasa.com.mx', + 'máximo de 48h', + ]) { + expect(c.texto).toContain(frag) + } + // El encabezado de cada bloque de contacto está en la misma página que su contenido + const paginaDe = (pagina: string, frag: string) => pagina.includes(frag) + for (const pagina of c.paginas) { + if (paginaDe(pagina, 'ENTREGA DE DOCUMENTOS:')) { + expect(pagina).toContain('Graciela Bautista Velasco') + } + if (paginaDe(pagina, 'FIRMA DE CONTRATO:')) { + expect(pagina).toContain('ALEJANDRA GUERRERO SANCHEZ') + } + } + }) + + it('casos borde: proyecto OTRO, sin referencias, montos en cero y sin firma', () => { + const alta = minimos() + for (const [, modulo] of generadores) { + expect(() => modulo.crearDocumento(alta)).not.toThrow() + } + const perfil = capturarAlta(perfilCliente.crearDocumento, alta) + expect(perfil.texto).toContain('RESIDENCIAL LAS ÁGUILAS') + expect(perfil.texto).toContain('$0.00') + // Sin firma no se incrusta ninguna imagen + expect(perfil.numImagenes).toBe(0) + const carta = capturarAlta(cartaAntilavado.crearDocumento, alta) + expect(carta.texto).toContain('proyecto RESIDENCIAL LAS ÁGUILAS') + }) +}) diff --git a/apps/alta-clientes/src/lib/pdf/perfilCliente.ts b/apps/alta-clientes/src/lib/pdf/perfilCliente.ts index 0c638ff..ce12a39 100644 --- a/apps/alta-clientes/src/lib/pdf/perfilCliente.ts +++ b/apps/alta-clientes/src/lib/pdf/perfilCliente.ts @@ -3,11 +3,15 @@ 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 { TIPOS_CREDITO, TIPOS_EMPLEO, TIPOS_ECONOMIA, TIPOS_VIVIENDA, TIPOS_COMPRA, REGIMENES_BIENES } from '../../data/constantes' import { MARGEN, finalY, titulo, lineaFirma, numero, type DocumentoPdf } from './helpers' const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '—') +/** Etiqueta legible de un catálogo; si el valor no está en el catálogo se muestra tal cual */ +const etiqueta = (catalogo: readonly { valor: string; etiqueta: string }[], valor: string): string => + catalogo.find((o) => o.valor === valor)?.etiqueta ?? vacio(valor) + function seccion(doc: jsPDF, y: number, texto: string): number { if (y > 260) { doc.addPage() @@ -62,7 +66,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { 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)]], + body: [[vacio(g.estadoCivil), etiqueta(REGIMENES_BIENES, g.regimenBienes), vacio(g.peso), vacio(g.estatura)]], }) y = finalY(doc) autoTable(doc, { @@ -81,7 +85,7 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { 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)], + [vacio(g.delegacionMunicipio), vacio(g.codigoPostal), vacio(g.antiguedadDomicilio), etiqueta(TIPOS_VIVIENDA, g.tipoVivienda), etiqueta(TIPOS_COMPRA, g.tipoCompra)], ], }) y = finalY(doc) + 6 @@ -96,8 +100,8 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { head: [['TIPO DE EMPLEO', 'TIPO DE ECONOMÍA', 'COMPAÑÍA', 'ANTIGÜEDAD']], body: [ [ - vacio(e.tipoEmpleo), - vacio(e.tipoEconomia), + etiqueta(TIPOS_EMPLEO, e.tipoEmpleo), + etiqueta(TIPOS_ECONOMIA, e.tipoEconomia), vacio(e.compania), `${vacio(e.antiguedadAnios)} AÑOS ${vacio(e.antiguedadMeses)} MESES`, ], @@ -207,7 +211,8 @@ export function crearDocumento(alta: AltaCliente): DocumentoPdf { head: [['PROYECTO', 'UBICACIÓN', 'PROTOTIPO']], body: [ [ - [`${vacio(v.manzana)} ${vacio(v.lote)}`.replace(/—/g, '').trim(), proyecto, vacio(v.modelo)].join(' ').trim(), + // Manzana/Lote + nombre del proyecto (el modelo va en su propia columna PROTOTIPO) + [`${vacio(v.manzana)} ${vacio(v.lote)}`.replace(/—/g, '').trim(), proyecto].filter(Boolean).join(' '), vacio(v.ubicacion), vacio(v.modelo), ], diff --git a/apps/cotizador/src/data/unidades-senda.ts b/apps/cotizador/src/data/unidades-senda.ts index 69449ef..d4aeb93 100644 --- a/apps/cotizador/src/data/unidades-senda.ts +++ b/apps/cotizador/src/data/unidades-senda.ts @@ -110,6 +110,9 @@ export const UNIDADES_SENDA: Unidad[] = [ { codigo: 'SENDA-410-1-F202', modelo: 'ESMERALDA PA', desarrollo: 'SENDA', fechaEntrega: null, supEstacionamiento: null, supConstruccion: 55.78, supExcedente: null, precioSinAcabados: 1850000, precioConAcabados: 1850000, precioLista: 1850000, status: 'DISPONIBLE', mesesParaFirma: null, extras: { prototipo: 'CUADRUPLEX', manzana: 410, lote: 1, cajones: 1 } }, { codigo: 'SENDA-410-1-G101', modelo: 'ESMERALDA PB', desarrollo: 'SENDA', fechaEntrega: null, supEstacionamiento: null, supConstruccion: 55.78, supExcedente: null, precioSinAcabados: 1948000, precioConAcabados: 1948000, precioLista: 1948000, status: 'DISPONIBLE', mesesParaFirma: null, extras: { prototipo: 'CUADRUPLEX', manzana: 410, lote: 1, cajones: 1 } }, { codigo: 'SENDA-410-1-G202', modelo: 'ESMERALDA PA', desarrollo: 'SENDA', fechaEntrega: null, supEstacionamiento: null, supConstruccion: 55.78, supExcedente: null, precioSinAcabados: 1850000, precioConAcabados: 1850000, precioLista: 1850000, status: 'VENDIDO', mesesParaFirma: null, extras: { prototipo: 'CUADRUPLEX', manzana: 410, lote: 1, cajones: 1 } }, + { codigo: 'SENDA-410-1-G102', modelo: 'ESMERALDA PB', desarrollo: 'SENDA', fechaEntrega: null, supEstacionamiento: null, supConstruccion: 55.78, supExcedente: null, precioSinAcabados: 1948000, precioConAcabados: 1948000, precioLista: 1948000, status: 'VENDIDO', mesesParaFirma: null, extras: { prototipo: 'CUADRUPLEX', manzana: 410, lote: 1, cajones: 1 } }, + { codigo: 'SENDA-410-1-G201', modelo: 'ESMERALDA PA', desarrollo: 'SENDA', fechaEntrega: null, supEstacionamiento: null, supConstruccion: 55.78, supExcedente: null, precioSinAcabados: 1850000, precioConAcabados: 1850000, precioLista: 1850000, status: 'VENDIDO', mesesParaFirma: null, extras: { prototipo: 'CUADRUPLEX', manzana: 410, lote: 1, cajones: 1 } }, + { codigo: 'SENDA-401-1-L41', modelo: 'SANTIAGO', desarrollo: 'SENDA', fechaEntrega: null, supEstacionamiento: null, supConstruccion: 63.95, supExcedente: null, precioSinAcabados: 2750000, precioConAcabados: 2750000, precioLista: 2750000, status: 'VENDIDO', mesesParaFirma: null, extras: { prototipo: 'LAUREL', manzana: 401, lote: 1, cajones: 2 } }, { codigo: 'SENDA-409-5-1', modelo: 'SANTIAGO', desarrollo: 'SENDA', fechaEntrega: '2027-11-20', supEstacionamiento: null, supConstruccion: 63.95, supExcedente: null, precioSinAcabados: 2700000, precioConAcabados: 2700000, precioLista: 2700000, status: 'VENDIDO', mesesParaFirma: null, extras: { prototipo: 'LAUREL', manzana: 409, lote: 5, cajones: 2 } }, { codigo: 'SENDA-409-4-1', modelo: 'SANTIAGO', desarrollo: 'SENDA', fechaEntrega: '2027-11-27', supEstacionamiento: null, supConstruccion: 63.95, supExcedente: null, precioSinAcabados: 2750000, precioConAcabados: 2750000, precioLista: 2750000, status: 'VENDIDO', mesesParaFirma: null, extras: { prototipo: 'LAUREL', manzana: 409, lote: 4, cajones: 2 } }, { codigo: 'SENDA-409-1-1', modelo: 'SANTIAGO', desarrollo: 'SENDA', fechaEntrega: '2027-08-28', supEstacionamiento: null, supConstruccion: 63.95, supExcedente: null, precioSinAcabados: 2750000, precioConAcabados: 2750000, precioLista: 2750000, status: 'VENDIDO', mesesParaFirma: null, extras: { prototipo: 'LAUREL', manzana: 409, lote: 1, cajones: 2 } },