feat(7-mejoras): notas persisten + soporte + captura visible
1. Notas propagan entre etapas (convertirLead/apartar updateMany) 2. Afluencia: conexión → aviso amarillo 3. Escrituración: click nombre abre bitácora 4. Co-acreditado: opción 'no tiene' 5. Nota del alta de lead → bitácora 6. Módulo Soporte: formulario + listado solicitudes 7. Captura visible para todos
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
-- Tabla del módulo Soporte: solicitudes de los departamentos.
|
||||
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
|
||||
|
||||
-- CreateTable SolicitudSoporte
|
||||
CREATE TABLE IF NOT EXISTS "SolicitudSoporte" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"usuarioId" TEXT NOT NULL,
|
||||
"titulo" TEXT NOT NULL,
|
||||
"descripcion" TEXT NOT NULL,
|
||||
"departamento" TEXT NOT NULL,
|
||||
"prioridad" TEXT NOT NULL,
|
||||
"responsable" TEXT,
|
||||
"fechaSolicitud" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"fechaLimite" DATETIME,
|
||||
"notas" TEXT,
|
||||
"archivos" TEXT,
|
||||
"solicitante" TEXT NOT NULL DEFAULT '',
|
||||
"solicitanteEmail" TEXT,
|
||||
"estado" TEXT NOT NULL DEFAULT 'ABIERTA',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "SolicitudSoporte_usuarioId_idx" ON "SolicitudSoporte"("usuarioId");
|
||||
CREATE INDEX IF NOT EXISTS "SolicitudSoporte_departamento_idx" ON "SolicitudSoporte"("departamento");
|
||||
@@ -504,3 +504,29 @@ model ClienteMaestro {
|
||||
@@index([estatus])
|
||||
@@index([vendedor])
|
||||
}
|
||||
|
||||
// ── Módulo Soporte: solicitudes de los departamentos (Sistemas, Admin, ...) ──
|
||||
|
||||
model SolicitudSoporte {
|
||||
id String @id @default(uuid())
|
||||
usuarioId String
|
||||
titulo String
|
||||
descripcion String
|
||||
departamento String
|
||||
prioridad String
|
||||
responsable String?
|
||||
fechaSolicitud DateTime @default(now())
|
||||
fechaLimite DateTime?
|
||||
notas String?
|
||||
archivos String? // JSON array de rutas
|
||||
// Quién pide la solicitud (nombre y email; el front los manda o el API usa
|
||||
// el usuario del token)
|
||||
solicitante String @default("")
|
||||
solicitanteEmail String?
|
||||
estado String @default("ABIERTA") // ABIERTA, EN_PROCESO, CERRADA
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([usuarioId])
|
||||
@@index([departamento])
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { MODULOS, esRolValido, modulosDeRol } from './roles.js'
|
||||
|
||||
const ESPERADOS: Record<string, string[]> = {
|
||||
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
|
||||
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
administracion_venta: [
|
||||
'mi-dia',
|
||||
'alta-cliente',
|
||||
@@ -12,6 +12,8 @@ const ESPERADOS: Record<string, string[]> = {
|
||||
'contactos',
|
||||
'chat',
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
'cobranza',
|
||||
],
|
||||
director_ventas: [
|
||||
@@ -23,6 +25,8 @@ const ESPERADOS: Record<string, string[]> = {
|
||||
'contactos',
|
||||
'chat',
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
'cobranza',
|
||||
'tablero-comercial',
|
||||
],
|
||||
@@ -35,9 +39,11 @@ const ESPERADOS: Record<string, string[]> = {
|
||||
'contactos',
|
||||
'chat',
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
'cobranza',
|
||||
],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
admin: [...MODULOS],
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ export const MODULOS = [
|
||||
'contactos',
|
||||
'chat',
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
] as const
|
||||
export type Modulo = (typeof MODULOS)[number]
|
||||
|
||||
@@ -26,6 +28,7 @@ export const ROLES = [
|
||||
] as const
|
||||
export type Rol = (typeof ROLES)[number]
|
||||
|
||||
// 'captura' y 'soporte' son transversales: todos los roles los ven
|
||||
const MODULOS_VENDEDOR: readonly Modulo[] = [
|
||||
'mi-dia',
|
||||
'alta-cliente',
|
||||
@@ -35,6 +38,8 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
|
||||
'contactos',
|
||||
'chat',
|
||||
'clientes-maestro',
|
||||
'captura',
|
||||
'soporte',
|
||||
]
|
||||
|
||||
export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
@@ -42,7 +47,7 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
|
||||
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
|
||||
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'],
|
||||
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
admin: MODULOS,
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
recordatorioVendedor: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
|
||||
@@ -37,6 +37,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
recordatorioVendedor: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
cliente: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
import { MOTIVOS_NO_COMPRA, SUBCATEGORIAS_NO_COMPRA } from './oportunidades.controller.js'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
import { recordatorioVendedorService } from '../services/recordatorio-vendedor.service.js'
|
||||
|
||||
/**
|
||||
* Captura y acciones del CRM ligero, sobre DOS tableros locales (tabla
|
||||
@@ -162,6 +163,14 @@ export async function crearLeadEndpoint(req: Request, res: Response): Promise<vo
|
||||
})
|
||||
return
|
||||
}
|
||||
// La nota del alta se persiste como recordatorio en la bitácora del lead
|
||||
if (body.notas !== undefined && body.notas.trim() !== '') {
|
||||
await recordatorioVendedorService.crear(usuarioSub ?? 'sistema', {
|
||||
titulo: body.notas,
|
||||
leadId: resultado.id,
|
||||
fechaRecordar: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
res.status(201).json({
|
||||
id: resultado.id,
|
||||
estado: 'NUEVO',
|
||||
|
||||
@@ -21,6 +21,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
recordatorioVendedor: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
auditoriaLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
|
||||
111
api-gateway/src/controllers/soporte.controller.ts
Normal file
111
api-gateway/src/controllers/soporte.controller.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../services/prisma.js'
|
||||
import * as auditoria from '../services/auditoria.service.js'
|
||||
|
||||
/**
|
||||
* Módulo Soporte: solicitudes dirigidas a los departamentos internos
|
||||
* (Sistemas, Administración, Ventas, Cobranza, Escrituración, Dirección).
|
||||
*
|
||||
* - POST /api/soporte — crear solicitud (cualquier usuario autenticado).
|
||||
* - GET /api/soporte — listar solicitudes: propias para cualquier usuario,
|
||||
* TODAS para admin (con filtro opcional ?estado=ABIERTA|EN_PROCESO|CERRADA).
|
||||
*/
|
||||
|
||||
const DEPARTAMENTOS_SOPORTE = ['Sistemas', 'Administración', 'Ventas', 'Cobranza', 'Escrituración', 'Dirección'] as const
|
||||
const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
|
||||
const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
|
||||
|
||||
const crearSolicitudSchema = z.object({
|
||||
titulo: z.string().trim().min(1, 'El título es obligatorio'),
|
||||
descripcion: z.string().trim().min(1, 'La descripción es obligatoria'),
|
||||
departamento: z.enum(DEPARTAMENTOS_SOPORTE),
|
||||
prioridad: z.enum(PRIORIDADES_SOPORTE),
|
||||
responsable: z.string().trim().min(1).optional(),
|
||||
fechaSolicitud: z.coerce.date(),
|
||||
fechaLimite: z.coerce.date().optional(),
|
||||
notas: z.string().trim().min(1).optional(),
|
||||
// Quién pide la solicitud; el front lo manda desde la sesión y el API cae al
|
||||
// username del token si viene vacío
|
||||
solicitante: z.string().trim().min(1).optional(),
|
||||
email: z.string().trim().email('Email inválido').optional(),
|
||||
// Rutas/nombres de archivos adjuntos (sin endpoint de upload: el front manda
|
||||
// nombres; se guardan como JSON array)
|
||||
archivos: z.array(z.string().trim().min(1)).optional(),
|
||||
})
|
||||
|
||||
const listarSolicitudSchema = z.object({
|
||||
estado: z.enum(ESTADOS_SOPORTE).optional(),
|
||||
})
|
||||
|
||||
/** Serializa la fila para el front: archivos JSON → array (tolerante a basura) */
|
||||
function aPublico(fila: {
|
||||
id: string
|
||||
usuarioId: string
|
||||
titulo: string
|
||||
descripcion: string
|
||||
departamento: string
|
||||
prioridad: string
|
||||
responsable: string | null
|
||||
fechaSolicitud: Date
|
||||
fechaLimite: Date | null
|
||||
notas: string | null
|
||||
archivos: string | null
|
||||
solicitante: string
|
||||
solicitanteEmail: string | null
|
||||
estado: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}) {
|
||||
let archivos: string[] = []
|
||||
if (fila.archivos) {
|
||||
try {
|
||||
const parseado: unknown = JSON.parse(fila.archivos)
|
||||
if (Array.isArray(parseado)) archivos = parseado.filter((a): a is string => typeof a === 'string')
|
||||
} catch {
|
||||
archivos = []
|
||||
}
|
||||
}
|
||||
return { ...fila, archivos }
|
||||
}
|
||||
|
||||
/** POST /api/soporte — crear solicitud de soporte */
|
||||
export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const body = crearSolicitudSchema.parse(req.body)
|
||||
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
|
||||
const fila = await prisma.solicitudSoporte.create({
|
||||
data: {
|
||||
usuarioId,
|
||||
titulo: body.titulo,
|
||||
descripcion: body.descripcion,
|
||||
departamento: body.departamento,
|
||||
prioridad: body.prioridad,
|
||||
responsable: body.responsable ?? null,
|
||||
fechaSolicitud: body.fechaSolicitud,
|
||||
fechaLimite: body.fechaLimite ?? null,
|
||||
notas: body.notas ?? null,
|
||||
archivos: body.archivos !== undefined ? JSON.stringify(body.archivos) : null,
|
||||
solicitante: body.solicitante ?? req.usuarioToken?.username ?? '',
|
||||
solicitanteEmail: body.email ?? null,
|
||||
},
|
||||
})
|
||||
res.status(201).json(aPublico(fila))
|
||||
auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento)
|
||||
}
|
||||
|
||||
/** GET /api/soporte — listar solicitudes (propias; admin ve todas) */
|
||||
export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise<void> {
|
||||
const { estado } = listarSolicitudSchema.parse(req.query)
|
||||
const where: Record<string, unknown> = {}
|
||||
// Admin ve todas; el resto solo las suyas
|
||||
if (req.usuarioToken?.rol !== 'admin') {
|
||||
where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido'
|
||||
}
|
||||
if (estado !== undefined) where['estado'] = estado
|
||||
|
||||
const filas = await prisma.solicitudSoporte.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json(filas.map(aPublico))
|
||||
}
|
||||
@@ -133,6 +133,10 @@ import {
|
||||
importarEndpoint as importarMaestroEndpoint,
|
||||
requireAdmin as requireAdminMaestro,
|
||||
} from '../controllers/cliente-maestro.controller.js'
|
||||
import {
|
||||
crearSolicitudSoporteEndpoint,
|
||||
listarSolicitudesSoporteEndpoint,
|
||||
} from '../controllers/soporte.controller.js'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
@@ -292,3 +296,7 @@ apiRouter.get('/clientes-maestro', listarMaestroEndpoint)
|
||||
apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint)
|
||||
apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint)
|
||||
apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint)
|
||||
|
||||
// ── Módulo Soporte: solicitudes a departamentos internos ──────────
|
||||
apiRouter.post('/soporte', crearSolicitudSoporteEndpoint)
|
||||
apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint)
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('login', () => {
|
||||
rol: 'vendedor',
|
||||
asignacion: 'MONTEBELLO',
|
||||
mondayUserId: null,
|
||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro'],
|
||||
modulos: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
|
||||
acciones: [],
|
||||
})
|
||||
expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos')
|
||||
@@ -103,7 +103,7 @@ describe('me', () => {
|
||||
it('incluye los módulos visibles del rol (matriz de config/roles)', async () => {
|
||||
prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ rol: 'direccion_general' }))
|
||||
const resultado = await me({ sub: 'u-1', username: 'x', rol: 'direccion_general' })
|
||||
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro'])
|
||||
expect(resultado.modulos).toEqual(['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'])
|
||||
})
|
||||
|
||||
it('rechaza si el usuario ya no existe o está inactivo', async () => {
|
||||
|
||||
@@ -19,6 +19,9 @@ const prismaMock = vi.hoisted(() => ({
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
recordatorioVendedor: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
cliente: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||
import { claveApartadoCreado } from './automatizaciones.service.js'
|
||||
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
|
||||
import { prisma } from './prisma.js'
|
||||
|
||||
/**
|
||||
@@ -256,11 +257,13 @@ export class CrmLocalService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Conversión local lead → oportunidad (botón "Agendo" de Mis leads): el lead
|
||||
* queda 'Calificado', se crea la oportunidad local en 'Agendo Cita' copiando
|
||||
* los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Si el
|
||||
* lead ya fue convertido → 409. Devuelve la fila local de la oportunidad.
|
||||
* los datos y se liga LeadOportunidad + `oportunidadId` en la captura. Las
|
||||
* notas (recordatorios) del lead pasan a apuntar a la oportunidad para que
|
||||
* sobrevivan al cambio de etapa. Si el lead ya fue convertido → 409. Devuelve
|
||||
* la fila local de la oportunidad.
|
||||
*/
|
||||
async convertirLead(leadIdLocal: string, _usuarioId?: string) {
|
||||
const lead = await prisma.leadCaptura.findUnique({ where: { leadId: leadIdLocal } })
|
||||
@@ -291,6 +294,11 @@ export class CrmLocalService {
|
||||
},
|
||||
}),
|
||||
prisma.leadOportunidad.create({ data: { leadId: leadIdLocal, oportunidadId } }),
|
||||
// Las notas del lead viejo siguen a la nueva oportunidad (bitácora persiste)
|
||||
prisma.recordatorioVendedor.updateMany({
|
||||
where: { leadId: leadIdLocal },
|
||||
data: { leadId: oportunidadId, clienteClave: calcularClienteClave(lead.nombreLead, lead.telefono) },
|
||||
}),
|
||||
])
|
||||
return resultados[1]
|
||||
}
|
||||
@@ -298,8 +306,10 @@ export class CrmLocalService {
|
||||
/**
|
||||
* "Aparto" local: la oportunidad pasa a etapa 'Aparto' y se crea el apartado
|
||||
* local (board=APARTADO, etapa 'Pago Apartado', primer paso del pipeline
|
||||
* Enkontrol) con los datos conocidos. Idempotente con el mismo dedup
|
||||
* `apartado-creado:<opId>` de las automatizaciones.
|
||||
* Enkontrol) con los datos conocidos. Las notas (recordatorios) de la
|
||||
* oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de
|
||||
* board. Idempotente con el mismo dedup `apartado-creado:<opId>` de las
|
||||
* automatizaciones.
|
||||
*/
|
||||
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
|
||||
// Sombra para ids heredados sin fila local
|
||||
@@ -343,6 +353,11 @@ export class CrmLocalService {
|
||||
update: { valor: apartadoId },
|
||||
create: { clave, valor: apartadoId },
|
||||
}),
|
||||
// Las notas de la oportunidad siguen al apartado (bitácora persiste)
|
||||
prisma.recordatorioVendedor.updateMany({
|
||||
where: { leadId: oportunidadIdLocal },
|
||||
data: { leadId: apartadoId, clienteClave: calcularClienteClave(oportunidad.nombreLead, oportunidad.telefono) },
|
||||
}),
|
||||
])
|
||||
return resultados[1]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user