feat: conversión lead→oportunidad (Agendo) y alta ligada a apartado
- Mis leads: botón 'Agendo →' convierte el lead en oportunidad (etapa Agendo Cita, con sus datos, asesor = vendedor del lead o quien convierte), marca el lead Calificado y guarda el mapping LeadOportunidad; 409 si ya fue convertido - Mis oportunidades: se quitan No Asistio y Abandono (redundantes con Descartar); quedan Cotizar, Afluencia, Agendo Cita y Aparto - Mis apartados: 'Firma cliente (venta) →' abre el alta de cliente con TODO lo recolectado prellenado (ficha del apartado + contacto + oportunidad + cotización vinculada vía GET /altas/prefill-apartado/:id) - El alta con apartadoMondayId se LIGA al apartado existente y lo enriquece (curp, teléfono, email, precio final, enganche, forma de pago, unidad) en vez de crear un duplicado - monday.service: actualizarApartado + ApartadoMonday con curp, forma de pago, enganche y monto apartado - Tests: 470 API + 120 alta-clientes + 16 mi-dia, verde
This commit is contained in:
79
apps/alta-clientes/src/lib/apartadoAlta.test.ts
Normal file
79
apps/alta-clientes/src/lib/apartadoAlta.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apartadoAPrellenado, type PrefillApartado } from './apartadoAlta'
|
||||
|
||||
const base: PrefillApartado = {
|
||||
apartado: {
|
||||
id: 'a1',
|
||||
nombre: 'Ana Demo',
|
||||
telefono: '+52 6641234567',
|
||||
email: 'ana@correo.com',
|
||||
curp: '',
|
||||
unidadCodigo: 'SENDA-409-1-2',
|
||||
precioFinal: 2646000,
|
||||
enganche: 132300,
|
||||
formaPago: 'Cofinavit',
|
||||
},
|
||||
contacto: {
|
||||
nombre: 'Ana Demo',
|
||||
CURP: 'AAXX010101XXXXXXX0',
|
||||
RFC: 'AAXX010101XXX',
|
||||
Dirección: 'Calle 1, Col. Centro, CP 38000',
|
||||
'Estado Civil': 'Casado(a)',
|
||||
'Fecha Nacimiento': '1990-05-12',
|
||||
},
|
||||
oportunidad: { id: 'op-1', desarrollo: 'SENDA', modeloInteres: 'SOHO', credito: 'Cofinavit' },
|
||||
cotizacion: { tipoPropuesta: 'INFONAVIT', acabados: true, precioFinal: 2646000, enganche: 132300 },
|
||||
}
|
||||
|
||||
describe('apartadoAPrellenado (alta desde apartado de Monday)', () => {
|
||||
it('prellena datos generales (contacto manda) y vivienda/crédito (apartado > oportunidad > cotización)', () => {
|
||||
const p = apartadoAPrellenado(base)
|
||||
expect(p.datosGenerales).toMatchObject({
|
||||
nombreCompleto: 'ANA DEMO',
|
||||
email: 'ana@correo.com',
|
||||
celular: '+52 6641234567',
|
||||
curp: 'AAXX010101XXXXXXX0',
|
||||
rfc: 'AAXX010101XXX',
|
||||
direccion: 'CALLE 1, COL. CENTRO, CP 38000',
|
||||
fechaNacimiento: '1990-05-12',
|
||||
estadoCivil: 'CASADO',
|
||||
})
|
||||
expect(p.viviendaCredito).toMatchObject({
|
||||
proyecto: 'SENDA',
|
||||
modelo: 'SOHO',
|
||||
ubicacion: 'SENDA-409-1-2',
|
||||
valorVenta: '2646000',
|
||||
enganche: '132300',
|
||||
tiposCredito: ['COFINAVIT'], // formaPago del apartado manda sobre la cotización
|
||||
equipamientoAdicional: 'COCINA Y CLOSET', // cotización con acabados
|
||||
})
|
||||
expect(p.leadMondayId).toBe('op-1')
|
||||
})
|
||||
|
||||
it('desarrollo desconocido cae en OTRO con el nombre en texto libre', () => {
|
||||
const p = apartadoAPrellenado({ ...base, oportunidad: { id: 'op-1', desarrollo: 'MONTEBELLO' } })
|
||||
expect(p.viviendaCredito.proyecto).toBe('OTRO')
|
||||
expect(p.viviendaCredito.proyectoOtro).toBe('MONTEBELLO')
|
||||
})
|
||||
|
||||
it('sin contacto usa los datos del apartado; sin créditos no prellena tiposCredito', () => {
|
||||
const p = apartadoAPrellenado({
|
||||
...base,
|
||||
contacto: null,
|
||||
apartado: { ...base.apartado, formaPago: '' },
|
||||
oportunidad: { id: 'op-1', desarrollo: 'SENDA' },
|
||||
cotizacion: null,
|
||||
})
|
||||
expect(p.datosGenerales).toMatchObject({ nombreCompleto: 'ANA DEMO', email: 'ana@correo.com', celular: '+52 6641234567' })
|
||||
expect(p.datosGenerales.curp).toBeUndefined()
|
||||
expect(p.datosGenerales.estadoCivil).toBeUndefined()
|
||||
expect(p.viviendaCredito.tiposCredito).toBeUndefined()
|
||||
expect(p.viviendaCredito.equipamientoAdicional).toBeUndefined()
|
||||
expect(p.leadMondayId).toBe('op-1')
|
||||
})
|
||||
|
||||
it('sin oportunidad relacionada el alta queda sin lead ligado', () => {
|
||||
const p = apartadoAPrellenado({ ...base, oportunidad: null })
|
||||
expect(p.leadMondayId).toBeNull()
|
||||
})
|
||||
})
|
||||
138
apps/alta-clientes/src/lib/apartadoAlta.ts
Normal file
138
apps/alta-clientes/src/lib/apartadoAlta.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { DatosGenerales, EstadoCivil, TipoCredito, ViviendaCredito } from '../types'
|
||||
import { PROYECTOS } from '../data/constantes'
|
||||
|
||||
/**
|
||||
* Transformación pura: todo lo recolectado de un apartado de Monday (ficha del
|
||||
* apartado + contacto + oportunidad + cotización vinculada) → campos del
|
||||
* wizard de alta. Prellena solo lo que exista (nunca inventa datos).
|
||||
*/
|
||||
|
||||
/** Shape de GET /api/altas/prefill-apartado/:itemId que usa el pre-llenado */
|
||||
export interface PrefillApartado {
|
||||
apartado: {
|
||||
id: string
|
||||
nombre: string
|
||||
telefono?: string
|
||||
email?: string
|
||||
curp?: string
|
||||
unidadCodigo: string
|
||||
precioFinal?: number
|
||||
enganche?: number
|
||||
formaPago?: string
|
||||
}
|
||||
contacto: Record<string, string> | null
|
||||
oportunidad: {
|
||||
id: string
|
||||
desarrollo?: string
|
||||
modeloInteres?: string
|
||||
credito?: string
|
||||
} | null
|
||||
cotizacion: {
|
||||
tipoPropuesta: string
|
||||
acabados: boolean
|
||||
precioFinal: number
|
||||
enganche: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface PrellenadoDesdeApartado {
|
||||
datosGenerales: Partial<DatosGenerales>
|
||||
viviendaCredito: Partial<ViviendaCredito>
|
||||
/** Oportunidad relacionada (para que el alta mueva el funnel como siempre) */
|
||||
leadMondayId: string | null
|
||||
}
|
||||
|
||||
// Label de Monday (Contacto/Oportunidad) → enum del wizard
|
||||
const ESTADO_CIVIL_ENUM: Record<string, EstadoCivil> = {
|
||||
'SOLTERO(A)': 'SOLTERO',
|
||||
SOLTERO: 'SOLTERO',
|
||||
'CASADO(A)': 'CASADO',
|
||||
CASADO: 'CASADO',
|
||||
'UNION LIBRE': 'UNION_LIBRE',
|
||||
'UNIÓN LIBRE': 'UNION_LIBRE',
|
||||
'DIVORCIADO(A)': 'DIVORCIADO',
|
||||
DIVORCIADO: 'DIVORCIADO',
|
||||
'VIUDO(A)': 'VIUDO',
|
||||
VIUDO: 'VIUDO',
|
||||
}
|
||||
|
||||
// Label del dropdown 'Forma de Pago' (Apartado) o 'Crédito' (Oportunidades) → enum
|
||||
const CREDITO_ENUM: Record<string, TipoCredito> = {
|
||||
INFONAVIT: 'INFONAVIT',
|
||||
'APOYO INFONAVIT': 'APOYO_INFONAVIT',
|
||||
COFINAVIT: 'COFINAVIT',
|
||||
'SEGUNDO CRÉDITO': 'SEGUNDO_CREDITO',
|
||||
'SEGUNDO CREDITO': 'SEGUNDO_CREDITO',
|
||||
CONTADO: 'CONTADO',
|
||||
FOVISSSTE: 'FOVISSSTE',
|
||||
'COFI-FOVISSSTE': 'COFI_FOVISSSTE',
|
||||
BANCARIO: 'BANCARIO',
|
||||
}
|
||||
|
||||
// Tipo de propuesta del cotizador → enum de crédito del wizard
|
||||
const PROPUESTA_ENUM: Record<string, TipoCredito> = {
|
||||
BANCARIO: 'BANCARIO',
|
||||
INFONAVIT: 'INFONAVIT',
|
||||
COFINAVIT: 'COFINAVIT',
|
||||
CONTADO: 'CONTADO',
|
||||
}
|
||||
|
||||
const texto = (v: string | undefined | null): string | undefined => {
|
||||
const t = (v ?? '').trim()
|
||||
return t === '' ? undefined : t
|
||||
}
|
||||
|
||||
const monto = (n: number | undefined | null): string | undefined =>
|
||||
n !== undefined && n !== null && n > 0 ? String(n) : undefined
|
||||
|
||||
/** apartado/contacto/oportunidad/cotización → campos del wizard de alta */
|
||||
export function apartadoAPrellenado(pre: PrefillApartado): PrellenadoDesdeApartado {
|
||||
const { apartado, contacto, oportunidad, cotizacion } = pre
|
||||
const c = contacto ?? {}
|
||||
|
||||
// Datos generales: el contacto (perfil del alta progresiva) manda; el
|
||||
// apartado cubre lo que falte
|
||||
const estadoCivilLabel = (texto(c['Estado Civil']) ?? '').toUpperCase()
|
||||
const datosGenerales: Partial<DatosGenerales> = {
|
||||
nombreCompleto: (texto(c['nombre']) ?? apartado.nombre).toUpperCase(),
|
||||
...(texto(c['Email']) ?? texto(apartado.email) ? { email: (texto(c['Email']) ?? texto(apartado.email))! } : {}),
|
||||
...(texto(c['Teléfono']) ?? texto(apartado.telefono) ? { celular: (texto(c['Teléfono']) ?? texto(apartado.telefono))! } : {}),
|
||||
...(texto(c['CURP']) ?? texto(apartado.curp) ? { curp: (texto(c['CURP']) ?? texto(apartado.curp))!.toUpperCase() } : {}),
|
||||
...(texto(c['RFC']) ? { rfc: texto(c['RFC'])!.toUpperCase() } : {}),
|
||||
...(texto(c['Dirección']) ? { direccion: texto(c['Dirección'])!.toUpperCase() } : {}),
|
||||
...(texto(c['Fecha Nacimiento']) ? { fechaNacimiento: texto(c['Fecha Nacimiento'])!.slice(0, 10) } : {}),
|
||||
...(ESTADO_CIVIL_ENUM[estadoCivilLabel] ? { estadoCivil: ESTADO_CIVIL_ENUM[estadoCivilLabel] } : {}),
|
||||
}
|
||||
|
||||
// Vivienda/crédito: desarrollo → proyecto del dropdown; la "ubicación" del
|
||||
// formato es el código de unidad. Forma de pago: apartado > crédito de la
|
||||
// oportunidad > tipo de propuesta de la cotización.
|
||||
const desarrollo = (oportunidad?.desarrollo ?? '').trim().toUpperCase()
|
||||
const conocido = (PROYECTOS as readonly string[]).includes(desarrollo)
|
||||
const formaPago = CREDITO_ENUM[(apartado.formaPago ?? '').trim().toUpperCase()]
|
||||
const creditoOp = CREDITO_ENUM[(oportunidad?.credito ?? '').trim().toUpperCase()]
|
||||
const creditoCot = cotizacion ? PROPUESTA_ENUM[cotizacion.tipoPropuesta] : undefined
|
||||
const tipoCredito = formaPago ?? creditoOp ?? creditoCot
|
||||
|
||||
const viviendaCredito: Partial<ViviendaCredito> = {
|
||||
...(desarrollo !== ''
|
||||
? { proyecto: conocido ? desarrollo : 'OTRO', proyectoOtro: conocido ? '' : desarrollo }
|
||||
: {}),
|
||||
...(texto(oportunidad?.modeloInteres) ? { modelo: texto(oportunidad?.modeloInteres)!.toUpperCase() } : {}),
|
||||
...(texto(apartado.unidadCodigo) ? { ubicacion: apartado.unidadCodigo } : {}),
|
||||
...(monto(apartado.precioFinal) ?? monto(cotizacion?.precioFinal)
|
||||
? { valorVenta: (monto(apartado.precioFinal) ?? monto(cotizacion?.precioFinal))! }
|
||||
: {}),
|
||||
...(monto(apartado.enganche) ?? monto(cotizacion?.enganche)
|
||||
? { enganche: (monto(apartado.enganche) ?? monto(cotizacion?.enganche))! }
|
||||
: {}),
|
||||
...(tipoCredito ? { tiposCredito: [tipoCredito] } : {}),
|
||||
...(cotizacion?.acabados ? { equipamientoAdicional: 'COCINA Y CLOSET' } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
datosGenerales,
|
||||
viviendaCredito,
|
||||
leadMondayId: oportunidad?.id ?? null,
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const FIRMA_PRUEBA =
|
||||
*/
|
||||
export const fixtureAlta: AltaCliente = {
|
||||
leadMondayId: null,
|
||||
apartadoMondayId: null,
|
||||
progresoId: null,
|
||||
datosGenerales: {
|
||||
campaniaVenta: '',
|
||||
|
||||
Reference in New Issue
Block a user