fix(admin): reasignar mueve toda la cadena del cliente (lead+oportunidad+apartado) y sus actividades pendientes; 404 si el id no existe
This commit is contained in:
@@ -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<unknown>[]) => 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 () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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({
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<number> {
|
||||
const origen = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
|
||||
if (!origen) throw new NotFoundError('Lead no encontrado')
|
||||
|
||||
const ids = new Set<string>([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) */
|
||||
|
||||
Reference in New Issue
Block a user