diff --git a/api-gateway/src/controllers/clientes-progreso.controller.test.ts b/api-gateway/src/controllers/clientes-progreso.controller.test.ts new file mode 100644 index 0000000..58501e9 --- /dev/null +++ b/api-gateway/src/controllers/clientes-progreso.controller.test.ts @@ -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 = {}): 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') + }) +}) diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts index c78bb49..e92dbea 100644 --- a/api-gateway/src/controllers/clientes.controller.ts +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto' import multer from 'multer' import { z } from 'zod' 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 { prismaStateStore } from '../services/automatizaciones.service.js' import { prisma } from '../services/prisma.js' @@ -157,6 +157,45 @@ const hoyMas = (dias: number): string => { 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:` 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): Promise { + 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 { + 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) */ export async function crearCliente(req: Request, res: Response): Promise { const body = crearClienteSchema.parse(req.body) @@ -177,20 +216,7 @@ export async function crearCliente(req: Request, res: Response): Promise { }, }) - // 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:` 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)['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) - } - } + await moverLeadAApartoSiAplica(body as Record) // Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null let mondayContactoId: string | null = null @@ -211,18 +237,7 @@ export async function crearCliente(req: Request, res: Response): Promise { ...{ actividadEconomicaLabel: actividadEconomicaLabel(body.empleo?.tipoEmpleo) }, }) - const valorVenta = aNumero(vc.valorVenta) - 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 - }) - } + mondayApartadoId = await crearApartadoSiAplica(dg, vc) await prisma.cliente.update({ where: { id: cliente.id }, @@ -236,6 +251,209 @@ export async function crearCliente(req: Request, res: Response): Promise { 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, clave: string): Record => { + const v = datos[clave] + return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record) : {} +} + +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 | 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): Omit { + 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 { + 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), + 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 { + 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 { + 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) + + // 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), + estadoLabel: 'CLIENTE', + }) + } else { + mondayContactoId = await mondayService.crearContacto({ + ...contactoDesdeDatos(body as Record), + 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 */ export async function obtenerCliente(req: Request, res: Response): Promise { const cliente = await prisma.cliente.findUnique({ diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 2909789..8d1321c 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -10,7 +10,10 @@ import { obtenerCotizacionPublica, } from '../controllers/cotizaciones.controller.js' import { + actualizarProgreso, + completarProgreso, crearCliente, + iniciarProgreso, listarDocumentos, obtenerCliente, subirDocumentos, @@ -90,6 +93,11 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica) apiRouter.get('/cotizaciones/:id', obtenerCotizacion) 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 // scope se verifica dentro de cada controlador. Van ANTES de /clientes/:id. apiRouter.get('/clientes/publico/:token', limitadorLiga, obtenerClientePublico) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts index 043f514..017ad93 100644 --- a/api-gateway/src/services/monday.service.ts +++ b/api-gateway/src/services/monday.service.ts @@ -449,6 +449,39 @@ export class MondayService { 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): Promise { + 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 + 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 { const boardId = config.monday.boards.apartado const columnValues = await this.construirColumnValues(boardId, { diff --git a/apps/alta-clientes/src/hooks/useAltaCliente.ts b/apps/alta-clientes/src/hooks/useAltaCliente.ts index da269e9..3962f9e 100644 --- a/apps/alta-clientes/src/hooks/useAltaCliente.ts +++ b/apps/alta-clientes/src/hooks/useAltaCliente.ts @@ -58,6 +58,7 @@ const economicosVacios = (): AltaCliente['economicos'] => ({ const estadoInicial = (): Omit => ({ leadMondayId: null, + progresoId: null, datosGenerales: datosGeneralesVacios(), empleo: empleoVacio(), economicos: economicosVacios(), diff --git a/apps/alta-clientes/src/lib/fixture.ts b/apps/alta-clientes/src/lib/fixture.ts index 2590217..140738a 100644 --- a/apps/alta-clientes/src/lib/fixture.ts +++ b/apps/alta-clientes/src/lib/fixture.ts @@ -10,6 +10,7 @@ export const FIRMA_PRUEBA = */ export const fixtureAlta: AltaCliente = { leadMondayId: null, + progresoId: null, datosGenerales: { campaniaVenta: '', fecha: '2026-07-19', diff --git a/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx b/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx new file mode 100644 index 0000000..c40e8cb --- /dev/null +++ b/apps/alta-clientes/src/pages/AltaClientePage.progreso.test.tsx @@ -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: () =>
Paso de firmas
})) + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise + +const respuesta = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }) + +let fetchMock: ReturnType> + +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().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() + 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() + }) +}) diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index bc02789..7dc04a7 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import Stepper from '../components/Stepper' import DatosGeneralesForm from '../forms/DatosGeneralesForm' import EmpleoForm from '../forms/EmpleoForm' @@ -10,10 +10,13 @@ import DocumentosForm from '../forms/DocumentosForm' import FirmasForm from '../forms/FirmasForm' import { useAltaCliente, PASOS } from '../hooks/useAltaCliente' import { + actualizarProgreso, + completarProgreso, crearAltaCliente, eliminarBorrador, generarLigaRemota, guardarBorrador, + iniciarProgreso, listarBorradores, obtenerBorrador, obtenerCotizacion, @@ -61,6 +64,7 @@ export default function AltaClientePage() { const [borradores, setBorradores] = useState([]) const [borradorId, setBorradorId] = useState(null) const [avisoBorrador, setAvisoBorrador] = useState('') + const [avisoProgreso, setAvisoProgreso] = useState('') const [cargoDeBorrador, setCargoDeBorrador] = useState(false) 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 - // 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 res = await crearAltaCliente(alta) + const res = datos.progresoId + ? await completarProgreso(datos.progresoId, alta) + : await crearAltaCliente(alta) if (res.ok) { await subirDocumentos(res.id, alta) setRegistro(res.id) @@ -337,6 +385,11 @@ export default function AltaClientePage() { {avisoBorrador} )} + {avisoProgreso && ( + + {avisoProgreso} + + )}