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:
23
apps/alta-clientes/src/lib/calculations.test.ts
Normal file
23
apps/alta-clientes/src/lib/calculations.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calcularTotalIngresos, calcularGastosTotal } from './calculations'
|
||||
|
||||
describe('calcularTotalIngresos', () => {
|
||||
it('suma ingresos mensuales y otros ingresos', () => {
|
||||
expect(calcularTotalIngresos({ ingresosMensuales: 54988, otrosIngresos: 0 })).toBe(54988)
|
||||
expect(calcularTotalIngresos({ ingresosMensuales: 20000, otrosIngresos: 5000 })).toBe(25000)
|
||||
})
|
||||
|
||||
it('devuelve 0 cuando ambos son 0', () => {
|
||||
expect(calcularTotalIngresos({ ingresosMensuales: 0, otrosIngresos: 0 })).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularGastosTotal', () => {
|
||||
it('suma gastos de luz y otros gastos', () => {
|
||||
expect(calcularGastosTotal({ gastosLuz: 500, gastosOtros: 5000 })).toBe(5500)
|
||||
})
|
||||
|
||||
it('devuelve 0 cuando ambos son 0', () => {
|
||||
expect(calcularGastosTotal({ gastosLuz: 0, gastosOtros: 0 })).toBe(0)
|
||||
})
|
||||
})
|
||||
11
apps/alta-clientes/src/lib/calculations.ts
Normal file
11
apps/alta-clientes/src/lib/calculations.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { DatosEconomicos } from '../types'
|
||||
|
||||
/** Total de ingresos: ingresos mensuales + otros ingresos */
|
||||
export function calcularTotalIngresos(economicos: Pick<DatosEconomicos, 'ingresosMensuales' | 'otrosIngresos'>): number {
|
||||
return economicos.ingresosMensuales + economicos.otrosIngresos
|
||||
}
|
||||
|
||||
/** Total de gastos aproximados: luz + otros */
|
||||
export function calcularGastosTotal(economicos: Pick<DatosEconomicos, 'gastosLuz' | 'gastosOtros'>): number {
|
||||
return economicos.gastosLuz + economicos.gastosOtros
|
||||
}
|
||||
109
apps/alta-clientes/src/lib/curp.test.ts
Normal file
109
apps/alta-clientes/src/lib/curp.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
validarCurp,
|
||||
extraerDeCurp,
|
||||
compararConDatos,
|
||||
digitoVerificadorCurp,
|
||||
ENTIDADES_CURP,
|
||||
} from './curp'
|
||||
|
||||
// Fixture real del formato PDF de alta (reference/alta-clientes/pdf_formatos.txt)
|
||||
const CURP_FIXTURE = 'TECP891124HBCLHS07'
|
||||
|
||||
/** Construye una CURP de prueba con dígito verificador calculado por el propio algoritmo */
|
||||
function construirCurp(base17: string): string {
|
||||
return `${base17}${digitoVerificadorCurp(base17)}`
|
||||
}
|
||||
|
||||
describe('validarCurp', () => {
|
||||
it('acepta el fixture real TECP891124HBCLHS07 (formato y dígito)', () => {
|
||||
expect(validarCurp(CURP_FIXTURE)).toEqual({ valida: true })
|
||||
})
|
||||
|
||||
it('acepta CURPs construidas con el propio algoritmo', () => {
|
||||
const bases = [
|
||||
'TECP891124HBCLHS0',
|
||||
'GOML900515HDFRRN0',
|
||||
'LOQA050312MCLPMA0',
|
||||
'HEMA000101HDFXXX0',
|
||||
'XAXX010101MNEXXXA', // genérica sin homoclave real, solo para dígito
|
||||
]
|
||||
for (const base of bases) {
|
||||
expect(validarCurp(construirCurp(base)).valida).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rechaza un dígito verificador alterado', () => {
|
||||
const buena = construirCurp('TECP891124HBCLHS0')
|
||||
const malDigito = `${buena.slice(0, 17)}${(Number(buena[17]) + 1) % 10}`
|
||||
expect(validarCurp(malDigito)).toEqual({ valida: false, motivo: 'digito' })
|
||||
})
|
||||
|
||||
it('rechaza formatos inválidos', () => {
|
||||
expect(validarCurp('TECP891124')).toEqual({ valida: false, motivo: 'formato' })
|
||||
expect(validarCurp('TECP891124XBCLHS07')).toEqual({ valida: false, motivo: 'formato' })
|
||||
expect(validarCurp('')).toEqual({ valida: false, motivo: 'formato' })
|
||||
})
|
||||
|
||||
it('acepta minúsculas y espacios alrededor', () => {
|
||||
expect(validarCurp(' tecp891124hbclhs07 ').valida).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extraerDeCurp', () => {
|
||||
it('parsea fecha, sexo y entidad del fixture real', () => {
|
||||
const datos = extraerDeCurp(CURP_FIXTURE)
|
||||
expect(datos).not.toBeNull()
|
||||
expect(datos?.fechaNacimiento).toBe('1989-11-24')
|
||||
expect(datos?.sexo).toBe('H')
|
||||
expect(datos?.entidad).toBe('BC')
|
||||
expect(datos?.entidadNombre).toBe('BAJA CALIFORNIA')
|
||||
})
|
||||
|
||||
it('aplica la regla de siglo (YY <= año actual => 2000s)', () => {
|
||||
const datos = extraerDeCurp(construirCurp('GOML050515HDFRRN0'))
|
||||
expect(datos?.fechaNacimiento).toBe('2005-05-15')
|
||||
})
|
||||
|
||||
it('devuelve null con formato inválido', () => {
|
||||
expect(extraerDeCurp('NO-ES-CURP')).toBeNull()
|
||||
})
|
||||
|
||||
it('el mapa tiene las 33 entidades', () => {
|
||||
expect(Object.keys(ENTIDADES_CURP)).toHaveLength(33)
|
||||
expect(ENTIDADES_CURP.NE).toBe('NACIDO EN EL EXTRANJERO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('compararConDatos', () => {
|
||||
it('sin advertencias cuando todo coincide', () => {
|
||||
expect(
|
||||
compararConDatos(CURP_FIXTURE, {
|
||||
fechaNacimiento: '1989-11-24',
|
||||
lugarNacimiento: 'Tijuana, Baja California',
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('advierte cuando la fecha no coincide', () => {
|
||||
const avisos = compararConDatos(CURP_FIXTURE, { fechaNacimiento: '1990-01-01' })
|
||||
expect(avisos).toHaveLength(1)
|
||||
expect(avisos[0]).toContain('fecha de nacimiento')
|
||||
})
|
||||
|
||||
it('advierte cuando el lugar no coincide con la entidad', () => {
|
||||
const avisos = compararConDatos(CURP_FIXTURE, { lugarNacimiento: 'Guadalajara, Jalisco' })
|
||||
expect(avisos).toHaveLength(1)
|
||||
expect(avisos[0]).toContain('lugar de nacimiento')
|
||||
expect(avisos[0]).toContain('BAJA CALIFORNIA')
|
||||
})
|
||||
|
||||
it('advierte dígito verificador incorrecto', () => {
|
||||
const avisos = compararConDatos('TECP891124HBCLHS09', {})
|
||||
expect(avisos.some((a) => a.includes('dígito verificador'))).toBe(true)
|
||||
})
|
||||
|
||||
it('no advierte nada con CURP vacía', () => {
|
||||
expect(compararConDatos('', { fechaNacimiento: '1989-11-24' })).toEqual([])
|
||||
})
|
||||
})
|
||||
148
apps/alta-clientes/src/lib/curp.ts
Normal file
148
apps/alta-clientes/src/lib/curp.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// Validación y extracción de datos de la CURP (Clave Única de Registro de Población)
|
||||
// Algoritmo oficial del dígito verificador: alfabeto RENAPO, pesos 18..2,
|
||||
// suma mod 10 y complemento a 10.
|
||||
|
||||
export const REGEX_CURP_FORMATO = /^[A-Z]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d$/
|
||||
|
||||
const ALFABETO = '0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ'
|
||||
|
||||
export const ENTIDADES_CURP: Record<string, string> = {
|
||||
AS: 'AGUASCALIENTES',
|
||||
BC: 'BAJA CALIFORNIA',
|
||||
BS: 'BAJA CALIFORNIA SUR',
|
||||
CC: 'CAMPECHE',
|
||||
CL: 'COAHUILA',
|
||||
CM: 'COLIMA',
|
||||
CS: 'CHIAPAS',
|
||||
CH: 'CHIHUAHUA',
|
||||
DF: 'CIUDAD DE MEXICO',
|
||||
DG: 'DURANGO',
|
||||
GT: 'GUANAJUATO',
|
||||
GR: 'GUERRERO',
|
||||
HG: 'HIDALGO',
|
||||
JC: 'JALISCO',
|
||||
MC: 'MEXICO',
|
||||
MN: 'MICHOACAN',
|
||||
MS: 'MORELOS',
|
||||
NT: 'NAYARIT',
|
||||
NL: 'NUEVO LEON',
|
||||
OC: 'OAXACA',
|
||||
PL: 'PUEBLA',
|
||||
QT: 'QUERETARO',
|
||||
QR: 'QUINTANA ROO',
|
||||
SP: 'SAN LUIS POTOSI',
|
||||
SL: 'SINALOA',
|
||||
SR: 'SONORA',
|
||||
TC: 'TABASCO',
|
||||
TS: 'TAMAULIPAS',
|
||||
TL: 'TLAXCALA',
|
||||
VZ: 'VERACRUZ',
|
||||
YN: 'YUCATAN',
|
||||
ZS: 'ZACATECAS',
|
||||
NE: 'NACIDO EN EL EXTRANJERO',
|
||||
}
|
||||
|
||||
/** Quita acentos y pasa a mayúsculas para comparar textos */
|
||||
function normalizar(texto: string): string {
|
||||
return texto
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.toUpperCase()
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Calcula el dígito verificador de los primeros 17 caracteres de la CURP */
|
||||
export function digitoVerificadorCurp(curp17: string): number {
|
||||
let suma = 0
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const valor = ALFABETO.indexOf(curp17[i])
|
||||
if (valor === -1) return -1
|
||||
suma += valor * (18 - i)
|
||||
}
|
||||
return (10 - (suma % 10)) % 10
|
||||
}
|
||||
|
||||
export interface ResultadoValidacionCurp {
|
||||
valida: boolean
|
||||
/** 'formato' si no cumple el patrón, 'digito' si el verificador no cuadra */
|
||||
motivo?: 'formato' | 'digito'
|
||||
}
|
||||
|
||||
/** Valida formato (regex) y dígito verificador de la CURP */
|
||||
export function validarCurp(curp: string): ResultadoValidacionCurp {
|
||||
const limpia = normalizar(curp)
|
||||
if (!REGEX_CURP_FORMATO.test(limpia)) return { valida: false, motivo: 'formato' }
|
||||
const esperado = digitoVerificadorCurp(limpia.slice(0, 17))
|
||||
if (esperado !== Number(limpia[17])) return { valida: false, motivo: 'digito' }
|
||||
return { valida: true }
|
||||
}
|
||||
|
||||
export interface DatosCurp {
|
||||
/** Fecha de nacimiento en formato YYYY-MM-DD */
|
||||
fechaNacimiento: string
|
||||
sexo: 'H' | 'M'
|
||||
/** Código de entidad federativa (posiciones 12-13) */
|
||||
entidad: string
|
||||
/** Nombre de la entidad federativa */
|
||||
entidadNombre: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae fecha de nacimiento, sexo y entidad de una CURP con formato válido.
|
||||
* Regla de siglo: YY <= año actual % 100 => 20YY, si no 19YY.
|
||||
* Devuelve null si el formato no es válido.
|
||||
*/
|
||||
export function extraerDeCurp(curp: string): DatosCurp | null {
|
||||
const limpia = normalizar(curp)
|
||||
if (!REGEX_CURP_FORMATO.test(limpia)) return null
|
||||
const yy = Number(limpia.slice(4, 6))
|
||||
const mm = limpia.slice(6, 8)
|
||||
const dd = limpia.slice(8, 10)
|
||||
const anioActual = new Date().getFullYear() % 100
|
||||
const anio = yy <= anioActual ? 2000 + yy : 1900 + yy
|
||||
const entidad = limpia.slice(11, 13)
|
||||
return {
|
||||
fechaNacimiento: `${anio}-${mm}-${dd}`,
|
||||
sexo: limpia[10] as 'H' | 'M',
|
||||
entidad,
|
||||
entidadNombre: ENTIDADES_CURP[entidad] ?? entidad,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compara la CURP con los datos capturados y devuelve advertencias
|
||||
* (no bloquean; solo alertan inconsistencias).
|
||||
*/
|
||||
export function compararConDatos(
|
||||
curp: string,
|
||||
datos: { fechaNacimiento?: string; lugarNacimiento?: string },
|
||||
): string[] {
|
||||
const advertencias: string[] = []
|
||||
const limpia = normalizar(curp)
|
||||
if (limpia === '') return advertencias
|
||||
|
||||
const validacion = validarCurp(limpia)
|
||||
if (!validacion.valida && validacion.motivo === 'digito') {
|
||||
advertencias.push('El dígito verificador de la CURP no coincide; verifica la captura')
|
||||
}
|
||||
if (!validacion.valida) return advertencias
|
||||
|
||||
const extraidos = extraerDeCurp(limpia)
|
||||
if (!extraidos) return advertencias
|
||||
|
||||
const fechaCapturada = (datos.fechaNacimiento ?? '').trim()
|
||||
if (fechaCapturada !== '' && fechaCapturada !== extraidos.fechaNacimiento) {
|
||||
advertencias.push(
|
||||
`La fecha de nacimiento no coincide con la CURP (${extraidos.fechaNacimiento} según CURP)`,
|
||||
)
|
||||
}
|
||||
|
||||
const lugar = normalizar(datos.lugarNacimiento ?? '')
|
||||
if (lugar !== '' && !lugar.includes(extraidos.entidadNombre)) {
|
||||
advertencias.push(
|
||||
`El lugar de nacimiento no coincide con la entidad de la CURP (${extraidos.entidadNombre})`,
|
||||
)
|
||||
}
|
||||
|
||||
return advertencias
|
||||
}
|
||||
48
apps/alta-clientes/src/lib/expediente.test.ts
Normal file
48
apps/alta-clientes/src/lib/expediente.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { estadoExpediente } from './expediente'
|
||||
import type { DocumentoCargado, SlotDocumento } from '../types'
|
||||
|
||||
const doc = (slot: SlotDocumento): DocumentoCargado => ({
|
||||
slot,
|
||||
nombre: `${slot}.pdf`,
|
||||
tamano: 1024,
|
||||
tipoMime: 'application/pdf',
|
||||
archivo: new File(['x'], `${slot}.pdf`, { type: 'application/pdf' }),
|
||||
})
|
||||
|
||||
const FIRMA = 'data:image/png;base64,iVBORw0KGgo='
|
||||
|
||||
describe('estadoExpediente', () => {
|
||||
it('expediente vacío: 0/6 y todo pendiente', () => {
|
||||
const e = estadoExpediente([], '')
|
||||
expect(e.total).toBe(6)
|
||||
expect(e.completos).toBe(0)
|
||||
expect(e.completoTodo).toBe(false)
|
||||
expect(e.items.every((i) => !i.completo)).toBe(true)
|
||||
})
|
||||
|
||||
it('cuenta documentos cargados y firma por separado', () => {
|
||||
const e = estadoExpediente([doc('INE'), doc('CURP')], FIRMA)
|
||||
expect(e.completos).toBe(3)
|
||||
expect(e.items.find((i) => i.clave === 'INE')?.completo).toBe(true)
|
||||
expect(e.items.find((i) => i.clave === 'CONSTANCIA_RFC')?.completo).toBe(false)
|
||||
expect(e.items.find((i) => i.clave === 'FIRMA')?.completo).toBe(true)
|
||||
})
|
||||
|
||||
it('expediente completo: 6/6', () => {
|
||||
const docs = [
|
||||
doc('INE'),
|
||||
doc('CONSTANCIA_RFC'),
|
||||
doc('CURP'),
|
||||
doc('COMPROBANTE_DOMICILIO'),
|
||||
doc('ACTA_NACIMIENTO'),
|
||||
]
|
||||
const e = estadoExpediente(docs, FIRMA)
|
||||
expect(e.completos).toBe(6)
|
||||
expect(e.completoTodo).toBe(true)
|
||||
})
|
||||
|
||||
it('una cadena que no es data:image no cuenta como firma', () => {
|
||||
expect(estadoExpediente([], 'garabato').items.find((i) => i.clave === 'FIRMA')?.completo).toBe(false)
|
||||
})
|
||||
})
|
||||
34
apps/alta-clientes/src/lib/expediente.ts
Normal file
34
apps/alta-clientes/src/lib/expediente.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { DocumentoCargado } from '../types'
|
||||
import { SLOTS_DOCUMENTOS } from '../data/constantes'
|
||||
|
||||
export interface ItemExpediente {
|
||||
clave: string
|
||||
etiqueta: string
|
||||
completo: boolean
|
||||
}
|
||||
|
||||
export interface EstadoExpediente {
|
||||
items: ItemExpediente[]
|
||||
completos: number
|
||||
total: number
|
||||
completoTodo: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado del expediente en vivo: los 5 documentos del formato + la firma.
|
||||
* Función pura para poder probarla y reutilizarla en los pasos 7 y 8.
|
||||
*/
|
||||
export function estadoExpediente(documentos: DocumentoCargado[], firmaDataUrl: string): EstadoExpediente {
|
||||
const items: ItemExpediente[] = SLOTS_DOCUMENTOS.map(({ slot, etiqueta }) => ({
|
||||
clave: slot,
|
||||
etiqueta,
|
||||
completo: documentos.some((d) => d.slot === slot),
|
||||
}))
|
||||
items.push({
|
||||
clave: 'FIRMA',
|
||||
etiqueta: 'Firma del cliente',
|
||||
completo: firmaDataUrl.startsWith('data:image/'),
|
||||
})
|
||||
const completos = items.filter((i) => i.completo).length
|
||||
return { items, completos, total: items.length, completoTodo: completos === items.length }
|
||||
}
|
||||
156
apps/alta-clientes/src/lib/fixture.ts
Normal file
156
apps/alta-clientes/src/lib/fixture.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { AltaCliente } from '../types'
|
||||
|
||||
// PNG de 1x1 px para ejercitar la incrustación de firma en los tests
|
||||
export const FIRMA_PRUEBA =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
|
||||
|
||||
/**
|
||||
* Fixture con los datos del cliente del PDF de referencia
|
||||
* (PASCUAL MARTIN TELLEZ CHIGUINDO — pdf_formatos.txt)
|
||||
*/
|
||||
export const fixtureAlta: AltaCliente = {
|
||||
datosGenerales: {
|
||||
campaniaVenta: '',
|
||||
fecha: '2026-07-19',
|
||||
nombreCompleto: 'PASCUAL MARTIN TELLEZ CHIGUINDO',
|
||||
nacionalidad: 'MEXICANA',
|
||||
nivelEstudios: 'MAESTRIA',
|
||||
curp: 'TECP891124HBCLHS07',
|
||||
rfc: 'TECP8911246B4',
|
||||
nss: '',
|
||||
delegacionMunicipio: 'TIJUANA',
|
||||
direccion: 'RIO EUFRATES EDIF 1 7145 203',
|
||||
codigoPostal: '22223',
|
||||
antiguedadDomicilio: '2',
|
||||
fechaNacimiento: '1989-11-24',
|
||||
lugarNacimiento: 'TIJUANA',
|
||||
estadoCivil: 'SOLTERO',
|
||||
regimenBienes: '',
|
||||
peso: '87',
|
||||
estatura: '1.78',
|
||||
email: 'dantemartinchi@gmail.com',
|
||||
telCasa: '6646935924',
|
||||
celular: '6646935924',
|
||||
viviendaAnios: '2',
|
||||
tipoVivienda: 'PROPIA',
|
||||
tipoCompra: 'VIVIR_AHI',
|
||||
actividadOcupacion: 'AGENTE DEL MINISTERIO PUBLICO FEDERAL',
|
||||
},
|
||||
empleo: {
|
||||
tipoEmpleo: 'ASALARIADO',
|
||||
tipoEconomia: 'E_MEXICANA',
|
||||
compania: 'FISCALIA GENERAL DE LA REPUBLICA',
|
||||
antiguedadAnios: '12',
|
||||
antiguedadMeses: '10',
|
||||
direccion: 'ZONA RIO TIJUANA',
|
||||
colonia: 'ZONA RIO',
|
||||
numero: '',
|
||||
codigoPostal: '22230',
|
||||
puesto: 'AGENTE DE MINISTERIO PUBLICO FEDERAL',
|
||||
telefono: '6646336847',
|
||||
ext: '',
|
||||
delegacion: 'TIJUANA',
|
||||
},
|
||||
economicos: {
|
||||
ingresosMensuales: 54988,
|
||||
otrosIngresos: 0,
|
||||
gastosLuz: 500,
|
||||
gastosOtros: 5000,
|
||||
dependientes: { personas: '', parentesco: '', edad: '' },
|
||||
},
|
||||
coacreditado: {
|
||||
tieneCoacreditado: false,
|
||||
generales: {
|
||||
campaniaVenta: '',
|
||||
fecha: '2026-07-19',
|
||||
nombreCompleto: '',
|
||||
nacionalidad: '',
|
||||
nivelEstudios: '',
|
||||
curp: '',
|
||||
rfc: '',
|
||||
nss: '',
|
||||
delegacionMunicipio: '',
|
||||
direccion: '',
|
||||
codigoPostal: '',
|
||||
antiguedadDomicilio: '',
|
||||
fechaNacimiento: '',
|
||||
lugarNacimiento: '',
|
||||
estadoCivil: '',
|
||||
regimenBienes: '',
|
||||
peso: '',
|
||||
estatura: '',
|
||||
email: '',
|
||||
telCasa: '',
|
||||
celular: '',
|
||||
viviendaAnios: '',
|
||||
tipoVivienda: '',
|
||||
tipoCompra: '',
|
||||
actividadOcupacion: '',
|
||||
},
|
||||
empleo: {
|
||||
tipoEmpleo: '',
|
||||
tipoEconomia: '',
|
||||
compania: '',
|
||||
antiguedadAnios: '',
|
||||
antiguedadMeses: '',
|
||||
direccion: '',
|
||||
colonia: '',
|
||||
numero: '',
|
||||
codigoPostal: '',
|
||||
puesto: '',
|
||||
telefono: '',
|
||||
ext: '',
|
||||
delegacion: '',
|
||||
},
|
||||
economicos: {
|
||||
ingresosMensuales: 0,
|
||||
otrosIngresos: 0,
|
||||
gastosLuz: 0,
|
||||
gastosOtros: 0,
|
||||
dependientes: { personas: '', parentesco: '', edad: '' },
|
||||
},
|
||||
},
|
||||
referencias: [
|
||||
{ nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' },
|
||||
{ nombre: 'JAIME JOHAN LARA', telefono: '6644704362', parentesco: 'AMIGO', direccion: 'CAÑON OASIS' },
|
||||
{ nombre: 'FANNY VANESSA CARDENAS', telefono: '6634381839', parentesco: 'AMIGO', direccion: 'VISTA LAGO' },
|
||||
{ nombre: 'CYNTHIA ANAHI GARCIA', telefono: '6634303303', parentesco: 'AMIGO', direccion: 'ZONA RIO' },
|
||||
],
|
||||
viviendaCredito: {
|
||||
proyecto: 'SENDA',
|
||||
proyectoOtro: '',
|
||||
modelo: 'LAUREL',
|
||||
manzana: '11',
|
||||
lote: '11',
|
||||
ubicacion: '409-1-11-11',
|
||||
superficieM2: '',
|
||||
valorVenta: '2700000',
|
||||
enganche: '0',
|
||||
equipamientoAdicional: '',
|
||||
fechaPagoEnganche: '',
|
||||
firmaContrato: '',
|
||||
tiposCredito: ['BANCARIO'],
|
||||
montoCreditoInfoFovi: '',
|
||||
ahorroVoluntarioSubcuenta: '',
|
||||
tipoProducto: '',
|
||||
descuentoMensualInfoFovissste: '',
|
||||
idAutoridadEmisora: 'INSTITUTO NACIONAL ELECTORAL',
|
||||
idNumero: 'IDMEX2311049617',
|
||||
idVigencia: '2032-12-31',
|
||||
domicilioFiscal: {
|
||||
calle: 'FRAY PEDRO DE GANTE',
|
||||
numExt: '97',
|
||||
numInt: '0',
|
||||
colonia: 'TIJUANA OTAY',
|
||||
municipio: 'TIJUANA',
|
||||
estado: 'BAJA CALIFORNIA',
|
||||
cp: '22508',
|
||||
pais: 'MEXICO',
|
||||
},
|
||||
},
|
||||
documentos: [],
|
||||
firma: {
|
||||
firmaDataUrl: FIRMA_PRUEBA,
|
||||
asesorNombre: 'ESMERALDA BECERRA DOMINGUEZ',
|
||||
},
|
||||
}
|
||||
48
apps/alta-clientes/src/lib/formatters.ts
Normal file
48
apps/alta-clientes/src/lib/formatters.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const formatoMXN = new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
})
|
||||
|
||||
/** Formato monetario MXN, p. ej. $2,700,000.00 */
|
||||
export function formatoMoneda(valor: number): string {
|
||||
return formatoMXN.format(valor)
|
||||
}
|
||||
|
||||
/** 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: "24/11/1989" */
|
||||
export function formatoFechaCorta(iso: string): string {
|
||||
if (!iso) return ''
|
||||
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}`
|
||||
}
|
||||
|
||||
/** Normaliza un nombre para usarlo en nombre de archivo: sin acentos ni espacios */
|
||||
export function normalizarNombreArchivo(nombre: string): string {
|
||||
return nombre
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^A-Za-z0-9_]/g, '')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
/** Construye el nombre de archivo PDF: {tipo}_{nombreCliente}_{fechaISO}.pdf */
|
||||
export function nombreArchivoPdf(tipo: string, nombreCliente: string, fecha: Date = new Date()): string {
|
||||
return `${tipo}_${normalizarNombreArchivo(nombreCliente)}_${fechaISOHoy(fecha)}.pdf`
|
||||
}
|
||||
51
apps/alta-clientes/src/lib/ocrIne.test.ts
Normal file
51
apps/alta-clientes/src/lib/ocrIne.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parsearTextoIne } from './ocrIne'
|
||||
|
||||
// Texto OCR de ejemplo de una credencial INE (frente)
|
||||
const TEXTO_OCR = `
|
||||
INSTITUTO NACIONAL ELECTORAL
|
||||
CREDENCIAL PARA VOTAR
|
||||
NOMBRE
|
||||
TELLEZ CHIGUINDO
|
||||
PASCUAL MARTIN
|
||||
DOMICILIO
|
||||
AV REVOLUCION 123
|
||||
TIJUANA BC 22000
|
||||
CLAVE DE ELECTOR TLCHPS89112402H400
|
||||
CURP TECP891124HBCLHS07
|
||||
FECHA DE NACIMIENTO 24/11/1989
|
||||
SEXO H
|
||||
VIGENCIA 2020-2030
|
||||
`
|
||||
|
||||
describe('parsearTextoIne', () => {
|
||||
it('extrae CURP y deriva fecha y sexo', () => {
|
||||
const s = parsearTextoIne(TEXTO_OCR)
|
||||
expect(s.curp).toBe('TECP891124HBCLHS07')
|
||||
expect(s.fechaNacimiento).toBe('1989-11-24')
|
||||
expect(s.sexo).toBe('H')
|
||||
})
|
||||
|
||||
it('extrae el nombre de las líneas tras NOMBRE', () => {
|
||||
const s = parsearTextoIne(TEXTO_OCR)
|
||||
expect(s.nombre).toBe('TELLEZ CHIGUINDO PASCUAL MARTIN')
|
||||
})
|
||||
|
||||
it('sin CURP no deriva fecha ni sexo', () => {
|
||||
const s = parsearTextoIne('NOMBRE\nPEREZ LOPEZ\nJUAN\nDOMICILIO')
|
||||
expect(s.curp).toBeUndefined()
|
||||
expect(s.fechaNacimiento).toBeUndefined()
|
||||
expect(s.sexo).toBeUndefined()
|
||||
expect(s.nombre).toBe('PEREZ LOPEZ JUAN')
|
||||
})
|
||||
|
||||
it('texto sin nada útil devuelve sugerencias vacías', () => {
|
||||
expect(parsearTextoIne('!!! ### 123')).toEqual({})
|
||||
})
|
||||
|
||||
it('tolerante a minúsculas del OCR', () => {
|
||||
const s = parsearTextoIne('nombre\ntellez chiguindo\npascual martin\ncurp tecp891124hbclhs07')
|
||||
expect(s.curp).toBe('TECP891124HBCLHS07')
|
||||
expect(s.nombre).toBe('TELLEZ CHIGUINDO PASCUAL MARTIN')
|
||||
})
|
||||
})
|
||||
81
apps/alta-clientes/src/lib/ocrIne.ts
Normal file
81
apps/alta-clientes/src/lib/ocrIne.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// Parser del texto OCR de una credencial INE: extrae sugerencias
|
||||
// (CURP, nombre, y fecha/sexo derivados de la CURP) sin tocar el formulario.
|
||||
|
||||
import { extraerDeCurp } from './curp'
|
||||
|
||||
export interface SugerenciasIne {
|
||||
curp?: string
|
||||
nombre?: string
|
||||
/** YYYY-MM-DD, derivada de la CURP detectada */
|
||||
fechaNacimiento?: string
|
||||
sexo?: 'H' | 'M'
|
||||
}
|
||||
|
||||
const REGEX_CURP_TEXTO = /[A-ZÑ]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d/
|
||||
|
||||
// Etiquetas típicas de la credencial que delimitan el bloque del nombre
|
||||
const ETIQUETAS_CORTE = [
|
||||
'DOMICILIO',
|
||||
'FECHA DE NACIMIENTO',
|
||||
'CLAVE DE ELECTOR',
|
||||
'CURP',
|
||||
'ESTADO',
|
||||
'MUNICIPIO',
|
||||
'LOCALIDAD',
|
||||
'SEXO',
|
||||
'VIGENCIA',
|
||||
'EMISION',
|
||||
'ANO DE REGISTRO',
|
||||
]
|
||||
|
||||
function esEtiquetaCorte(linea: string): boolean {
|
||||
return ETIQUETAS_CORTE.some((e) => linea.startsWith(e))
|
||||
}
|
||||
|
||||
/**
|
||||
* Heurística de nombre: líneas en mayúsculas que siguen a la línea "NOMBRE",
|
||||
* hasta topar con otra etiqueta de la credencial (máx 2 líneas).
|
||||
*/
|
||||
function extraerNombre(lineas: string[]): string | undefined {
|
||||
const idx = lineas.findIndex((l) => l === 'NOMBRE' || l.startsWith('NOMBRE '))
|
||||
if (idx === -1) return undefined
|
||||
const partes: string[] = []
|
||||
// Si la línea "NOMBRE" ya trae texto detrás, tómalo como primera parte
|
||||
const enMismaLinea = lineas[idx].replace(/^NOMBRE\s*/, '').trim()
|
||||
if (enMismaLinea !== '') partes.push(enMismaLinea)
|
||||
for (let i = idx + 1; i < lineas.length && partes.length < 2; i++) {
|
||||
const linea = lineas[i]
|
||||
if (esEtiquetaCorte(linea)) break
|
||||
if (!/^[A-ZÑÁÉÍÓÚ' -]{3,}$/.test(linea)) break
|
||||
partes.push(linea)
|
||||
}
|
||||
const nombre = partes.join(' ').replace(/\s+/g, ' ').trim()
|
||||
return nombre === '' ? undefined : nombre
|
||||
}
|
||||
|
||||
/** Convierte el texto OCR crudo en sugerencias para el formulario */
|
||||
export function parsearTextoIne(texto: string): SugerenciasIne {
|
||||
const lineas = texto
|
||||
.toUpperCase()
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l !== '')
|
||||
|
||||
const sugerencias: SugerenciasIne = {}
|
||||
|
||||
const textoPlano = lineas.join(' ')
|
||||
const curp = textoPlano.match(REGEX_CURP_TEXTO)?.[0]
|
||||
if (curp) {
|
||||
sugerencias.curp = curp
|
||||
const derivados = extraerDeCurp(curp)
|
||||
if (derivados) {
|
||||
sugerencias.fechaNacimiento = derivados.fechaNacimiento
|
||||
sugerencias.sexo = derivados.sexo
|
||||
}
|
||||
}
|
||||
|
||||
const nombre = extraerNombre(lineas)
|
||||
if (nombre) sugerencias.nombre = nombre
|
||||
|
||||
return sugerencias
|
||||
}
|
||||
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)
|
||||
}
|
||||
173
apps/alta-clientes/src/lib/schemas.test.ts
Normal file
173
apps/alta-clientes/src/lib/schemas.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
datosGeneralesSchema,
|
||||
coacreditadoSchema,
|
||||
referenciasSchema,
|
||||
REGEX_CURP,
|
||||
REGEX_RFC,
|
||||
} from './schemas'
|
||||
import type { DatosGenerales } from '../types'
|
||||
|
||||
// Datos válidos base con el fixture del cliente del PDF de referencia
|
||||
const generalesValidos: DatosGenerales = {
|
||||
campaniaVenta: '',
|
||||
fecha: '2026-07-19',
|
||||
nombreCompleto: 'PASCUAL MARTIN TELLEZ CHIGUINDO',
|
||||
nacionalidad: 'MEXICANA',
|
||||
nivelEstudios: 'MAESTRIA',
|
||||
curp: 'TECP891124HBCLHS07',
|
||||
rfc: 'TECP8911246B4',
|
||||
nss: '',
|
||||
delegacionMunicipio: 'TIJUANA',
|
||||
direccion: 'RIO EUFRATES EDIF 1 7145 203',
|
||||
codigoPostal: '22223',
|
||||
antiguedadDomicilio: '2',
|
||||
fechaNacimiento: '1989-11-24',
|
||||
lugarNacimiento: 'TIJUANA',
|
||||
estadoCivil: 'SOLTERO',
|
||||
regimenBienes: '',
|
||||
peso: '87',
|
||||
estatura: '1.78',
|
||||
email: 'dantemartinchi@gmail.com',
|
||||
telCasa: '',
|
||||
celular: '6646935924',
|
||||
viviendaAnios: '2',
|
||||
tipoVivienda: 'PROPIA',
|
||||
tipoCompra: 'VIVIR_AHI',
|
||||
actividadOcupacion: 'AGENTE DEL MINISTERIO PUBLICO FEDERAL',
|
||||
}
|
||||
|
||||
describe('regex de identificadores', () => {
|
||||
it('acepta CURP válida', () => {
|
||||
expect(REGEX_CURP.test('TECP891124HBCLHS07')).toBe(true)
|
||||
})
|
||||
|
||||
it('rechaza CURP inválida', () => {
|
||||
expect(REGEX_CURP.test('TECP891124HBCLHS0')).toBe(false) // corta
|
||||
expect(REGEX_CURP.test('tecp891124hbclhs07')).toBe(false) // minúsculas
|
||||
expect(REGEX_CURP.test('TECP89112XHBCLHS07')).toBe(false) // fecha inválida
|
||||
})
|
||||
|
||||
it('acepta RFC válido', () => {
|
||||
expect(REGEX_RFC.test('TECP8911246B4')).toBe(true)
|
||||
})
|
||||
|
||||
it('rechaza RFC inválido', () => {
|
||||
expect(REGEX_RFC.test('TECP8911246B')).toBe(false) // corto
|
||||
expect(REGEX_RFC.test('TECP891124!B4')).toBe(false)
|
||||
expect(REGEX_RFC.test('tecp8911246b4')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('datosGeneralesSchema', () => {
|
||||
it('acepta los datos del fixture', () => {
|
||||
expect(datosGeneralesSchema.safeParse(generalesValidos).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rechaza CURP inválida', () => {
|
||||
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, curp: 'XXXX000000XXXXXXX0' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rechaza RFC inválido', () => {
|
||||
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, rfc: '123' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rechaza email inválido', () => {
|
||||
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, email: 'no-es-correo' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rechaza código postal que no tenga 5 dígitos', () => {
|
||||
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, codigoPostal: '2223' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('exige régimen de bienes solo cuando el estado civil es CASADO', () => {
|
||||
const casado = datosGeneralesSchema.safeParse({ ...generalesValidos, estadoCivil: 'CASADO' })
|
||||
expect(casado.success).toBe(false)
|
||||
const casadoConRegimen = datosGeneralesSchema.safeParse({
|
||||
...generalesValidos,
|
||||
estadoCivil: 'CASADO',
|
||||
regimenBienes: 'SEPARACION_BIENES',
|
||||
})
|
||||
expect(casadoConRegimen.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coacreditadoSchema', () => {
|
||||
const base = {
|
||||
generales: { ...generalesValidos, nombreCompleto: '', curp: '', rfc: '', email: '', fechaNacimiento: '', celular: '' },
|
||||
empleo: {
|
||||
tipoEmpleo: '' as const,
|
||||
tipoEconomia: '' as const,
|
||||
compania: '',
|
||||
antiguedadAnios: '',
|
||||
antiguedadMeses: '',
|
||||
direccion: '',
|
||||
colonia: '',
|
||||
numero: '',
|
||||
codigoPostal: '',
|
||||
puesto: '',
|
||||
telefono: '',
|
||||
ext: '',
|
||||
delegacion: '',
|
||||
},
|
||||
economicos: {
|
||||
ingresosMensuales: 0,
|
||||
otrosIngresos: 0,
|
||||
gastosLuz: 0,
|
||||
gastosOtros: 0,
|
||||
dependientes: { personas: '', parentesco: '', edad: '' },
|
||||
},
|
||||
}
|
||||
|
||||
it('no exige datos del co-acreditado cuando el toggle está apagado', () => {
|
||||
const r = coacreditadoSchema.safeParse({ ...base, tieneCoacreditado: false })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('exige el nombre del co-acreditado cuando el toggle está activo', () => {
|
||||
const r = coacreditadoSchema.safeParse({ ...base, tieneCoacreditado: true })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('acepta co-acreditado con nombre cuando el toggle está activo', () => {
|
||||
const r = coacreditadoSchema.safeParse({
|
||||
...base,
|
||||
tieneCoacreditado: true,
|
||||
generales: { ...base.generales, nombreCompleto: 'MARIA LOPEZ PEREZ' },
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('referenciasSchema', () => {
|
||||
const vacia = { nombre: '', telefono: '', parentesco: '', direccion: '' }
|
||||
|
||||
it('rechaza cuando ninguna referencia tiene nombre y teléfono', () => {
|
||||
const r = referenciasSchema.safeParse([vacia, vacia, vacia, vacia])
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rechaza referencia con nombre pero sin teléfono', () => {
|
||||
const r = referenciasSchema.safeParse([
|
||||
{ ...vacia, nombre: 'JOSE ARNOLDO MENDOZA' },
|
||||
vacia,
|
||||
vacia,
|
||||
vacia,
|
||||
])
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('acepta con al menos 1 referencia válida', () => {
|
||||
const r = referenciasSchema.safeParse([
|
||||
{ nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' },
|
||||
vacia,
|
||||
vacia,
|
||||
vacia,
|
||||
])
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
})
|
||||
219
apps/alta-clientes/src/lib/schemas.ts
Normal file
219
apps/alta-clientes/src/lib/schemas.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const REGEX_CURP = /^[A-Z]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d$/
|
||||
export const REGEX_RFC = /^[A-ZÑ&]{4}\d{6}[0-9A-Z]{3}$/
|
||||
export const REGEX_CP = /^\d{5}$/
|
||||
|
||||
// Texto opcional: cadena siempre presente (vacía si no se captura)
|
||||
const textoOpcional = z.string().trim().max(200)
|
||||
|
||||
const curpSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.regex(REGEX_CURP, 'CURP inválida (18 caracteres, formato oficial)')
|
||||
|
||||
const rfcSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.regex(REGEX_RFC, 'RFC inválido (13 caracteres, formato persona física)')
|
||||
|
||||
const cpOpcional = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => v === '' || REGEX_CP.test(v), 'Código postal de 5 dígitos')
|
||||
|
||||
const curpOpcional = z
|
||||
.string()
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.refine((v) => v === '' || REGEX_CURP.test(v), 'CURP inválida')
|
||||
|
||||
const rfcOpcional = z
|
||||
.string()
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.refine((v) => v === '' || REGEX_RFC.test(v), 'RFC inválido')
|
||||
|
||||
const emailOpcional = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => v === '' || z.string().email().safeParse(v).success, 'Correo electrónico inválido')
|
||||
|
||||
export const datosGeneralesSchema = z
|
||||
.object({
|
||||
campaniaVenta: textoOpcional,
|
||||
fecha: z.string().min(1, 'La fecha es requerida'),
|
||||
nombreCompleto: z.string().trim().min(3, 'El nombre completo es requerido'),
|
||||
nacionalidad: textoOpcional,
|
||||
nivelEstudios: textoOpcional,
|
||||
curp: curpSchema,
|
||||
rfc: rfcSchema,
|
||||
nss: textoOpcional,
|
||||
delegacionMunicipio: textoOpcional,
|
||||
direccion: textoOpcional,
|
||||
codigoPostal: cpOpcional,
|
||||
antiguedadDomicilio: textoOpcional,
|
||||
fechaNacimiento: z.string().min(1, 'La fecha de nacimiento es requerida'),
|
||||
lugarNacimiento: textoOpcional,
|
||||
estadoCivil: z.enum(['SOLTERO', 'CASADO', 'UNION_LIBRE', 'DIVORCIADO', 'VIUDO', '']),
|
||||
regimenBienes: z.enum(['SOCIEDAD_CONYUGAL', 'SEPARACION_BIENES', '']),
|
||||
peso: textoOpcional,
|
||||
estatura: textoOpcional,
|
||||
email: z.string().trim().email('Correo electrónico inválido'),
|
||||
telCasa: textoOpcional,
|
||||
celular: z.string().trim().min(8, 'El celular es requerido'),
|
||||
viviendaAnios: textoOpcional,
|
||||
tipoVivienda: z.enum(['PRESTADA', 'RENTADA', 'FAMILIAR', 'PROPIA', '']),
|
||||
tipoCompra: z.enum(['INVERSION', 'VIVIR_AHI', '']),
|
||||
actividadOcupacion: textoOpcional,
|
||||
})
|
||||
.superRefine((datos, ctx) => {
|
||||
// Régimen de bienes solo aplica (y se exige) cuando el estado civil es CASADO
|
||||
if (datos.estadoCivil === 'CASADO' && datos.regimenBienes === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['regimenBienes'],
|
||||
message: 'Selecciona el régimen de bienes',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const empleoSchema = z.object({
|
||||
tipoEmpleo: z.enum(['ASALARIADO', 'INDEPENDIENTE', '']),
|
||||
tipoEconomia: z.enum(['E_MEXICANA', 'E_AMERICANA', 'MIXTO', '']),
|
||||
compania: textoOpcional,
|
||||
antiguedadAnios: textoOpcional,
|
||||
antiguedadMeses: textoOpcional,
|
||||
direccion: textoOpcional,
|
||||
colonia: textoOpcional,
|
||||
numero: textoOpcional,
|
||||
codigoPostal: cpOpcional,
|
||||
puesto: textoOpcional,
|
||||
telefono: textoOpcional,
|
||||
ext: textoOpcional,
|
||||
delegacion: textoOpcional,
|
||||
})
|
||||
|
||||
const numeroNoNegativo = z.number({ invalid_type_error: 'Ingresa un número' }).min(0, 'No puede ser negativo')
|
||||
|
||||
export const economicosSchema = z.object({
|
||||
ingresosMensuales: numeroNoNegativo,
|
||||
otrosIngresos: numeroNoNegativo,
|
||||
gastosLuz: numeroNoNegativo,
|
||||
gastosOtros: numeroNoNegativo,
|
||||
dependientes: z.object({
|
||||
personas: textoOpcional,
|
||||
parentesco: textoOpcional,
|
||||
edad: textoOpcional,
|
||||
}),
|
||||
})
|
||||
|
||||
// Datos generales del co-acreditado: el nombre se exige vía superRefine solo
|
||||
// cuando el toggle está activo; el resto se valida solo si se captura
|
||||
const coacreditadoGeneralesSchema = datosGeneralesSchema.innerType().extend({
|
||||
nombreCompleto: z.string().trim().max(200),
|
||||
curp: curpOpcional,
|
||||
rfc: rfcOpcional,
|
||||
email: emailOpcional,
|
||||
fechaNacimiento: textoOpcional,
|
||||
celular: textoOpcional,
|
||||
})
|
||||
|
||||
export const coacreditadoSchema = z
|
||||
.object({
|
||||
tieneCoacreditado: z.boolean(),
|
||||
generales: coacreditadoGeneralesSchema,
|
||||
empleo: empleoSchema,
|
||||
economicos: economicosSchema,
|
||||
})
|
||||
.superRefine((datos, ctx) => {
|
||||
// Los datos del co-acreditado solo se exigen si el toggle está activo
|
||||
if (datos.tieneCoacreditado && datos.generales.nombreCompleto.trim().length < 3) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['generales', 'nombreCompleto'],
|
||||
message: 'El nombre del co-acreditado es requerido',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const referenciaSchema = z.object({
|
||||
nombre: textoOpcional,
|
||||
telefono: textoOpcional,
|
||||
parentesco: textoOpcional,
|
||||
direccion: textoOpcional,
|
||||
})
|
||||
|
||||
export const referenciasSchema = z
|
||||
.array(referenciaSchema)
|
||||
.length(4, 'Se capturan 4 referencias')
|
||||
.refine(
|
||||
(refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''),
|
||||
'Captura al menos 1 referencia con nombre y teléfono',
|
||||
)
|
||||
|
||||
export const domicilioFiscalSchema = z.object({
|
||||
calle: textoOpcional,
|
||||
numExt: textoOpcional,
|
||||
numInt: textoOpcional,
|
||||
colonia: textoOpcional,
|
||||
municipio: textoOpcional,
|
||||
estado: textoOpcional,
|
||||
cp: cpOpcional,
|
||||
pais: textoOpcional,
|
||||
})
|
||||
|
||||
export const viviendaCreditoSchema = z
|
||||
.object({
|
||||
proyecto: z.string().min(1, 'Selecciona el proyecto'),
|
||||
proyectoOtro: textoOpcional,
|
||||
modelo: textoOpcional,
|
||||
manzana: textoOpcional,
|
||||
lote: textoOpcional,
|
||||
ubicacion: textoOpcional,
|
||||
superficieM2: textoOpcional,
|
||||
valorVenta: textoOpcional,
|
||||
enganche: textoOpcional,
|
||||
equipamientoAdicional: textoOpcional,
|
||||
fechaPagoEnganche: textoOpcional,
|
||||
firmaContrato: textoOpcional,
|
||||
tiposCredito: z.array(
|
||||
z.enum([
|
||||
'INFONAVIT',
|
||||
'APOYO_INFONAVIT',
|
||||
'COFINAVIT',
|
||||
'SEGUNDO_CREDITO',
|
||||
'CONTADO',
|
||||
'FOVISSSTE',
|
||||
'COFI_FOVISSSTE',
|
||||
'BANCARIO',
|
||||
]),
|
||||
),
|
||||
montoCreditoInfoFovi: textoOpcional,
|
||||
ahorroVoluntarioSubcuenta: textoOpcional,
|
||||
tipoProducto: z.enum(['PESOS_FIJOS', 'PESOS_CRECIENTES', 'VSM', '']),
|
||||
descuentoMensualInfoFovissste: textoOpcional,
|
||||
idAutoridadEmisora: textoOpcional,
|
||||
idNumero: textoOpcional,
|
||||
idVigencia: textoOpcional,
|
||||
domicilioFiscal: domicilioFiscalSchema,
|
||||
})
|
||||
.superRefine((datos, ctx) => {
|
||||
if (datos.proyecto === 'OTRO' && datos.proyectoOtro.trim() === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['proyectoOtro'],
|
||||
message: 'Escribe el nombre del proyecto',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const firmaSchema = z.object({
|
||||
firmaDataUrl: z
|
||||
.string()
|
||||
.min(1, 'La firma del cliente es requerida')
|
||||
.startsWith('data:image/', 'La firma debe ser una imagen'),
|
||||
asesorNombre: z.string().trim().min(3, 'El nombre del asesor es requerido'),
|
||||
})
|
||||
14
apps/alta-clientes/src/lib/uuid.ts
Normal file
14
apps/alta-clientes/src/lib/uuid.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Genera un UUID v4. crypto.randomUUID solo existe en contextos seguros
|
||||
* (HTTPS o localhost); al servir por IP en HTTP hay que usar el fallback.
|
||||
*/
|
||||
export function uuid(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user