feat(apartado): ficha completa en Monday + estado visible en la app
- El alta (directa y progresiva) llena el Apartado con telefono, correo, curp, Precio Final, Enganche, Fecha Compra, Vendedor (el capturista con Monday vinculado) y Forma de Pago; el path webhook/cron hereda contacto, precio y asesor de la oportunidad. - Bidireccional: Mis clientes en /hoy muestra el estado actual del Apartado leyendolo de Monday (si lo mueven en Monday, la app lo refleja en el siguiente refresh). - getApartados con la correccion value-null al leer Estado. - Test del mapa de Tipo propuesta del cotizador (los 502 del log eran de un proceso previo al fix 4f31d1c; queda cubierto).
This commit is contained in:
@@ -10,6 +10,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
usuario: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
@@ -66,6 +69,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado
|
||||
mondayMock.crearContacto.mockResolvedValue('cont-1')
|
||||
mondayMock.actualizarContacto.mockResolvedValue(undefined)
|
||||
mondayMock.crearApartado.mockResolvedValue('ap-1')
|
||||
@@ -282,6 +286,38 @@ describe('completarProgreso (POST /api/clientes/progreso/:id/completar)', () =>
|
||||
})
|
||||
})
|
||||
|
||||
it('el Apartado lleva la ficha completa: contacto, precio, Forma de Pago y Vendedor del capturista original', async () => {
|
||||
const { completarProgreso } = await import('./clientes.controller.js')
|
||||
prismaMock.cliente.findUnique.mockResolvedValue({
|
||||
id: 'cli-1',
|
||||
mondayContactoId: 'cont-1',
|
||||
mondayApartadoId: null,
|
||||
capturadoPorId: 'usr-9', // quien inició el alta progresiva
|
||||
})
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
||||
const altaConCredito = {
|
||||
...altaCompleta,
|
||||
viviendaCredito: { ...altaCompleta.viviendaCredito, tiposCredito: ['COFI_FOVISSSTE'] },
|
||||
}
|
||||
const { res, status } = resFake()
|
||||
await completarProgreso(reqFake(altaConCredito, { id: 'cli-1' }), res)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clienteNombre: 'Ana Demo Progreso',
|
||||
unidadCodigo: 'U-999-DEMO2',
|
||||
telefono: '6649990003',
|
||||
email: 'ana@correo.com',
|
||||
curp: 'AEDA900101MBCXXX00',
|
||||
precioFinal: 1500000,
|
||||
enganche: 20000,
|
||||
vendedorId: 12345,
|
||||
formaPagoLabel: 'Cofi-Fovissste',
|
||||
fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sin Contacto previo lo crea directo como CLIENTE (alta sin Monday al iniciar)', async () => {
|
||||
const { completarProgreso } = await import('./clientes.controller.js')
|
||||
prismaMock.cliente.findUnique.mockResolvedValue({
|
||||
|
||||
@@ -14,6 +14,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
usuario: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const mondayMock = vi.hoisted(() => ({
|
||||
@@ -134,6 +137,7 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () =>
|
||||
vi.clearAllMocks()
|
||||
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||
prismaMock.cliente.update.mockResolvedValue({})
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(null) // default: capturista sin Monday vinculado
|
||||
mondayMock.crearContacto.mockResolvedValue('cont-1')
|
||||
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
|
||||
})
|
||||
@@ -176,3 +180,99 @@ describe('crearCliente (POST /api/clientes) — leadMondayId del funnel', () =>
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formaPagoLabel (tipos de crédito del alta → dropdown Forma de Pago)', () => {
|
||||
it('mapea los 8 valores del enum del formato a los labels sembrados en Monday', async () => {
|
||||
const { formaPagoLabel } = await import('./clientes.controller.js')
|
||||
expect(formaPagoLabel(['INFONAVIT'])).toBe('Infonavit')
|
||||
expect(formaPagoLabel(['APOYO_INFONAVIT'])).toBe('Apoyo Infonavit')
|
||||
expect(formaPagoLabel(['COFINAVIT'])).toBe('Cofinavit')
|
||||
expect(formaPagoLabel(['SEGUNDO_CREDITO'])).toBe('Segundo crédito')
|
||||
expect(formaPagoLabel(['CONTADO'])).toBe('Contado')
|
||||
expect(formaPagoLabel(['FOVISSSTE'])).toBe('Fovissste')
|
||||
expect(formaPagoLabel(['COFI_FOVISSSTE'])).toBe('Cofi-Fovissste')
|
||||
expect(formaPagoLabel(['BANCARIO'])).toBe('Bancario')
|
||||
})
|
||||
|
||||
it('toma el PRIMERO reconocido del arreglo y tolera desconocidos', async () => {
|
||||
const { formaPagoLabel } = await import('./clientes.controller.js')
|
||||
expect(formaPagoLabel(['OTRA_COSA', 'CONTADO'])).toBe('Contado')
|
||||
expect(formaPagoLabel(['OTRA_COSA'])).toBeUndefined()
|
||||
expect(formaPagoLabel([])).toBeUndefined()
|
||||
expect(formaPagoLabel(undefined)).toBeUndefined()
|
||||
expect(formaPagoLabel('CONTADO')).toBeUndefined() // no es arreglo
|
||||
})
|
||||
})
|
||||
|
||||
describe('crearCliente — Apartado con ficha completa', () => {
|
||||
const altaConVenta = {
|
||||
datosGenerales: {
|
||||
nombreCompleto: 'Cliente Demo',
|
||||
curp: 'CURP123',
|
||||
rfc: 'RFC123',
|
||||
email: 'cli@correo.com',
|
||||
celular: '6641112233',
|
||||
},
|
||||
viviendaCredito: {
|
||||
proyecto: 'SENDA',
|
||||
ubicacion: 'U-101',
|
||||
valorVenta: 1500000,
|
||||
enganche: 20000,
|
||||
tiposCredito: ['INFONAVIT'],
|
||||
},
|
||||
}
|
||||
|
||||
const reqFake = (body: unknown, usuarioToken?: { sub: string }): Request =>
|
||||
({ body, usuarioToken } as unknown as Request)
|
||||
const resFake = () => {
|
||||
const json = vi.fn()
|
||||
const status = vi.fn().mockReturnValue({ json })
|
||||
return { res: { json, status } as unknown as Response, status, json }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||
prismaMock.cliente.update.mockResolvedValue({})
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(null)
|
||||
mondayMock.crearContacto.mockResolvedValue('cont-1')
|
||||
mondayMock.crearApartado.mockResolvedValue('ap-1')
|
||||
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
|
||||
})
|
||||
|
||||
it('el Apartado lleva contacto, precio, enganche, Fecha Compra (hoy) y Forma de Pago', async () => {
|
||||
const { crearCliente } = await import('./clientes.controller.js')
|
||||
const { res, status, json } = resFake()
|
||||
await crearCliente(reqFake(altaConVenta), res)
|
||||
expect(status).toHaveBeenCalledWith(201)
|
||||
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clienteNombre: 'Cliente Demo',
|
||||
unidadCodigo: 'U-101',
|
||||
montoApartado: 20000,
|
||||
estadoLabel: 'Pago Apartado',
|
||||
telefono: '6641112233',
|
||||
email: 'cli@correo.com',
|
||||
curp: 'CURP123',
|
||||
precioFinal: 1500000,
|
||||
enganche: 20000,
|
||||
formaPagoLabel: 'Infonavit',
|
||||
fechaCompra: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
||||
}),
|
||||
)
|
||||
expect(json.mock.calls[0]?.[0]).toMatchObject({ mondayApartadoId: 'ap-1' })
|
||||
})
|
||||
|
||||
it('con capturista vinculado a Monday, el Apartado lo lleva como Vendedor (people)', async () => {
|
||||
const { crearCliente } = await import('./clientes.controller.js')
|
||||
prismaMock.usuario.findUnique.mockResolvedValue({ mondayUserId: 12345 })
|
||||
await crearCliente(reqFake(altaConVenta, { sub: 'usr-1' }), resFake().res)
|
||||
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: 12345 }))
|
||||
})
|
||||
|
||||
it('sin capturista vinculado, el Apartado se crea sin Vendedor', async () => {
|
||||
const { crearCliente } = await import('./clientes.controller.js')
|
||||
await crearCliente(reqFake(altaConVenta), resFake().res)
|
||||
expect(mondayMock.crearApartado).toHaveBeenCalledWith(expect.objectContaining({ vendedorId: undefined }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,14 +181,55 @@ async function moverLeadAApartoSiAplica(body: Record<string, unknown>): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/** Item en el tablero Apartado (pipeline Enkontrol); solo si hay valor de venta */
|
||||
/** Label del dropdown "Forma de Pago" (tablero Apartado) a partir del enum del formato de alta */
|
||||
const FORMA_PAGO_LABEL: Record<string, string> = {
|
||||
INFONAVIT: 'Infonavit',
|
||||
APOYO_INFONAVIT: 'Apoyo Infonavit',
|
||||
COFINAVIT: 'Cofinavit',
|
||||
SEGUNDO_CREDITO: 'Segundo crédito',
|
||||
CONTADO: 'Contado',
|
||||
FOVISSSTE: 'Fovissste',
|
||||
COFI_FOVISSSTE: 'Cofi-Fovissste',
|
||||
BANCARIO: 'Bancario',
|
||||
}
|
||||
|
||||
/**
|
||||
* Label de "Forma de Pago" a partir de viviendaCredito.tiposCredito (arreglo
|
||||
* del wizard; se toma el PRIMERO reconocido — la columna del board es de una
|
||||
* sola forma de pago). undefined si no hay ninguno reconocible.
|
||||
*/
|
||||
export function formaPagoLabel(tiposCredito: unknown): string | undefined {
|
||||
if (!Array.isArray(tiposCredito)) return undefined
|
||||
for (const t of tiposCredito) {
|
||||
const label = FORMA_PAGO_LABEL[String(t).trim().toUpperCase()]
|
||||
if (label !== undefined) return label
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Item en el tablero Apartado (pipeline Enkontrol) con la ficha completa; solo si hay valor de venta */
|
||||
async function crearApartadoSiAplica(
|
||||
dg: { nombreCompleto: string },
|
||||
vc: { ubicacion?: string | undefined; valorVenta?: string | number | undefined; enganche?: string | number | undefined },
|
||||
dg: { nombreCompleto: string; celular?: string | undefined; email?: string | undefined; curp?: string | undefined },
|
||||
vc: {
|
||||
ubicacion?: string | undefined
|
||||
valorVenta?: string | number | undefined
|
||||
enganche?: string | number | undefined
|
||||
tiposCredito?: unknown
|
||||
},
|
||||
capturadoPorId: string | null,
|
||||
): Promise<string | null> {
|
||||
const valorVenta = aNumero(vc.valorVenta)
|
||||
if (valorVenta <= 0) return null
|
||||
const enganche = aNumero(vc.enganche)
|
||||
// Vendedor del apartado: el usuario Monday vinculado de quien capturó el alta
|
||||
let vendedorId: number | undefined
|
||||
if (capturadoPorId !== null) {
|
||||
const capturista = await prisma.usuario.findUnique({
|
||||
where: { id: capturadoPorId },
|
||||
select: { mondayUserId: true },
|
||||
})
|
||||
vendedorId = capturista?.mondayUserId ?? undefined
|
||||
}
|
||||
return mondayService.crearApartado({
|
||||
clienteNombre: dg.nombreCompleto,
|
||||
unidadCodigo: vc.ubicacion ?? '',
|
||||
@@ -196,6 +237,14 @@ async function crearApartadoSiAplica(
|
||||
fechaApartado: hoyMas(0),
|
||||
fechaLimite: hoyMas(15),
|
||||
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
|
||||
telefono: dg.celular,
|
||||
email: dg.email,
|
||||
curp: dg.curp,
|
||||
precioFinal: valorVenta,
|
||||
enganche: enganche > 0 ? enganche : undefined,
|
||||
fechaCompra: hoyMas(0),
|
||||
vendedorId,
|
||||
formaPagoLabel: formaPagoLabel(vc.tiposCredito),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,7 +290,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
|
||||
})
|
||||
|
||||
mondayApartadoId = await crearApartadoSiAplica(dg, vc)
|
||||
mondayApartadoId = await crearApartadoSiAplica(dg, vc, req.usuarioToken?.sub ?? null)
|
||||
|
||||
await prisma.cliente.update({
|
||||
where: { id: cliente.id },
|
||||
@@ -444,7 +493,8 @@ export async function completarProgreso(req: Request, res: Response): Promise<vo
|
||||
}
|
||||
|
||||
if (mondayApartadoId === null) {
|
||||
mondayApartadoId = await crearApartadoSiAplica(dg, vc)
|
||||
// El capturista original del alta progresiva es quien queda como Vendedor
|
||||
mondayApartadoId = await crearApartadoSiAplica(dg, vc, cliente.capturadoPorId)
|
||||
}
|
||||
|
||||
await prisma.cliente.update({
|
||||
|
||||
@@ -112,3 +112,15 @@ describe('crearCotizacion (POST /api/cotizaciones) — match con el funnel', ()
|
||||
expect(prismaMock.cotizacion.create).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('LABEL_PROPUESTA_MONDAY (códigos del cotizador → labels reales del board)', () => {
|
||||
it('los 5 códigos del enum mapean a los labels sembrados en la columna "Tipo propuesta"', async () => {
|
||||
const { LABEL_PROPUESTA_MONDAY } = await import('./cotizaciones.controller.js')
|
||||
// Labels reales verificados en Monday (difieren en guiones/espacios)
|
||||
expect(LABEL_PROPUESTA_MONDAY['BANCARIO']).toBe('CREDITO BANCARIO')
|
||||
expect(LABEL_PROPUESTA_MONDAY['INFONAVIT']).toBe('INFONAVIT-COFINAVIT')
|
||||
expect(LABEL_PROPUESTA_MONDAY['COFINAVIT']).toBe('INFONAVIT-COFINAVIT')
|
||||
expect(LABEL_PROPUESTA_MONDAY['CONTADO']).toBe('CONTADO')
|
||||
expect(LABEL_PROPUESTA_MONDAY['SEMI_CONTADO']).toBe('SEMI CONTADO')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,8 +29,10 @@ const hoyISO = (): string => {
|
||||
return `${d.getFullYear()}-${m}-${dia}`
|
||||
}
|
||||
|
||||
// Labels reales de la columna status "Tipo de Propuesta" del tablero Oportunidades
|
||||
const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
|
||||
// Labels reales de la columna status "Tipo de Propuesta" del tablero
|
||||
// Oportunidades (verificados en Monday: difieren de los códigos del cotizador
|
||||
// en guiones/espacios — el resolver case-insensitive solo no alcanza)
|
||||
export const LABEL_PROPUESTA_MONDAY: Record<string, string> = {
|
||||
BANCARIO: 'CREDITO BANCARIO',
|
||||
INFONAVIT: 'INFONAVIT-COFINAVIT',
|
||||
COFINAVIT: 'INFONAVIT-COFINAVIT',
|
||||
|
||||
Reference in New Issue
Block a user