feat(alta): guardado progresivo por etapa con réplica a Monday
- Al avanzar cada paso del wizard: upsert silencioso del borrador local + sync a Monday (iniciar → Contacto en PROSPECTO; pasos siguientes → PATCH con los campos mapeables: datos generales, dirección armada, actividad económica) - Al completar: Contacto pasa a CLIENTE + Apartado + funnel (dedup) - progresoId viaja con el borrador (se restaura solo); fallos de sync no bloquean el avance (aviso ámbar no bloqueante); submit final con fallback al endpoint legado si nunca hubo sync - API: POST /clientes/progreso/iniciar, PATCH /clientes/progreso/:id, POST /clientes/progreso/:id/completar; POST /clientes legado intacto - Tests: API 338 (+16), alta-clientes 112 (+4); smoke en vivo verificado (PROSPECTO → patch → CLIENTE + Apartado) y limpiado
This commit is contained in:
306
api-gateway/src/controllers/clientes-progreso.controller.test.ts
Normal file
306
api-gateway/src/controllers/clientes-progreso.controller.test.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { Request, Response } from 'express'
|
||||||
|
import { contactoDesdeDatos, textoDireccion } from './clientes.controller.js'
|
||||||
|
|
||||||
|
// ---------- Mocks de módulo (los tests no tocan BD ni red) ----------
|
||||||
|
|
||||||
|
const prismaMock = vi.hoisted(() => ({
|
||||||
|
cliente: {
|
||||||
|
create: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mondayMock = vi.hoisted(() => ({
|
||||||
|
crearContacto: vi.fn(),
|
||||||
|
actualizarContacto: vi.fn(),
|
||||||
|
crearApartado: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const funnelMock = vi.hoisted(() => ({
|
||||||
|
moverAApartoConDedup: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
vi.mock('../services/monday.service.js', () => ({ mondayService: mondayMock }))
|
||||||
|
vi.mock('../services/funnel.service.js', () => ({ funnelService: funnelMock }))
|
||||||
|
|
||||||
|
const reqFake = (body: unknown, params: Record<string, string> = {}): Request =>
|
||||||
|
({ body, params }) 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const datosWizard = {
|
||||||
|
datosGenerales: {
|
||||||
|
nombreCompleto: 'Ana Demo Progreso',
|
||||||
|
curp: 'AEDA900101MBCXXX00',
|
||||||
|
rfc: 'AEDA900101XX0',
|
||||||
|
email: 'ana@correo.com',
|
||||||
|
celular: '6649990003',
|
||||||
|
estadoCivil: 'SOLTERO',
|
||||||
|
tipoVivienda: 'RENTADA',
|
||||||
|
tipoCompra: 'VIVIR_AHI',
|
||||||
|
fechaNacimiento: '1990-01-01',
|
||||||
|
},
|
||||||
|
empleo: { tipoEmpleo: 'ASALARIADO' },
|
||||||
|
viviendaCredito: {
|
||||||
|
proyecto: 'DIAGONAL',
|
||||||
|
domicilioFiscal: {
|
||||||
|
calle: 'Av. Siempre Viva',
|
||||||
|
numExt: '123',
|
||||||
|
numInt: '4',
|
||||||
|
colonia: 'Col. Centro',
|
||||||
|
cp: '22000',
|
||||||
|
municipio: 'Tijuana',
|
||||||
|
estado: 'Baja California',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
prismaMock.cliente.create.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||||
|
prismaMock.cliente.update.mockImplementation(async ({ data }) => ({ id: 'cli-1', ...data }))
|
||||||
|
mondayMock.crearContacto.mockResolvedValue('cont-1')
|
||||||
|
mondayMock.actualizarContacto.mockResolvedValue(undefined)
|
||||||
|
mondayMock.crearApartado.mockResolvedValue('ap-1')
|
||||||
|
funnelMock.moverAApartoConDedup.mockResolvedValue('op-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('textoDireccion (domicilio del wizard → columna Dirección)', () => {
|
||||||
|
it('arma calle con ext/int, colonia, CP, municipio y estado', () => {
|
||||||
|
expect(
|
||||||
|
textoDireccion({
|
||||||
|
calle: 'Av. Siempre Viva',
|
||||||
|
numExt: '123',
|
||||||
|
numInt: '4',
|
||||||
|
colonia: 'Col. Centro',
|
||||||
|
cp: '22000',
|
||||||
|
municipio: 'Tijuana',
|
||||||
|
estado: 'Baja California',
|
||||||
|
}),
|
||||||
|
).toBe('Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omite las partes vacías y devuelve undefined si no hay nada', () => {
|
||||||
|
expect(textoDireccion({ calle: 'Calle Uno', municipio: 'Tijuana' })).toBe('Calle Uno, Tijuana')
|
||||||
|
expect(textoDireccion({})).toBeUndefined()
|
||||||
|
expect(textoDireccion({ calle: ' ' })).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('contactoDesdeDatos (estado del wizard → campos del Contacto)', () => {
|
||||||
|
it('deriva todos los campos mapeables presentes', () => {
|
||||||
|
const c = contactoDesdeDatos(datosWizard)
|
||||||
|
expect(c).toMatchObject({
|
||||||
|
nombre: 'Ana Demo Progreso',
|
||||||
|
curp: 'AEDA900101MBCXXX00',
|
||||||
|
rfc: 'AEDA900101XX0',
|
||||||
|
email: 'ana@correo.com',
|
||||||
|
telefono: '6649990003',
|
||||||
|
direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California',
|
||||||
|
estadoCivilLabel: 'Soltero',
|
||||||
|
viviendaActualLabel: 'Rentada',
|
||||||
|
propositoCompraLabel: 'Habitarla',
|
||||||
|
rangoEdadLabel: 'Entre 24 y 40',
|
||||||
|
fechaNacimiento: '1990-01-01',
|
||||||
|
actividadEconomicaLabel: 'Empleado',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('solo incluye campos presentes (no inventa datos)', () => {
|
||||||
|
const c = contactoDesdeDatos({ datosGenerales: { nombreCompleto: 'Solo Nombre' } })
|
||||||
|
expect(c).toEqual({ nombre: 'Solo Nombre' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin domicilioFiscal usa el texto libre datosGenerales.direccion', () => {
|
||||||
|
const c = contactoDesdeDatos({
|
||||||
|
datosGenerales: { nombreCompleto: 'Ana', direccion: 'Calle Libre 1' },
|
||||||
|
})
|
||||||
|
expect(c.direccion).toBe('Calle Libre 1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('iniciarProgreso (POST /api/clientes/progreso/iniciar)', () => {
|
||||||
|
it('crea el Cliente local y el Contacto en Monday con Estado PROSPECTO', async () => {
|
||||||
|
const { iniciarProgreso } = await import('./clientes.controller.js')
|
||||||
|
const { res, status, json } = resFake()
|
||||||
|
await iniciarProgreso(
|
||||||
|
reqFake({
|
||||||
|
datosGenerales: {
|
||||||
|
nombreCompleto: 'Ana Demo Progreso',
|
||||||
|
curp: 'AEDA900101MBCXXX00',
|
||||||
|
rfc: 'AEDA900101XX0',
|
||||||
|
celular: '6649990003',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
res,
|
||||||
|
)
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: 'cont-1' })
|
||||||
|
expect(mondayMock.crearContacto).toHaveBeenCalledWith({
|
||||||
|
nombre: 'Ana Demo Progreso',
|
||||||
|
estadoLabel: 'PROSPECTO',
|
||||||
|
curp: 'AEDA900101MBCXXX00',
|
||||||
|
rfc: 'AEDA900101XX0',
|
||||||
|
telefono: '6649990003',
|
||||||
|
})
|
||||||
|
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'cli-1' },
|
||||||
|
data: { mondayContactoId: 'cont-1' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requiere solo datosGenerales.nombreCompleto', async () => {
|
||||||
|
const { iniciarProgreso } = await import('./clientes.controller.js')
|
||||||
|
const { res, status } = resFake()
|
||||||
|
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Solo Nombre' } }), res)
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(mondayMock.crearContacto).toHaveBeenCalledWith({
|
||||||
|
nombre: 'Solo Nombre',
|
||||||
|
estadoLabel: 'PROSPECTO',
|
||||||
|
})
|
||||||
|
await expect(iniciarProgreso(reqFake({ datosGenerales: {} }), resFake().res)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fallo de Monday: crea el local igual y devuelve mondayContactoId null', async () => {
|
||||||
|
const { iniciarProgreso } = await import('./clientes.controller.js')
|
||||||
|
mondayMock.crearContacto.mockRejectedValue(new Error('Monday caído'))
|
||||||
|
const { res, status, json } = resFake()
|
||||||
|
await iniciarProgreso(reqFake({ datosGenerales: { nombreCompleto: 'Ana Demo' } }), res)
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondayContactoId: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('actualizarProgreso (PATCH /api/clientes/progreso/:id)', () => {
|
||||||
|
it('deriva los campos presentes y actualiza el Contacto (incl. Dirección armada)', async () => {
|
||||||
|
const { actualizarProgreso } = await import('./clientes.controller.js')
|
||||||
|
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
|
||||||
|
const { res, json } = resFake()
|
||||||
|
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
|
||||||
|
expect(prismaMock.cliente.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'cli-1' },
|
||||||
|
data: { payloadJson: JSON.stringify(datosWizard) },
|
||||||
|
})
|
||||||
|
expect(mondayMock.actualizarContacto).toHaveBeenCalledWith(
|
||||||
|
'cont-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
nombre: 'Ana Demo Progreso',
|
||||||
|
direccion: 'Av. Siempre Viva 123, Int. 4, Col. Centro, CP 22000, Tijuana, Baja California',
|
||||||
|
actividadEconomicaLabel: 'Empleado',
|
||||||
|
telefono: '6649990003',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin mondayContactoId responde mondaySync: false y no toca Monday', async () => {
|
||||||
|
const { actualizarProgreso } = await import('./clientes.controller.js')
|
||||||
|
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: null })
|
||||||
|
const { res, json } = resFake()
|
||||||
|
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
|
||||||
|
expect(mondayMock.actualizarContacto).not.toHaveBeenCalled()
|
||||||
|
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fallo de Monday: 200 igual con mondaySync: false', async () => {
|
||||||
|
const { actualizarProgreso } = await import('./clientes.controller.js')
|
||||||
|
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
|
||||||
|
mondayMock.actualizarContacto.mockRejectedValue(new Error('Monday caído'))
|
||||||
|
const { res, json } = resFake()
|
||||||
|
await actualizarProgreso(reqFake(datosWizard, { id: 'cli-1' }), res)
|
||||||
|
expect(json.mock.calls[0]?.[0]).toEqual({ id: 'cli-1', mondaySync: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechaza un body que no sea objeto', async () => {
|
||||||
|
const { actualizarProgreso } = await import('./clientes.controller.js')
|
||||||
|
await expect(actualizarProgreso(reqFake('no-objeto', { id: 'cli-1' }), resFake().res)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('completarProgreso (POST /api/clientes/progreso/:id/completar)', () => {
|
||||||
|
const altaCompleta = {
|
||||||
|
...datosWizard,
|
||||||
|
viviendaCredito: {
|
||||||
|
...datosWizard.viviendaCredito,
|
||||||
|
ubicacion: 'U-999-DEMO2',
|
||||||
|
valorVenta: 1500000,
|
||||||
|
enganche: 20000,
|
||||||
|
},
|
||||||
|
firma: { firmaDataUrl: 'data:image/png;base64,AAAA' },
|
||||||
|
leadMondayId: 'lead-9',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('pasa el Contacto a CLIENTE, crea el Apartado y mueve el lead a Aparto (dedup)', async () => {
|
||||||
|
const { completarProgreso } = await import('./clientes.controller.js')
|
||||||
|
const { prismaStateStore } = await import('../services/automatizaciones.service.js')
|
||||||
|
prismaMock.cliente.findUnique.mockResolvedValue({
|
||||||
|
id: 'cli-1',
|
||||||
|
mondayContactoId: 'cont-1',
|
||||||
|
mondayApartadoId: null,
|
||||||
|
})
|
||||||
|
const { res, status, json } = resFake()
|
||||||
|
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(mondayMock.actualizarContacto).toHaveBeenCalledWith(
|
||||||
|
'cont-1',
|
||||||
|
expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }),
|
||||||
|
)
|
||||||
|
expect(mondayMock.crearApartado).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
clienteNombre: 'Ana Demo Progreso',
|
||||||
|
unidadCodigo: 'U-999-DEMO2',
|
||||||
|
montoApartado: 20000,
|
||||||
|
estadoLabel: 'Pago Apartado',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(funnelMock.moverAApartoConDedup).toHaveBeenCalledWith('lead-9', prismaStateStore)
|
||||||
|
expect(json.mock.calls[0]?.[0]).toMatchObject({
|
||||||
|
id: 'cli-1',
|
||||||
|
mondayContactoId: 'cont-1',
|
||||||
|
mondayApartadoId: 'ap-1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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({
|
||||||
|
id: 'cli-1',
|
||||||
|
mondayContactoId: null,
|
||||||
|
mondayApartadoId: null,
|
||||||
|
})
|
||||||
|
const { res, status } = resFake()
|
||||||
|
await completarProgreso(reqFake(altaCompleta, { id: 'cli-1' }), res)
|
||||||
|
expect(status).toHaveBeenCalledWith(201)
|
||||||
|
expect(mondayMock.actualizarContacto).not.toHaveBeenCalled()
|
||||||
|
expect(mondayMock.crearContacto).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ nombre: 'Ana Demo Progreso', estadoLabel: 'CLIENTE' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rechaza un payload incompleto (misma validación que POST /clientes)', async () => {
|
||||||
|
const { completarProgreso } = await import('./clientes.controller.js')
|
||||||
|
prismaMock.cliente.findUnique.mockResolvedValue({ id: 'cli-1', mondayContactoId: 'cont-1' })
|
||||||
|
await expect(
|
||||||
|
completarProgreso(
|
||||||
|
reqFake({ datosGenerales: { nombreCompleto: 'Sin curp ni proyecto' } }, { id: 'cli-1' }),
|
||||||
|
resFake().res,
|
||||||
|
),
|
||||||
|
).rejects.toThrow()
|
||||||
|
expect(prismaMock.cliente.update).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('404 si el progreso no existe', async () => {
|
||||||
|
const { completarProgreso } = await import('./clientes.controller.js')
|
||||||
|
prismaMock.cliente.findUnique.mockResolvedValue(null)
|
||||||
|
await expect(
|
||||||
|
completarProgreso(reqFake(altaCompleta, { id: 'no-existe' }), resFake().res),
|
||||||
|
).rejects.toThrow('Cliente no encontrado')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto'
|
|||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { mondayService } from '../services/monday.service.js'
|
import { mondayService, type DatosContacto } from '../services/monday.service.js'
|
||||||
import { funnelService } from '../services/funnel.service.js'
|
import { funnelService } from '../services/funnel.service.js'
|
||||||
import { prismaStateStore } from '../services/automatizaciones.service.js'
|
import { prismaStateStore } from '../services/automatizaciones.service.js'
|
||||||
import { prisma } from '../services/prisma.js'
|
import { prisma } from '../services/prisma.js'
|
||||||
@@ -157,6 +157,45 @@ const hoyMas = (dias: number): string => {
|
|||||||
return `${d.getFullYear()}-${m}-${dia}`
|
return `${d.getFullYear()}-${m}-${dia}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Helpers compartidos (alta directa y alta progresiva) ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si el alta viene de un lead del funnel (la app alta-clientes manda
|
||||||
|
* leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'.
|
||||||
|
* moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
|
||||||
|
* cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
|
||||||
|
* Un fallo aquí no rompe el alta.
|
||||||
|
*/
|
||||||
|
async function moverLeadAApartoSiAplica(body: Record<string, unknown>): Promise<void> {
|
||||||
|
const leadMondayId = body['leadMondayId']
|
||||||
|
if (typeof leadMondayId === 'string' && leadMondayId !== '') {
|
||||||
|
try {
|
||||||
|
await funnelService.moverAApartoConDedup(leadMondayId, prismaStateStore)
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Item en el tablero Apartado (pipeline Enkontrol); solo si hay valor de venta */
|
||||||
|
async function crearApartadoSiAplica(
|
||||||
|
dg: { nombreCompleto: string },
|
||||||
|
vc: { ubicacion?: string | undefined; valorVenta?: string | number | undefined; enganche?: string | number | undefined },
|
||||||
|
): Promise<string | null> {
|
||||||
|
const valorVenta = aNumero(vc.valorVenta)
|
||||||
|
if (valorVenta <= 0) return null
|
||||||
|
const enganche = aNumero(vc.enganche)
|
||||||
|
return mondayService.crearApartado({
|
||||||
|
clienteNombre: dg.nombreCompleto,
|
||||||
|
unidadCodigo: vc.ubicacion ?? '',
|
||||||
|
...(enganche > 0 ? { montoApartado: enganche } : {}),
|
||||||
|
fechaApartado: hoyMas(0),
|
||||||
|
fechaLimite: hoyMas(15),
|
||||||
|
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
|
/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */
|
||||||
export async function crearCliente(req: Request, res: Response): Promise<void> {
|
export async function crearCliente(req: Request, res: Response): Promise<void> {
|
||||||
const body = crearClienteSchema.parse(req.body)
|
const body = crearClienteSchema.parse(req.body)
|
||||||
@@ -177,20 +216,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Si el alta viene de un lead del funnel (la app alta-clientes manda
|
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
|
||||||
// leadMondayId en la raíz del payload), su oportunidad pasa a 'Aparto'.
|
|
||||||
// moverAApartoConDedup escribe el dedup `apartado-creado:<opId>` ANTES del
|
|
||||||
// cambio de etapa, para que el webhook/cron no cree un Apartado duplicado.
|
|
||||||
// Un fallo aquí no rompe el alta.
|
|
||||||
const leadMondayId = (body as Record<string, unknown>)['leadMondayId']
|
|
||||||
if (typeof leadMondayId === 'string' && leadMondayId !== '') {
|
|
||||||
try {
|
|
||||||
await funnelService.moverAApartoConDedup(leadMondayId, prismaStateStore)
|
|
||||||
} catch (err) {
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.error(`No se pudo mover a 'Aparto' la oportunidad del lead ${leadMondayId}:`, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
|
// Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null
|
||||||
let mondayContactoId: string | null = null
|
let mondayContactoId: string | null = null
|
||||||
@@ -211,18 +237,7 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
|||||||
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
|
...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const valorVenta = aNumero(vc.valorVenta)
|
mondayApartadoId = await crearApartadoSiAplica(dg, vc)
|
||||||
if (valorVenta > 0) {
|
|
||||||
const enganche = aNumero(vc.enganche)
|
|
||||||
mondayApartadoId = await mondayService.crearApartado({
|
|
||||||
clienteNombre: dg.nombreCompleto,
|
|
||||||
unidadCodigo: vc.ubicacion ?? '',
|
|
||||||
...(enganche > 0 ? { montoApartado: enganche } : {}),
|
|
||||||
fechaApartado: hoyMas(0),
|
|
||||||
fechaLimite: hoyMas(15),
|
|
||||||
estadoLabel: 'Pago Apartado', // primer paso del pipeline Enkontrol
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.cliente.update({
|
await prisma.cliente.update({
|
||||||
where: { id: cliente.id },
|
where: { id: cliente.id },
|
||||||
@@ -236,6 +251,209 @@ export async function crearCliente(req: Request, res: Response): Promise<void> {
|
|||||||
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
|
res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Alta progresiva (guardado etapa por etapa del wizard) ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /progreso/iniciar — solo exige el nombre; curp/rfc/email/celular son
|
||||||
|
* opcionales porque el Contacto PROSPECTO se crea en cuanto hay nombre.
|
||||||
|
*/
|
||||||
|
export const iniciarProgresoSchema = z
|
||||||
|
.object({
|
||||||
|
datosGenerales: z
|
||||||
|
.object({
|
||||||
|
nombreCompleto: z.string().min(1),
|
||||||
|
curp: z.string().optional(),
|
||||||
|
rfc: z.string().optional(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
celular: z.string().optional(),
|
||||||
|
direccion: z.string().optional(),
|
||||||
|
fechaNacimiento: z.string().optional(),
|
||||||
|
estadoCivil: z.string().optional(),
|
||||||
|
tipoVivienda: z.string().optional(),
|
||||||
|
tipoCompra: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough(),
|
||||||
|
leadMondayId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
|
||||||
|
/** PATCH /progreso/:id — passthrough libre; solo se valida que sea un objeto */
|
||||||
|
const actualizarProgresoSchema = z.object({}).passthrough()
|
||||||
|
|
||||||
|
const seccionDe = (datos: Record<string, unknown>, clave: string): Record<string, unknown> => {
|
||||||
|
const v = datos[clave]
|
||||||
|
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textoDe = (v: unknown): string | undefined =>
|
||||||
|
typeof v === 'string' && v.trim() !== '' ? v : undefined
|
||||||
|
|
||||||
|
/** Texto de la columna Dirección del Contacto a partir del domicilio capturado */
|
||||||
|
export function textoDireccion(dom: Record<string, unknown>): string | undefined {
|
||||||
|
const t = (k: string): string => textoDe(dom[k])?.trim() ?? ''
|
||||||
|
const calleNum = [t('calle'), t('numExt')].filter((p) => p !== '').join(' ')
|
||||||
|
const numInt = t('numInt')
|
||||||
|
const linea1 = [calleNum, numInt !== '' ? `Int. ${numInt}` : ''].filter((p) => p !== '').join(', ')
|
||||||
|
const cp = t('cp')
|
||||||
|
const partes = [linea1, t('colonia'), cp !== '' ? `CP ${cp}` : '', t('municipio'), t('estado')].filter(
|
||||||
|
(p) => p !== '',
|
||||||
|
)
|
||||||
|
return partes.length > 0 ? partes.join(', ') : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deriva TODOS los campos mapeables del Contacto de Monday presentes en el
|
||||||
|
* estado del wizard (idempotente: se manda el estado completo en cada avance).
|
||||||
|
*/
|
||||||
|
export function contactoDesdeDatos(datos: Record<string, unknown>): Omit<DatosContacto, 'estadoLabel'> {
|
||||||
|
const dg = seccionDe(datos, 'datosGenerales')
|
||||||
|
const empleo = seccionDe(datos, 'empleo')
|
||||||
|
const vc = seccionDe(datos, 'viviendaCredito')
|
||||||
|
// El domicilio puede venir suelto ('domicilio') o como domicilioFiscal del paso Vivienda
|
||||||
|
const domRaiz = seccionDe(datos, 'domicilio')
|
||||||
|
const dom = Object.keys(domRaiz).length > 0 ? domRaiz : seccionDe(vc, 'domicilioFiscal')
|
||||||
|
const curp = textoDe(dg['curp'])
|
||||||
|
const rfc = textoDe(dg['rfc'])
|
||||||
|
const email = textoDe(dg['email'])
|
||||||
|
const telefono = textoDe(dg['celular'])
|
||||||
|
const direccion = textoDireccion(dom) ?? textoDe(dg['direccion'])
|
||||||
|
const actividadEconomica = actividadEconomicaLabel(textoDe(empleo['tipoEmpleo']))
|
||||||
|
return {
|
||||||
|
nombre: textoDe(dg['nombreCompleto']) ?? '',
|
||||||
|
...(curp !== undefined ? { curp } : {}),
|
||||||
|
...(rfc !== undefined ? { rfc } : {}),
|
||||||
|
...(email !== undefined ? { email } : {}),
|
||||||
|
...(telefono !== undefined ? { telefono } : {}),
|
||||||
|
...(direccion !== undefined ? { direccion } : {}),
|
||||||
|
...perfilDemografico({
|
||||||
|
estadoCivil: textoDe(dg['estadoCivil']),
|
||||||
|
tipoVivienda: textoDe(dg['tipoVivienda']),
|
||||||
|
tipoCompra: textoDe(dg['tipoCompra']),
|
||||||
|
fechaNacimiento: textoDe(dg['fechaNacimiento']),
|
||||||
|
}),
|
||||||
|
...(actividadEconomica !== undefined ? { actividadEconomicaLabel: actividadEconomica } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/clientes/progreso/iniciar — crea el alta en progreso + Contacto PROSPECTO */
|
||||||
|
export async function iniciarProgreso(req: Request, res: Response): Promise<void> {
|
||||||
|
const body = iniciarProgresoSchema.parse(req.body)
|
||||||
|
const dg = body.datosGenerales
|
||||||
|
|
||||||
|
const cliente = await prisma.cliente.create({
|
||||||
|
data: {
|
||||||
|
nombreCompleto: dg.nombreCompleto,
|
||||||
|
curp: dg.curp ?? '',
|
||||||
|
rfc: dg.rfc ?? '',
|
||||||
|
email: dg.email ?? null,
|
||||||
|
celular: dg.celular ?? null,
|
||||||
|
proyecto: '', // se define al completar el alta
|
||||||
|
payloadJson: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Contacto PROSPECTO en Monday; si falla, el progreso local ya quedó
|
||||||
|
let mondayContactoId: string | null = null
|
||||||
|
try {
|
||||||
|
mondayContactoId = await mondayService.crearContacto({
|
||||||
|
...contactoDesdeDatos(body as Record<string, unknown>),
|
||||||
|
estadoLabel: 'PROSPECTO',
|
||||||
|
})
|
||||||
|
await prisma.cliente.update({ where: { id: cliente.id }, data: { mondayContactoId } })
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('No se pudo crear el Contacto PROSPECTO en Monday:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ id: cliente.id, mondayContactoId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/clientes/progreso/:id — guarda el avance y lo replica en el Contacto */
|
||||||
|
export async function actualizarProgreso(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = String(req.params['id'])
|
||||||
|
const body = actualizarProgresoSchema.parse(req.body)
|
||||||
|
const cliente = await prisma.cliente.findUnique({ where: { id } })
|
||||||
|
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
||||||
|
|
||||||
|
await prisma.cliente.update({ where: { id }, data: { payloadJson: JSON.stringify(body) } })
|
||||||
|
|
||||||
|
// Réplica idempotente en Monday; un fallo no rompe el avance del wizard
|
||||||
|
let mondaySync = false
|
||||||
|
if (cliente.mondayContactoId !== null) {
|
||||||
|
try {
|
||||||
|
await mondayService.actualizarContacto(cliente.mondayContactoId, contactoDesdeDatos(body))
|
||||||
|
mondaySync = true
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`No se pudo actualizar el Contacto ${cliente.mondayContactoId} en Monday:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ id, mondaySync })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/clientes/progreso/:id/completar — cierra el alta iniciada con el
|
||||||
|
* guardado progresivo. Misma validación y shape de respuesta que POST /clientes.
|
||||||
|
*/
|
||||||
|
export async function completarProgreso(req: Request, res: Response): Promise<void> {
|
||||||
|
const id = String(req.params['id'])
|
||||||
|
const body = crearClienteSchema.parse(req.body)
|
||||||
|
const dg = body.datosGenerales
|
||||||
|
const vc = body.viviendaCredito
|
||||||
|
const cliente = await prisma.cliente.findUnique({ where: { id } })
|
||||||
|
if (!cliente) throw new NotFoundError('Cliente no encontrado')
|
||||||
|
|
||||||
|
const actualizado = await prisma.cliente.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
nombreCompleto: dg.nombreCompleto,
|
||||||
|
curp: dg.curp,
|
||||||
|
rfc: dg.rfc,
|
||||||
|
email: dg.email ?? null,
|
||||||
|
celular: dg.celular ?? null,
|
||||||
|
proyecto: vc.proyecto,
|
||||||
|
unidadCodigo: vc.ubicacion ?? '',
|
||||||
|
payloadJson: JSON.stringify(body),
|
||||||
|
firmaPng: body.firma?.firmaDataUrl ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await moverLeadAApartoSiAplica(body as Record<string, unknown>)
|
||||||
|
|
||||||
|
// El Contacto (creado como PROSPECTO al iniciar) pasa a CLIENTE con los
|
||||||
|
// datos finales; si no existe (nunca hubo Monday), se crea como en el alta directa
|
||||||
|
let mondayContactoId: string | null = cliente.mondayContactoId
|
||||||
|
let mondayApartadoId: string | null = cliente.mondayApartadoId
|
||||||
|
try {
|
||||||
|
if (mondayContactoId !== null) {
|
||||||
|
await mondayService.actualizarContacto(mondayContactoId, {
|
||||||
|
...contactoDesdeDatos(body as Record<string, unknown>),
|
||||||
|
estadoLabel: 'CLIENTE',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
mondayContactoId = await mondayService.crearContacto({
|
||||||
|
...contactoDesdeDatos(body as Record<string, unknown>),
|
||||||
|
estadoLabel: 'CLIENTE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mondayApartadoId === null) {
|
||||||
|
mondayApartadoId = await crearApartadoSiAplica(dg, vc)
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.cliente.update({
|
||||||
|
where: { id },
|
||||||
|
data: { mondayContactoId, mondayApartadoId },
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('No se pudo replicar el cliente en Monday:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ ...actualizado, mondayContactoId, mondayApartadoId })
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/clientes/:id */
|
/** GET /api/clientes/:id */
|
||||||
export async function obtenerCliente(req: Request, res: Response): Promise<void> {
|
export async function obtenerCliente(req: Request, res: Response): Promise<void> {
|
||||||
const cliente = await prisma.cliente.findUnique({
|
const cliente = await prisma.cliente.findUnique({
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
obtenerCotizacionPublica,
|
obtenerCotizacionPublica,
|
||||||
} from '../controllers/cotizaciones.controller.js'
|
} from '../controllers/cotizaciones.controller.js'
|
||||||
import {
|
import {
|
||||||
|
actualizarProgreso,
|
||||||
|
completarProgreso,
|
||||||
crearCliente,
|
crearCliente,
|
||||||
|
iniciarProgreso,
|
||||||
listarDocumentos,
|
listarDocumentos,
|
||||||
obtenerCliente,
|
obtenerCliente,
|
||||||
subirDocumentos,
|
subirDocumentos,
|
||||||
@@ -90,6 +93,11 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
|
|||||||
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
|
||||||
|
|
||||||
apiRouter.post('/clientes', crearCliente)
|
apiRouter.post('/clientes', crearCliente)
|
||||||
|
// Alta progresiva: guardado etapa por etapa del wizard (Contacto PROSPECTO → CLIENTE).
|
||||||
|
// Van ANTES de /clientes/:id para no colisionar con el parámetro.
|
||||||
|
apiRouter.post('/clientes/progreso/iniciar', iniciarProgreso)
|
||||||
|
apiRouter.patch('/clientes/progreso/:id', actualizarProgreso)
|
||||||
|
apiRouter.post('/clientes/progreso/:id/completar', completarProgreso)
|
||||||
// Liga remota: endpoints públicos con token propio (scope 'firma-remota'); el
|
// Liga remota: endpoints públicos con token propio (scope 'firma-remota'); el
|
||||||
// scope se verifica dentro de cada controlador. Van ANTES de /clientes/:id.
|
// scope se verifica dentro de cada controlador. Van ANTES de /clientes/:id.
|
||||||
apiRouter.get('/clientes/publico/:token', limitadorLiga, obtenerClientePublico)
|
apiRouter.get('/clientes/publico/:token', limitadorLiga, obtenerClientePublico)
|
||||||
|
|||||||
@@ -449,6 +449,39 @@ export class MondayService {
|
|||||||
return this.crearItem(boardId, datos.nombre, columnValues)
|
return this.crearItem(boardId, datos.nombre, columnValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza un Contacto existente con los campos presentes (mismo builder de
|
||||||
|
* valores que crearContacto) — usado por el guardado progresivo del alta.
|
||||||
|
* El nombre del item se actualiza por el column id especial 'name'.
|
||||||
|
*/
|
||||||
|
async actualizarContacto(itemId: string, datos: Partial<DatosContacto>): Promise<void> {
|
||||||
|
const boardId = config.monday.boards.contactos
|
||||||
|
const valores = JSON.parse(
|
||||||
|
await this.construirColumnValues(boardId, {
|
||||||
|
Teléfono: datos.telefono,
|
||||||
|
Email: datos.email,
|
||||||
|
CURP: datos.curp,
|
||||||
|
RFC: datos.rfc,
|
||||||
|
Dirección: datos.direccion,
|
||||||
|
Estado: datos.estadoLabel,
|
||||||
|
Sexo: datos.sexoLabel,
|
||||||
|
'Estado Civil': datos.estadoCivilLabel,
|
||||||
|
'Rango de Edad': datos.rangoEdadLabel,
|
||||||
|
'Actividad Economica': datos.actividadEconomicaLabel,
|
||||||
|
'Vivienda Actual': datos.viviendaActualLabel,
|
||||||
|
'Proposito de Compra': datos.propositoCompraLabel,
|
||||||
|
'Fecha Nacimiento': datos.fechaNacimiento,
|
||||||
|
}),
|
||||||
|
) as Record<string, unknown>
|
||||||
|
if (datos.nombre !== undefined && datos.nombre !== '') valores['name'] = datos.nombre
|
||||||
|
await this.graphql(
|
||||||
|
`mutation ($boardId: ID!, $itemId: ID!, $columnValues: JSON!) {
|
||||||
|
change_multiple_column_values(board_id: $boardId, item_id: $itemId, column_values: $columnValues) { id }
|
||||||
|
}`,
|
||||||
|
{ boardId, itemId, columnValues: JSON.stringify(valores) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async crearApartado(datos: DatosApartado): Promise<string> {
|
async crearApartado(datos: DatosApartado): Promise<string> {
|
||||||
const boardId = config.monday.boards.apartado
|
const boardId = config.monday.boards.apartado
|
||||||
const columnValues = await this.construirColumnValues(boardId, {
|
const columnValues = await this.construirColumnValues(boardId, {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const economicosVacios = (): AltaCliente['economicos'] => ({
|
|||||||
|
|
||||||
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
|
||||||
leadMondayId: null,
|
leadMondayId: null,
|
||||||
|
progresoId: null,
|
||||||
datosGenerales: datosGeneralesVacios(),
|
datosGenerales: datosGeneralesVacios(),
|
||||||
empleo: empleoVacio(),
|
empleo: empleoVacio(),
|
||||||
economicos: economicosVacios(),
|
economicos: economicosVacios(),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const FIRMA_PRUEBA =
|
|||||||
*/
|
*/
|
||||||
export const fixtureAlta: AltaCliente = {
|
export const fixtureAlta: AltaCliente = {
|
||||||
leadMondayId: null,
|
leadMondayId: null,
|
||||||
|
progresoId: null,
|
||||||
datosGenerales: {
|
datosGenerales: {
|
||||||
campaniaVenta: '',
|
campaniaVenta: '',
|
||||||
fecha: '2026-07-19',
|
fecha: '2026-07-19',
|
||||||
|
|||||||
151
apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx
Normal file
151
apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import AltaClientePage from './AltaClientePage'
|
||||||
|
|
||||||
|
// El lienzo de firma necesita canvas 2d (no existe en jsdom); el paso 7 se
|
||||||
|
// prueba solo hasta la orquestación de la página, no el trazo de la firma.
|
||||||
|
vi.mock('../forms/FirmasForm', () => ({ default: () => <div>Paso de firmas</div> }))
|
||||||
|
|
||||||
|
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
||||||
|
|
||||||
|
const respuesta = (data: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
|
||||||
|
|
||||||
|
const llamadasA = (fragmento: string, metodo?: string) =>
|
||||||
|
fetchMock.mock.calls.filter(
|
||||||
|
([input, init]) => String(input).includes(fragmento) && (metodo === undefined || (init?.method ?? 'GET') === metodo),
|
||||||
|
)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn<FetchFn>().mockImplementation((input, init) => {
|
||||||
|
const url = String(input)
|
||||||
|
const metodo = init?.method ?? 'GET'
|
||||||
|
if (url.includes('/borradores') && metodo === 'GET') return Promise.resolve(respuesta([]))
|
||||||
|
if (url.includes('/borradores') && metodo === 'POST') {
|
||||||
|
return Promise.resolve(respuesta({ id: 'b-1', nombre: 'B', paso: 1, datosJson: '{}' }), )
|
||||||
|
}
|
||||||
|
if (url.includes('/borradores') && metodo === 'DELETE') return Promise.resolve(respuesta({}))
|
||||||
|
if (url.endsWith('/clientes/progreso/iniciar')) {
|
||||||
|
return Promise.resolve(respuesta({ id: 'prog-1', mondayContactoId: 'cont-1' }, 201))
|
||||||
|
}
|
||||||
|
if (url.includes('/clientes/progreso/prog-1/completar')) return Promise.resolve(respuesta({ id: 'prog-1' }, 201))
|
||||||
|
if (url.includes('/clientes/progreso/prog-1') && metodo === 'PATCH') {
|
||||||
|
return Promise.resolve(respuesta({ id: 'prog-1', mondaySync: true }))
|
||||||
|
}
|
||||||
|
if (url.endsWith('/clientes') && metodo === 'POST') return Promise.resolve(respuesta({ id: 'cli-directo' }, 201))
|
||||||
|
return Promise.reject(new Error(`fetch inesperado: ${metodo} ${url}`))
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
const entrarAlWizard = async () => {
|
||||||
|
render(<AltaClientePage />)
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Alta de Cliente/ }))
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Empezar nuevo' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const llenarPaso0 = async () => {
|
||||||
|
fireEvent.change(await screen.findByLabelText(/Nombre completo/), { target: { value: 'Ana Demo Progreso' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/^CURP/), { target: { value: 'AEDA900101MBCXXX00' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/^RFC/), { target: { value: 'AEDA900101XX0' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/^Email/), { target: { value: 'ana@correo.com' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/Celular/), { target: { value: '6649990003' } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/Fecha de nacimiento/), { target: { value: '1990-01-01' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const avanzar = async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
|
||||||
|
await act(async () => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AltaClientePage — guardado progresivo (etapa por etapa)', () => {
|
||||||
|
it('al avanzar del paso 0 con nombre inicia el progreso (Contacto PROSPECTO)', async () => {
|
||||||
|
await entrarAlWizard()
|
||||||
|
await llenarPaso0()
|
||||||
|
await avanzar()
|
||||||
|
|
||||||
|
// Avanzó al paso Empleo y subió el inicio del progreso con datosGenerales
|
||||||
|
expect(await screen.findByLabelText(/Tipo de empleo/)).toBeTruthy()
|
||||||
|
await waitFor(() => expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1))
|
||||||
|
const cuerpo = JSON.parse(String(llamadasA('/clientes/progreso/iniciar', 'POST')[0]?.[1]?.body)) as {
|
||||||
|
datosGenerales: { nombreCompleto: string }
|
||||||
|
}
|
||||||
|
expect(cuerpo.datosGenerales.nombreCompleto).toBe('Ana Demo Progreso')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('los pasos siguientes hacen PATCH del progreso ya iniciado', async () => {
|
||||||
|
await entrarAlWizard()
|
||||||
|
await llenarPaso0()
|
||||||
|
await avanzar()
|
||||||
|
await screen.findByLabelText(/Tipo de empleo/)
|
||||||
|
await waitFor(() => expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1))
|
||||||
|
|
||||||
|
await avanzar() // Empleo → Económicos
|
||||||
|
await waitFor(() => expect(llamadasA('/clientes/progreso/prog-1', 'PATCH')).toHaveLength(1))
|
||||||
|
// No se duplica el inicio
|
||||||
|
expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('si el sync falla, el avance no se bloquea y solo queda un aviso', async () => {
|
||||||
|
fetchMock.mockImplementation((input, init) => {
|
||||||
|
const url = String(input)
|
||||||
|
const metodo = init?.method ?? 'GET'
|
||||||
|
if (url.includes('/borradores') && metodo === 'GET') return Promise.resolve(respuesta([]))
|
||||||
|
if (url.includes('/borradores') && metodo === 'POST') {
|
||||||
|
return Promise.resolve(respuesta({ id: 'b-1', nombre: 'B', paso: 1, datosJson: '{}' }))
|
||||||
|
}
|
||||||
|
if (url.endsWith('/clientes/progreso/iniciar')) return Promise.reject(new Error('API caída'))
|
||||||
|
return Promise.reject(new Error(`fetch inesperado: ${metodo} ${url}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
await entrarAlWizard()
|
||||||
|
await llenarPaso0()
|
||||||
|
await avanzar()
|
||||||
|
|
||||||
|
// Avanzó igual al paso Empleo y solo queda el aviso sutil
|
||||||
|
expect(await screen.findByLabelText(/Tipo de empleo/)).toBeTruthy()
|
||||||
|
await waitFor(() => {
|
||||||
|
const avisos = screen.getAllByRole('status').map((el) => el.textContent ?? '')
|
||||||
|
expect(avisos.some((a) => a.includes('El avance quedó guardado local'))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('el submit final usa el endpoint progresivo cuando hay progresoId', async () => {
|
||||||
|
await entrarAlWizard()
|
||||||
|
await llenarPaso0()
|
||||||
|
await avanzar()
|
||||||
|
await screen.findByLabelText(/Tipo de empleo/)
|
||||||
|
await waitFor(() => expect(llamadasA('/clientes/progreso/iniciar', 'POST')).toHaveLength(1))
|
||||||
|
|
||||||
|
await avanzar() // Empleo → Económicos
|
||||||
|
await screen.findByLabelText(/Ingresos mensuales/)
|
||||||
|
await avanzar() // Económicos → Co-acreditado
|
||||||
|
await screen.findByLabelText(/co-acreditado o cónyuge/)
|
||||||
|
await avanzar() // Co-acreditado → Referencias
|
||||||
|
// Referencias exige al menos 1 con nombre y teléfono
|
||||||
|
fireEvent.change((await screen.findAllByLabelText(/^Nombre completo/))[0]!, { target: { value: 'Ref Uno' } })
|
||||||
|
fireEvent.change(screen.getAllByLabelText(/^Teléfono/)[0]!, { target: { value: '6641112233' } })
|
||||||
|
await avanzar() // Referencias → Vivienda y crédito
|
||||||
|
fireEvent.change(await screen.findByLabelText(/^Proyecto/), { target: { value: 'DIAGONAL' } })
|
||||||
|
await avanzar() // Vivienda → Documentos
|
||||||
|
|
||||||
|
// Documentos → Firmas: dispara el submit final del alta
|
||||||
|
await avanzar()
|
||||||
|
await waitFor(() => expect(llamadasA('/clientes/progreso/prog-1/completar', 'POST')).toHaveLength(1))
|
||||||
|
// NO se usó el endpoint legado
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.filter(
|
||||||
|
([input, init]) => String(input).endsWith('/clientes') && (init?.method ?? '') === 'POST',
|
||||||
|
),
|
||||||
|
).toHaveLength(0)
|
||||||
|
// El flujo posterior queda idéntico: alta registrada con el id del progreso
|
||||||
|
expect(await screen.findByText(/Alta registrada en el servidor: prog-1/)).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Stepper from '../components/Stepper'
|
import Stepper from '../components/Stepper'
|
||||||
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
|
||||||
import EmpleoForm from '../forms/EmpleoForm'
|
import EmpleoForm from '../forms/EmpleoForm'
|
||||||
@@ -10,10 +10,13 @@ import DocumentosForm from '../forms/DocumentosForm'
|
|||||||
import FirmasForm from '../forms/FirmasForm'
|
import FirmasForm from '../forms/FirmasForm'
|
||||||
import { useAltaCliente, PASOS } from '../hooks/useAltaCliente'
|
import { useAltaCliente, PASOS } from '../hooks/useAltaCliente'
|
||||||
import {
|
import {
|
||||||
|
actualizarProgreso,
|
||||||
|
completarProgreso,
|
||||||
crearAltaCliente,
|
crearAltaCliente,
|
||||||
eliminarBorrador,
|
eliminarBorrador,
|
||||||
generarLigaRemota,
|
generarLigaRemota,
|
||||||
guardarBorrador,
|
guardarBorrador,
|
||||||
|
iniciarProgreso,
|
||||||
listarBorradores,
|
listarBorradores,
|
||||||
obtenerBorrador,
|
obtenerBorrador,
|
||||||
obtenerCotizacion,
|
obtenerCotizacion,
|
||||||
@@ -61,6 +64,7 @@ export default function AltaClientePage() {
|
|||||||
const [borradores, setBorradores] = useState<BorradorResumen[]>([])
|
const [borradores, setBorradores] = useState<BorradorResumen[]>([])
|
||||||
const [borradorId, setBorradorId] = useState<string | null>(null)
|
const [borradorId, setBorradorId] = useState<string | null>(null)
|
||||||
const [avisoBorrador, setAvisoBorrador] = useState('')
|
const [avisoBorrador, setAvisoBorrador] = useState('')
|
||||||
|
const [avisoProgreso, setAvisoProgreso] = useState('')
|
||||||
const [cargoDeBorrador, setCargoDeBorrador] = useState(false)
|
const [cargoDeBorrador, setCargoDeBorrador] = useState(false)
|
||||||
const [errorCarga, setErrorCarga] = useState('')
|
const [errorCarga, setErrorCarga] = useState('')
|
||||||
|
|
||||||
@@ -147,10 +151,54 @@ export default function AltaClientePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Guardado progresivo (borrador local + Contacto en Monday) ----------
|
||||||
|
// Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente
|
||||||
|
// avance manda el estado completo (el PATCH es idempotente).
|
||||||
|
const syncEnCurso = useRef(false)
|
||||||
|
|
||||||
|
const syncProgreso = async (datosSync: typeof datos) => {
|
||||||
|
if (syncEnCurso.current) return
|
||||||
|
if (datosSync.datosGenerales.nombreCompleto.trim() === '') return
|
||||||
|
syncEnCurso.current = true
|
||||||
|
try {
|
||||||
|
if (datosSync.progresoId) {
|
||||||
|
await actualizarProgreso(datosSync.progresoId, datosSync)
|
||||||
|
} else {
|
||||||
|
const r = await iniciarProgreso({
|
||||||
|
datosGenerales: datosSync.datosGenerales,
|
||||||
|
...(datosSync.leadMondayId ? { leadMondayId: datosSync.leadMondayId } : {}),
|
||||||
|
})
|
||||||
|
actualizar('progresoId', r.id)
|
||||||
|
}
|
||||||
|
setAvisoProgreso('')
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[alta] no se pudo sincronizar el avance con el servidor', err)
|
||||||
|
setAvisoProgreso('El avance quedó guardado local; no se pudo subir a Monday')
|
||||||
|
} finally {
|
||||||
|
syncEnCurso.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Al avanzar de paso: guarda el borrador en silencio y sube el avance a Monday */
|
||||||
|
const guardarAvance = (datosNuevos: typeof datos, pasoDestino: number) => {
|
||||||
|
guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos: datosNuevos })
|
||||||
|
.then((b) => {
|
||||||
|
setBorradorId(b.id)
|
||||||
|
setAvisoBorrador(
|
||||||
|
`Borrador guardado ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => setAvisoBorrador('No se pudo guardar el borrador'))
|
||||||
|
void syncProgreso(datosNuevos)
|
||||||
|
}
|
||||||
|
|
||||||
// Registra el alta en el API al llegar al paso de firmas; si el API no
|
// Registra el alta en el API al llegar al paso de firmas; si el API no
|
||||||
// responde, el flujo continúa solo local (los PDFs se generan igual)
|
// responde, el flujo continúa solo local (los PDFs se generan igual). Con
|
||||||
|
// guardado progresivo activo se cierra por el endpoint de progreso.
|
||||||
const registrarAlta = async () => {
|
const registrarAlta = async () => {
|
||||||
const res = await crearAltaCliente(alta)
|
const res = datos.progresoId
|
||||||
|
? await completarProgreso(datos.progresoId, alta)
|
||||||
|
: await crearAltaCliente(alta)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
await subirDocumentos(res.id, alta)
|
await subirDocumentos(res.id, alta)
|
||||||
setRegistro(res.id)
|
setRegistro(res.id)
|
||||||
@@ -337,6 +385,11 @@ export default function AltaClientePage() {
|
|||||||
{avisoBorrador}
|
{avisoBorrador}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{avisoProgreso && (
|
||||||
|
<span role="status" className="text-xs font-light text-amber-700">
|
||||||
|
{avisoProgreso}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void guardar()}
|
onClick={() => void guardar()}
|
||||||
@@ -364,6 +417,7 @@ export default function AltaClientePage() {
|
|||||||
onSiguiente={(v) => {
|
onSiguiente={(v) => {
|
||||||
actualizar('datosGenerales', v)
|
actualizar('datosGenerales', v)
|
||||||
siguiente()
|
siguiente()
|
||||||
|
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -374,6 +428,7 @@ export default function AltaClientePage() {
|
|||||||
onSiguiente={(v) => {
|
onSiguiente={(v) => {
|
||||||
actualizar('empleo', v)
|
actualizar('empleo', v)
|
||||||
siguiente()
|
siguiente()
|
||||||
|
guardarAvance({ ...datos, empleo: v }, paso + 1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -384,6 +439,7 @@ export default function AltaClientePage() {
|
|||||||
onSiguiente={(v) => {
|
onSiguiente={(v) => {
|
||||||
actualizar('economicos', v)
|
actualizar('economicos', v)
|
||||||
siguiente()
|
siguiente()
|
||||||
|
guardarAvance({ ...datos, economicos: v }, paso + 1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -394,6 +450,7 @@ export default function AltaClientePage() {
|
|||||||
onSiguiente={(v) => {
|
onSiguiente={(v) => {
|
||||||
actualizar('coacreditado', v)
|
actualizar('coacreditado', v)
|
||||||
siguiente()
|
siguiente()
|
||||||
|
guardarAvance({ ...datos, coacreditado: v }, paso + 1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -404,6 +461,7 @@ export default function AltaClientePage() {
|
|||||||
onSiguiente={(v) => {
|
onSiguiente={(v) => {
|
||||||
actualizar('referencias', v)
|
actualizar('referencias', v)
|
||||||
siguiente()
|
siguiente()
|
||||||
|
guardarAvance({ ...datos, referencias: v }, paso + 1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -414,6 +472,7 @@ export default function AltaClientePage() {
|
|||||||
onSiguiente={(v) => {
|
onSiguiente={(v) => {
|
||||||
actualizar('viviendaCredito', v)
|
actualizar('viviendaCredito', v)
|
||||||
siguiente()
|
siguiente()
|
||||||
|
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -440,6 +499,7 @@ export default function AltaClientePage() {
|
|||||||
onSiguiente={() => {
|
onSiguiente={() => {
|
||||||
void registrarAlta()
|
void registrarAlta()
|
||||||
siguiente()
|
siguiente()
|
||||||
|
guardarAvance(datos, paso + 1)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -32,16 +32,16 @@ const serializarAlta = (payload: AltaCliente) => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
* POST JSON del alta contra `ruta` (`/clientes` o `/clientes/progreso/:id/completar`).
|
||||||
* SQLite y replica en Monday. Si el API no responde devuelve motivo 'conexion'
|
* Si el API no responde devuelve motivo 'conexion' y el flujo continúa solo
|
||||||
* y el flujo continúa solo local: los PDFs se generan igual. Si el API rechaza
|
* local: los PDFs se generan igual. Si el API rechaza por validación (400)
|
||||||
* por validación (400) devuelve motivo 'validacion' con el detalle.
|
* devuelve motivo 'validacion' con el detalle.
|
||||||
*/
|
*/
|
||||||
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
async function enviarAlta(ruta: string, payload: AltaCliente): Promise<ResultadoAlta> {
|
||||||
let res: Response
|
let res: Response
|
||||||
try {
|
try {
|
||||||
res = await fetch(
|
res = await fetch(
|
||||||
`${API_URL}/clientes`,
|
`${API_URL}${ruta}`,
|
||||||
conAuth({
|
conAuth({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -49,7 +49,7 @@ export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoA
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[api] crearAltaCliente: sin conexión al servidor, el expediente se genera solo localmente', err)
|
console.warn('[api] alta: sin conexión al servidor, el expediente se genera solo localmente', err)
|
||||||
return { ok: false, motivo: 'conexion' }
|
return { ok: false, motivo: 'conexion' }
|
||||||
}
|
}
|
||||||
manejar401(res)
|
manejar401(res)
|
||||||
@@ -63,13 +63,65 @@ export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoA
|
|||||||
issues?: { path: (string | number)[]; message: string }[]
|
issues?: { path: (string | number)[]; message: string }[]
|
||||||
} | null
|
} | null
|
||||||
const detalle = cuerpo?.issues?.map((i) => `${i.path.join('.')}: ${i.message}`).join(' · ')
|
const detalle = cuerpo?.issues?.map((i) => `${i.path.join('.')}: ${i.message}`).join(' · ')
|
||||||
console.warn('[api] crearAltaCliente: validación rechazada', detalle)
|
console.warn('[api] alta: validación rechazada', detalle)
|
||||||
return { ok: false, motivo: 'validacion', detalle: detalle ?? 'Datos incompletos o inválidos' }
|
return { ok: false, motivo: 'validacion', detalle: detalle ?? 'Datos incompletos o inválidos' }
|
||||||
}
|
}
|
||||||
console.warn(`[api] crearAltaCliente: HTTP ${res.status}`)
|
console.warn(`[api] alta: HTTP ${res.status}`)
|
||||||
return { ok: false, motivo: 'conexion' }
|
return { ok: false, motivo: 'conexion' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
|
||||||
|
* SQLite y replica en Monday.
|
||||||
|
*/
|
||||||
|
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
|
||||||
|
return enviarAlta('/clientes', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Progreso del alta etapa por etapa (auth) ----------
|
||||||
|
|
||||||
|
export interface ProgresoIniciado {
|
||||||
|
id: string
|
||||||
|
mondayContactoId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /clientes/progreso/iniciar — crea el alta en progreso (Contacto PROSPECTO en Monday) */
|
||||||
|
export async function iniciarProgreso(payload: {
|
||||||
|
datosGenerales: unknown
|
||||||
|
leadMondayId?: string
|
||||||
|
}): Promise<ProgresoIniciado> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/clientes/progreso/iniciar`,
|
||||||
|
conAuth({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return (await res.json()) as ProgresoIniciado
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /clientes/progreso/:id — guarda el avance del wizard y lo replica en el Contacto */
|
||||||
|
export async function actualizarProgreso(id: string, datos: unknown): Promise<void> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/clientes/progreso/${id}`,
|
||||||
|
conAuth({
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(datos),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
manejar401(res)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /clientes/progreso/:id/completar — cierra el alta (mismo contrato que POST /clientes) */
|
||||||
|
export async function completarProgreso(id: string, payload: AltaCliente): Promise<ResultadoAlta> {
|
||||||
|
return enviarAlta(`/clientes/progreso/${id}/completar`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sube los documentos reales del expediente (POST multipart
|
* Sube los documentos reales del expediente (POST multipart
|
||||||
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
|
* /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ export interface AltaCliente {
|
|||||||
// ID del item del tablero Leads de Monday cuando el alta se prellenó desde
|
// ID del item del tablero Leads de Monday cuando el alta se prellenó desde
|
||||||
// un lead (null = captura manual). Passthrough hasta payloadJson del API.
|
// un lead (null = captura manual). Passthrough hasta payloadJson del API.
|
||||||
leadMondayId: string | null
|
leadMondayId: string | null
|
||||||
|
// ID del Cliente creado por el guardado progresivo (POST /clientes/progreso/
|
||||||
|
// iniciar); viaja con el borrador (datosJson) y no se captura en el formato.
|
||||||
|
progresoId: string | null
|
||||||
datosGenerales: DatosGenerales
|
datosGenerales: DatosGenerales
|
||||||
empleo: DatosEmpleo
|
empleo: DatosEmpleo
|
||||||
economicos: DatosEconomicos
|
economicos: DatosEconomicos
|
||||||
|
|||||||
@@ -34,11 +34,14 @@
|
|||||||
## 4. Alta de clientes — `/client/` (6 min)
|
## 4. Alta de clientes — `/client/` (6 min)
|
||||||
|
|
||||||
1. Iniciar un alta de prueba (usar datos ficticios, ej. "Cliente Demo").
|
1. Iniciar un alta de prueba (usar datos ficticios, ej. "Cliente Demo").
|
||||||
|
- El alta se guarda **etapa por etapa**: al avanzar del primer paso se crea el
|
||||||
|
**Contacto en Monday con Estado `PROSPECTO`** y cada paso actualiza sus
|
||||||
|
columnas (se puede mostrar en Monday cómo va llenándose).
|
||||||
2. Mostrar: **validación de CURP** (escribir una mal formada → error), secciones del formulario, co-acreditado condicional.
|
2. Mostrar: **validación de CURP** (escribir una mal formada → error), secciones del formulario, co-acreditado condicional.
|
||||||
3. Subir un PDF cualquiera como documento → mostrar **OCR**, **vista previa** y el **checklist** del expediente.
|
3. Subir un PDF cualquiera como documento → mostrar **OCR**, **vista previa** y el **checklist** del expediente.
|
||||||
4. **Firma digital** en pantalla.
|
4. **Firma digital** en pantalla.
|
||||||
5. Guardar → ir a Monday y mostrar:
|
5. Guardar → ir a Monday y mostrar:
|
||||||
- El **Contacto** creado con su perfil demográfico.
|
- El **Contacto** (creado como `PROSPECTO` al avanzar del paso 1) ahora con Estado `CLIENTE` y su perfil demográfico.
|
||||||
- El item en **Apartado** con estado `Pago Apartado` y el **checkbox del documento** ya prendido.
|
- El item en **Apartado** con estado `Pago Apartado` y el **checkbox del documento** ya prendido.
|
||||||
|
|
||||||
## 5. Cobranza — `/pay/` (5 min)
|
## 5. Cobranza — `/pay/` (5 min)
|
||||||
|
|||||||
Reference in New Issue
Block a user