From 1c1e6b04e4c6becab05b6810adf344ecdddc1321 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Tue, 29 Sep 2026 19:42:42 +0000 Subject: [PATCH] fix(admin): reasignar mueve toda la cadena del cliente (lead+oportunidad+apartado) y sus actividades pendientes; 404 si el id no existe --- .../src/controllers/leads.controller.test.ts | 21 ++++++- .../src/controllers/leads.controller.ts | 14 ++--- .../src/services/crm-local.service.test.ts | 57 +++++++++++++++++++ api-gateway/src/services/crm-local.service.ts | 56 ++++++++++++++++++ 4 files changed, 137 insertions(+), 11 deletions(-) diff --git a/api-gateway/src/controllers/leads.controller.test.ts b/api-gateway/src/controllers/leads.controller.test.ts index f89e1e1..98fcfda 100644 --- a/api-gateway/src/controllers/leads.controller.test.ts +++ b/api-gateway/src/controllers/leads.controller.test.ts @@ -34,6 +34,7 @@ const prismaMock = vi.hoisted(() => ({ findMany: vi.fn(), }, automatizacionState: { + findFirst: vi.fn(), findUnique: vi.fn(), upsert: vi.fn(), }, @@ -83,6 +84,7 @@ beforeEach(() => { prismaMock.usuario.findUnique.mockResolvedValue(null) prismaMock.cliente.findMany.mockResolvedValue([]) prismaMock.automatizacionState.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.findFirst.mockResolvedValue(null) prismaMock.automatizacionState.upsert.mockResolvedValue({}) prismaMock.$transaction.mockImplementation(async (ops: Promise[]) => Promise.all(ops)) }) @@ -449,17 +451,30 @@ describe('admin CRUD de leads — local', () => { expect(json).toHaveBeenCalledWith({ eliminado: true, itemId: 'loc_1' }) }) - it('asignarLeadEndpoint reasigna el lead localmente y responde con el nombre', async () => { + it('asignarLeadEndpoint reasigna la cadena completa y responde con el nombre', async () => { prismaMock.usuario.findUnique.mockResolvedValue({ nombre: 'Martin Zavala' }) + prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'LEADS', oportunidadId: null }) + prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 1 }) const { res, json } = resFake() await asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { usuarioId: 'usr-9' } }), res) expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ - where: { leadId: 'loc_1' }, + where: { leadId: { in: ['loc_1'] } }, data: { usuarioId: 'usr-9' }, }) - expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', usuarioId: 'usr-9', nombre: 'Martin Zavala' }) + expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', usuarioId: 'usr-9', nombre: 'Martin Zavala', movidas: 1 }) + }) + + it('asignarLeadEndpoint con lead inexistente → 404 (ya no es exito silencioso)', async () => { + prismaMock.usuario.findUnique.mockResolvedValue({ nombre: 'Martin Zavala' }) + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + const { res } = resFake() + + await expect( + asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_fantasma' }, body: { usuarioId: 'usr-9' } }), res), + ).rejects.toThrow('Lead no encontrado') + expect(prismaMock.leadCaptura.updateMany).not.toHaveBeenCalled() }) it('asignarLeadEndpoint con usuario inexistente → 404', async () => { diff --git a/api-gateway/src/controllers/leads.controller.ts b/api-gateway/src/controllers/leads.controller.ts index 14cb848..de10c65 100644 --- a/api-gateway/src/controllers/leads.controller.ts +++ b/api-gateway/src/controllers/leads.controller.ts @@ -340,7 +340,9 @@ const asignarLeadSchema = z.object({ usuarioId: z.string().trim().min(1, 'usuarioId es obligatorio'), }) -/** PATCH /api/leads/:itemId/asignar — reasignar lead a otro vendedor (solo admin) */ +/** PATCH /api/leads/:itemId/asignar — reasignar lead a otro vendedor (solo admin). + * Reasigna TODA la cadena del cliente (lead → oportunidad → apartado) y sus + * actividades pendientes, para no dejar el registro partido entre vendedores. */ export async function asignarLeadEndpoint(req: Request, res: Response): Promise { const itemId = String(req.params['itemId']) const { usuarioId } = asignarLeadSchema.parse(req.body) @@ -349,13 +351,9 @@ export async function asignarLeadEndpoint(req: Request, res: Response): Promise< select: { nombre: true }, }) if (!usuario) throw new NotFoundError('Usuario no encontrado') - // La asignación local es la verdad: se actualiza la fila del lead - await prisma.leadCaptura.updateMany({ - where: { leadId: itemId }, - data: { usuarioId }, - }) - res.json({ itemId, usuarioId, nombre: usuario.nombre }) - auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId) + const movidas = await crmLocal.reasignarCadena(itemId, usuarioId) + res.json({ itemId, usuarioId, nombre: usuario.nombre, movidas }) + auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, `${usuario.nombre} (${movidas} fila(s))`) } const moverLeadSchema = z.object({ diff --git a/api-gateway/src/services/crm-local.service.test.ts b/api-gateway/src/services/crm-local.service.test.ts index b32b9ad..31b4ce5 100644 --- a/api-gateway/src/services/crm-local.service.test.ts +++ b/api-gateway/src/services/crm-local.service.test.ts @@ -16,6 +16,7 @@ const prismaMock = vi.hoisted(() => ({ findUnique: vi.fn(), }, automatizacionState: { + findFirst: vi.fn(), findUnique: vi.fn(), upsert: vi.fn(), }, @@ -43,6 +44,7 @@ beforeEach(() => { prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data) prismaMock.leadOportunidad.create.mockResolvedValue({}) prismaMock.automatizacionState.findUnique.mockResolvedValue(null) + prismaMock.automatizacionState.findFirst.mockResolvedValue(null) prismaMock.automatizacionState.upsert.mockResolvedValue({}) }) @@ -596,3 +598,58 @@ describe('CrmLocalService dedup normalizado (anti-duplicados)', () => { expect(duplicados).toBe(1) }) }) + +describe('CrmLocalService.reasignarCadena', () => { + it('lead sin convertir: solo mueve su propia fila', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + leadId: 'L1', board: 'LEADS', oportunidadId: null, + }) + prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 1 }) + const n = await svc.reasignarCadena('L1', 'u-nueva') + expect(n).toBe(1) + expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ + where: { leadId: { in: ['L1'] } }, + data: { usuarioId: 'u-nueva' }, + }) + // Sin oportunidad no busca ni lead enlazado ni apartado + expect(prismaMock.automatizacionState.findFirst).not.toHaveBeenCalled() + // Las actividades pendientes también pasan al nuevo vendedor + expect(prismaMock.recordatorioVendedor.updateMany).toHaveBeenCalledWith({ + where: { leadId: { in: ['L1'] }, completado: false }, + data: { usuarioId: 'u-nueva' }, + }) + }) + + it('oportunidad convertida: mueve lead + oportunidad + apartado', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + leadId: 'O1', board: 'OPORTUNIDADES', oportunidadId: 'O1', + }) + prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'L1' }) + prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:O1', valor: 'A1' }) + prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 3 }) + const n = await svc.reasignarCadena('O1', 'u-nueva') + expect(n).toBe(3) + const where = prismaMock.leadCaptura.updateMany.mock.calls[0][0]['where'] + expect(where['leadId']['in'].sort()).toEqual(['A1', 'L1', 'O1']) + }) + + it('apartado: reconstruye la cadena completa hacia atrás', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue({ + leadId: 'A1', board: 'APARTADO', oportunidadId: 'A1', + }) + prismaMock.automatizacionState.findFirst.mockResolvedValue({ clave: 'apartado-creado:O1', valor: 'A1' }) + prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'L1' }) + prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:O1', valor: 'A1' }) + prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 3 }) + const n = await svc.reasignarCadena('A1', 'u-nueva') + expect(n).toBe(3) + const where = prismaMock.leadCaptura.updateMany.mock.calls[0][0]['where'] + expect(where['leadId']['in'].sort()).toEqual(['A1', 'L1', 'O1']) + }) + + it('id inexistente: 404 en vez de "éxito" silencioso', async () => { + prismaMock.leadCaptura.findUnique.mockResolvedValue(null) + await expect(svc.reasignarCadena('loc_fantasma', 'u-nueva')).rejects.toThrow('Lead no encontrado') + expect(prismaMock.leadCaptura.updateMany).not.toHaveBeenCalled() + }) +}) diff --git a/api-gateway/src/services/crm-local.service.ts b/api-gateway/src/services/crm-local.service.ts index 97ef7d7..580b1a9 100644 --- a/api-gateway/src/services/crm-local.service.ts +++ b/api-gateway/src/services/crm-local.service.ts @@ -532,6 +532,62 @@ export class CrmLocalService { .slice(0, 15) .map(({ orden: _orden, ...resto }) => resto) } + + /** + * Reasignación en cadena (solo admin): mueve el registro y TODA su cadena + * al nuevo vendedor. Un cliente puede tener hasta 3 filas (lead → + * oportunidad → apartado) y cada una tiene su propio leadId; reasignar sólo + * la fila tocada dejaba al cliente "partido" entre dos vendedores (el lead + * con uno y la oportunidad/apartado con el otro). También pasan las + * actividades PENDIENTES (calendario); las completadas quedan como + * historial del vendedor original. Devuelve cuántas filas se movieron. + */ + async reasignarCadena(itemId: string, usuarioId: string): Promise { + const origen = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } }) + if (!origen) throw new NotFoundError('Lead no encontrado') + + const ids = new Set([itemId]) + let oportunidadId: string | null = null + if (origen.board === 'LEADS') { + oportunidadId = origen.oportunidadId + } else if (origen.board === 'OPORTUNIDADES') { + oportunidadId = itemId + } else if (origen.board === 'APARTADO') { + // Cadena inversa: el estado 'apartado-creado:{O}' apunta a este apartado + const estado = await prisma.automatizacionState.findFirst({ + where: { clave: { startsWith: 'apartado-creado:' }, valor: itemId }, + }) + oportunidadId = estado?.clave.slice('apartado-creado:'.length) ?? null + } + + if (oportunidadId !== null) { + ids.add(oportunidadId) + // El lead origen (tabla LEADS) apunta a la oportunidad con su columna + const lead = await prisma.leadCaptura.findFirst({ + where: { board: 'LEADS', oportunidadId }, + select: { leadId: true }, + }) + if (lead) ids.add(lead.leadId) + // El apartado cuelga de la oportunidad por la clave de automatización + const apartado = await prisma.automatizacionState.findUnique({ + where: { clave: claveApartadoCreado(oportunidadId) }, + }) + if (apartado) ids.add(apartado.valor) + } + + const listaIds = [...ids] + const resultados = await prisma.$transaction([ + prisma.leadCaptura.updateMany({ + where: { leadId: { in: listaIds } }, + data: { usuarioId }, + }), + prisma.recordatorioVendedor.updateMany({ + where: { leadId: { in: listaIds }, completado: false }, + data: { usuarioId }, + }), + ]) + return resultados[0].count + } } /** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */