fix(auditoria general): 25 errores encontrados en la revision modulo por modulo
SEGURIDAD (agente IA): - Conversaciones: GET /ai/conversations/:id y el reanudar chat verifican propiedad (antes cualquier usuario leia/continuaba la conversacion de otro). - /ai/documents pasa a solo-admin (antes cualquien podia inyectar o borrar documentos de la base de conocimiento). DATOS / CORRUPCION: - Cotizador: elegir un cliente del maestro en el buscador ya no crea un lead fantasma "Registro previo (CRM anterior)" en el embudo. - Afluencia con oportunidadId inexistente: 404 en vez de crear sombra fantasma. - Cobranza: los 103 planes importados quedaron con vendedor asignado (7 con su asesor real segun el Excel, el resto al admin) y el motor de recordatorios cae al admin cuando un plan no tiene vendedor: antes los 411 recordatorios se registraban 'sin-destinatario' y no los veia nadie. - Backups: script reescrito para el host real (sqlite .backup + uploads + env en /root/backups) y cron instalado (antes: script que asumia docker y sin cron programado = cero respaldos). REPORTES / METRICAS: - Mi tablero: el embudo ya excluye cancelaciones reales (Cancelo/Cancelacion Apartado) y cuenta como vendidos Firma Cliente/Escriturado/Titulado (antes 41 "apartados" donde habia 13 activos y vendidos buscaba una etapa que no existe); tasa de descarte ya compara el mismo periodo. - Scoring: etapa real 'Agendo Cita' (antes 'AGENDO' nunca aplicaba) y los descartados/perdidos ya no reciben score. - ver_cobranza (IA): ignora planes cancelados y no cuenta como vencido el pago que vence hoy. UI / FLUJOS: - mi-dia: typo CANCO -> CANCELO (los cancelados reaparecen en Escrituracion); confirmar cita ya no retrocede la etapa de Cotizador/Aparto (boton y kanban). - alta-clientes: la firma ya no se borra al navegar fuera del paso de firmas; toast de exito solo cuando el API confirma; prefill de apartado trae direccion, estado civil y fecha de nacimiento desde el alta. - Auditoria: el filtro por usuario funciona (el front enviaba 'usuario' y el API esperaba 'usuarioId') y 'hasta' incluye todo el dia. - Contactos: paginacion aplicada despues de fusionar fuentes (antes saltaban o se repetian contactos entre paginas y 'Siguiente' se deshabilitaba antes de tiempo). - Buscador global: leads -> /hoy/, contactos -> /; el cotizador entiende ?cotizacion=<id> para retomar desde la liga. - Export mi-dia: columnas Desarrollo/Origen rellenas y sin duplicados. - Recibo de cobranza: "Recibio" es FRASA (el cliente paga, no recibe). - Alta de plan de cobranza: proyecto libre con sugerencias del catalogo real (antes limitado a 3 proyectos que no cubrian la cartera). Verificado en vivo: autorizacion IA (3x 403), filtro de auditoria, 404 de afluencia. Suites: 717 API + 124 alta-clientes + 115 cotizador + 92 cobranza + 89 landing + 32 mi-dia en verde. Backup de hoy generado en /root/backups.
This commit is contained in:
@@ -114,6 +114,12 @@ export async function obtenerConversacion(req: Request, res: Response): Promise<
|
|||||||
include: { mensajes: { orderBy: { createdAt: 'asc' } } },
|
include: { mensajes: { orderBy: { createdAt: 'asc' } } },
|
||||||
})
|
})
|
||||||
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||||
|
// Propiedad: misma regla que al borrar — sólo el dueño o un rol de
|
||||||
|
// supervisión puede leer la conversación.
|
||||||
|
const tokenLectura = req.usuarioToken
|
||||||
|
if (tokenLectura && !esAdminOManager(tokenLectura.rol) && conversacion.usuarioId !== tokenLectura.sub) {
|
||||||
|
throw new AppError(403, 'Solo puedes ver tus conversaciones')
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
id: conversacion.id,
|
id: conversacion.id,
|
||||||
createdAt: conversacion.createdAt,
|
createdAt: conversacion.createdAt,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => {
|
|||||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
|
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
createdAt: expect.objectContaining({ gte: expect.any(Date), lte: expect.any(Date) }),
|
createdAt: expect.objectContaining({ gte: expect.any(Date), lt: expect.any(Date) }),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -525,6 +525,22 @@ async function prefillApartadoLocal(itemId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Datos del alta (payloadJson) que el prefill del front consume con claves
|
||||||
|
// legacy de Monday: dirección, estado civil y fecha de nacimiento
|
||||||
|
const contactoExtra: Record<string, string> = {}
|
||||||
|
if (cliente?.payloadJson) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(cliente.payloadJson) as { datosGenerales?: Record<string, unknown> }
|
||||||
|
const g = payload.datosGenerales ?? {}
|
||||||
|
const str = (v: unknown): string => (typeof v === 'string' && v.trim() !== '' ? v : '')
|
||||||
|
if (str(g['direccion']) !== '') contactoExtra['Dirección'] = str(g['direccion'])
|
||||||
|
if (str(g['estadoCivil']) !== '') contactoExtra['Estado Civil'] = str(g['estadoCivil'])
|
||||||
|
if (str(g['fechaNacimiento']) !== '') contactoExtra['Fecha Nacimiento'] = str(g['fechaNacimiento'])
|
||||||
|
} catch {
|
||||||
|
// payload inválido: el prefill sale parcial, como antes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apartado: {
|
apartado: {
|
||||||
id: captura.leadId,
|
id: captura.leadId,
|
||||||
@@ -545,6 +561,7 @@ async function prefillApartadoLocal(itemId: string) {
|
|||||||
Email: cliente.email ?? '',
|
Email: cliente.email ?? '',
|
||||||
CURP: cliente.curp,
|
CURP: cliente.curp,
|
||||||
RFC: cliente.rfc,
|
RFC: cliente.rfc,
|
||||||
|
...contactoExtra,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
oportunidad: oportunidadLocal
|
oportunidad: oportunidadLocal
|
||||||
|
|||||||
@@ -149,8 +149,12 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
|
|||||||
mondayItemId = opExistente
|
mondayItemId = opExistente
|
||||||
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
|
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
|
||||||
try {
|
try {
|
||||||
|
// sombra:false: sólo se marca la etapa si el id corresponde a una
|
||||||
|
// oportunidad real. Antes (sombra para ids no locales) elegir un cliente
|
||||||
|
// del maestro en el buscador del cotizador creaba un LeadCaptura
|
||||||
|
// fantasma ("Registro previo (CRM anterior)") con pendienteSync.
|
||||||
await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', {
|
await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', {
|
||||||
sombra: !esIdLocal(opExistente),
|
sombra: false,
|
||||||
usuarioId: req.usuarioToken?.sub,
|
usuarioId: req.usuarioToken?.sub,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -339,9 +339,11 @@ apiRouter.post('/ai/chat/stream', chatIAStream)
|
|||||||
apiRouter.get('/ai/conversations', listarConversaciones)
|
apiRouter.get('/ai/conversations', listarConversaciones)
|
||||||
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
|
||||||
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
|
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
|
||||||
apiRouter.post('/ai/documents', subirDocumento)
|
// Mantenimiento de la base de conocimiento: SOLO admin (admin/manager no
|
||||||
apiRouter.get('/ai/documents', listarDocumentosIA)
|
// basta: borrar o inyectar documentos afecta a todos los usuarios del chat).
|
||||||
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
|
apiRouter.post('/ai/documents', requireAdminEstricto, subirDocumento)
|
||||||
|
apiRouter.get('/ai/documents', requireAdminEstricto, listarDocumentosIA)
|
||||||
|
apiRouter.delete('/ai/documents/:id', requireAdminEstricto, eliminarDocumento)
|
||||||
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
|
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
|
||||||
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
|
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
|
||||||
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
|
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
|
|||||||
import { prisma } from './prisma.js'
|
import { prisma } from './prisma.js'
|
||||||
import { config } from '../config/index.js'
|
import { config } from '../config/index.js'
|
||||||
import { AppError, NotFoundError } from '../utils/errors.js'
|
import { AppError, NotFoundError } from '../utils/errors.js'
|
||||||
|
import { esAdminOManager } from '../utils/roles.js'
|
||||||
import { getUnidadesLocal } from './unidades-local.service.js'
|
import { getUnidadesLocal } from './unidades-local.service.js'
|
||||||
import {
|
import {
|
||||||
APARTADO_DEFAULT,
|
APARTADO_DEFAULT,
|
||||||
@@ -1020,10 +1021,12 @@ export function crearEjecutoresHerramientas(
|
|||||||
})
|
})
|
||||||
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
|
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
|
||||||
const hoy = new Date()
|
const hoy = new Date()
|
||||||
|
// Medianoche de hoy: lo que vence HOY aún no está vencido
|
||||||
|
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
|
||||||
return planes.map((p) => {
|
return planes.map((p) => {
|
||||||
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
|
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
|
||||||
const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)
|
const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)
|
||||||
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < hoy && x.montoPagado < x.monto)
|
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < inicioHoy && x.montoPagado < x.monto)
|
||||||
const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
|
const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
|
||||||
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
|
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
|
||||||
const abonos = p.pagos
|
const abonos = p.pagos
|
||||||
@@ -1054,7 +1057,8 @@ export function crearEjecutoresHerramientas(
|
|||||||
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
|
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
|
||||||
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
|
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
|
||||||
prisma.pago.findMany({
|
prisma.pago.findMany({
|
||||||
where: { fechaVencimiento: { lt: new Date() }, plan: alcance },
|
// Sólo planes activos: el adeudo de planes cancelados no cuenta
|
||||||
|
where: { fechaVencimiento: { lt: new Date() }, plan: { ...alcance, status: 'ACTIVO' } },
|
||||||
select: {
|
select: {
|
||||||
monto: true,
|
monto: true,
|
||||||
montoPagado: true,
|
montoPagado: true,
|
||||||
@@ -1505,6 +1509,18 @@ export async function chat(
|
|||||||
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
|
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
|
||||||
: null
|
: null
|
||||||
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
|
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
|
||||||
|
// Propiedad: sólo el dueño (o un rol de supervisión) puede reanudar una
|
||||||
|
// conversación existente. Sin esto, cualquiera con el uuid continuaba el
|
||||||
|
// chat ajeno con todo su historial.
|
||||||
|
if (
|
||||||
|
conversacion &&
|
||||||
|
opciones.usuarioId &&
|
||||||
|
conversacion.usuarioId &&
|
||||||
|
conversacion.usuarioId !== opciones.usuarioId &&
|
||||||
|
!esAdminOManager(opciones.contexto?.rol)
|
||||||
|
) {
|
||||||
|
throw new AppError(403, 'No puedes continuar la conversación de otro usuario')
|
||||||
|
}
|
||||||
conversacion ??= await prisma.aIConversacion.create({
|
conversacion ??= await prisma.aIConversacion.create({
|
||||||
data: { usuarioId: opciones.usuarioId ?? null },
|
data: { usuarioId: opciones.usuarioId ?? null },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ describe('buscar', () => {
|
|||||||
prismaMock.usuario.findMany.mockResolvedValue([])
|
prismaMock.usuario.findMany.mockResolvedValue([])
|
||||||
await buscar({ desde, hasta })
|
await buscar({ desde, hasta })
|
||||||
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
|
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
|
||||||
where: { createdAt: { gte: desde, lte: hasta } },
|
// "hasta" incluye todo el día: se excluye con lt al día siguiente
|
||||||
|
where: { createdAt: { gte: desde, lt: new Date(hasta.getTime() + 24 * 60 * 60 * 1000) } },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 100,
|
take: 100,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
|
|||||||
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
|
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
|
||||||
where['createdAt'] = {
|
where['createdAt'] = {
|
||||||
...(filtros.desde !== undefined ? { gte: filtros.desde } : {}),
|
...(filtros.desde !== undefined ? { gte: filtros.desde } : {}),
|
||||||
...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}),
|
// "hasta" es inclusive de todo el día: se excluye con lt al día siguiente
|
||||||
|
// (antes con lte a las 00:00 del día se perdía casi todo el día elegido)
|
||||||
|
...(filtros.hasta !== undefined ? { lt: new Date(filtros.hasta.getTime() + 24 * 60 * 60 * 1000) } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const logs = await prisma.auditoriaLog.findMany({
|
const logs = await prisma.auditoriaLog.findMany({
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ describe('buscar (GET /api/buscar)', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const r = await buscar('X')
|
const r = await buscar('X')
|
||||||
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads')
|
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/hoy/')
|
||||||
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes')
|
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/')
|
||||||
expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1')
|
expect(r.find((i) => i.tipo === 'oportunidad')?.url).toBe('/quote/?cotizacion=ct1')
|
||||||
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
|
expect(r.find((i) => i.tipo === 'unidad')?.url).toBe('/quote/?unidad=X')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ export async function buscar(
|
|||||||
id: l.leadId,
|
id: l.leadId,
|
||||||
nombre: l.nombreLead,
|
nombre: l.nombreLead,
|
||||||
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
|
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
|
||||||
url: `/app/#leads`,
|
url: `/hoy/`,
|
||||||
})),
|
})),
|
||||||
...clientes.map((c) => ({
|
...clientes.map((c) => ({
|
||||||
tipo: 'contacto' as const,
|
tipo: 'contacto' as const,
|
||||||
id: c.id,
|
id: c.id,
|
||||||
nombre: c.nombreCompleto,
|
nombre: c.nombreCompleto,
|
||||||
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
|
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
|
||||||
url: `/app/#clientes`,
|
url: `/`,
|
||||||
})),
|
})),
|
||||||
...cotizaciones.map((ct) => ({
|
...cotizaciones.map((ct) => ({
|
||||||
tipo: 'oportunidad' as const,
|
tipo: 'oportunidad' as const,
|
||||||
|
|||||||
@@ -212,7 +212,8 @@ describe('listarContactosLocal', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
|
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
|
||||||
// 101 clientes: la página 1 llena 100 y detecta que hay más
|
// 101 clientes: la paginación se aplica DESPUÉS de fusionar fuentes, así
|
||||||
|
// que el servicio pide el universo completo y corta en memoria
|
||||||
prismaMock.cliente.findMany.mockResolvedValue(
|
prismaMock.cliente.findMany.mockResolvedValue(
|
||||||
Array.from({ length: 101 }, (_, i) =>
|
Array.from({ length: 101 }, (_, i) =>
|
||||||
clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }),
|
clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }),
|
||||||
@@ -225,13 +226,12 @@ describe('listarContactosLocal', () => {
|
|||||||
expect(pagina1.contactos).toHaveLength(100)
|
expect(pagina1.contactos).toHaveLength(100)
|
||||||
expect(pagina1.hayMas).toBe(true)
|
expect(pagina1.hayMas).toBe(true)
|
||||||
|
|
||||||
// La página 2 recibe el siguiente bloque (skip=100, take=101)
|
// La página 2 trae la fila más vieja (orden desc por fecha: la 101 va
|
||||||
prismaMock.cliente.findMany.mockResolvedValue([
|
// primero en la página 1)
|
||||||
clienteFake({ id: 'c101', celular: '4770000100' }),
|
|
||||||
])
|
|
||||||
const pagina2 = await listarContactosLocal(undefined, 2)
|
const pagina2 = await listarContactosLocal(undefined, 2)
|
||||||
expect(pagina2.pagina).toBe(2)
|
expect(pagina2.pagina).toBe(2)
|
||||||
expect(pagina2.contactos).toHaveLength(1)
|
expect(pagina2.contactos).toHaveLength(1)
|
||||||
|
expect(pagina2.contactos[0]?.id).toBe('c1')
|
||||||
expect(pagina2.hayMas).toBe(false)
|
expect(pagina2.hayMas).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -92,12 +92,12 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
|||||||
const porPagina = CONTACTOS_POR_PAGINA
|
const porPagina = CONTACTOS_POR_PAGINA
|
||||||
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
|
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
|
||||||
const skip = (paginaValida - 1) * porPagina
|
const skip = (paginaValida - 1) * porPagina
|
||||||
// +1 por fuente para detectar si queda más (hayMas) sin una consulta extra
|
// La paginación se aplica DESPUÉS de fusionar las dos fuentes: antes se hacía
|
||||||
|
// skip/take por fuente y el dedupe posterior perdía contactos entre páginas
|
||||||
|
// y daba hayMas=false antes de tiempo.
|
||||||
const clientes = await prisma.cliente.findMany({
|
const clientes = await prisma.cliente.findMany({
|
||||||
where: usuarioId ? { capturadoPorId: usuarioId } : {},
|
where: usuarioId ? { capturadoPorId: usuarioId } : {},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip,
|
|
||||||
take: porPagina + 1,
|
|
||||||
})
|
})
|
||||||
const capturas = await prisma.leadCaptura.findMany({
|
const capturas = await prisma.leadCaptura.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -105,8 +105,6 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
|||||||
...(usuarioId ? { usuarioId } : {}),
|
...(usuarioId ? { usuarioId } : {}),
|
||||||
},
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
skip,
|
|
||||||
take: porPagina + 1,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const lista: Candidato[] = []
|
const lista: Candidato[] = []
|
||||||
@@ -207,12 +205,11 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
const hayMas = ordenada.length > porPagina
|
|
||||||
return {
|
return {
|
||||||
contactos: ordenada.slice(0, porPagina),
|
contactos: ordenada.slice(skip, skip + porPagina),
|
||||||
pagina: paginaValida,
|
pagina: paginaValida,
|
||||||
porPagina,
|
porPagina,
|
||||||
hayMas,
|
hayMas: ordenada.length > skip + porPagina,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,27 +124,11 @@ export class CrmLocalService {
|
|||||||
|
|
||||||
if (oportunidadId !== undefined) {
|
if (oportunidadId !== undefined) {
|
||||||
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
|
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
|
||||||
// Sin fila local (id heredado del CRM anterior): se crea la sombra con
|
// Sin fila local: id inválido o liga guardada de una limpieza anterior.
|
||||||
// ese id para que la captura no se pierda en silencio.
|
// Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se
|
||||||
|
// rechaza para no sembrar registros huérfanos en Oportunidades.
|
||||||
if (!existente) {
|
if (!existente) {
|
||||||
await prisma.leadCaptura.create({
|
throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`)
|
||||||
data: {
|
|
||||||
leadId: oportunidadId,
|
|
||||||
oportunidadId,
|
|
||||||
usuarioId: usuarioId ?? 'desconocido',
|
|
||||||
nombreLead: datos.nombre,
|
|
||||||
telefono: datos.telefono ?? null,
|
|
||||||
email: datos.email ?? null,
|
|
||||||
origen: datos.origen ?? null,
|
|
||||||
desarrollo: datos.desarrollo ?? null,
|
|
||||||
modelo: modelo ?? null,
|
|
||||||
board: 'OPORTUNIDADES',
|
|
||||||
etapa: 'Afluencia',
|
|
||||||
datosJson: JSON.stringify(datos),
|
|
||||||
pendienteSync: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { id: oportunidadId, oportunidadId, actualizada: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sólo se pisan los campos capturados: lo que no viene en el payload se
|
// Sólo se pisan los campos capturados: lo que no viene en el payload se
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ describe('obtenerScores', () => {
|
|||||||
where: {
|
where: {
|
||||||
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||||
usuarioId: 'u1',
|
usuarioId: 'u1',
|
||||||
|
// Los cerrados (descartados/perdidos) no reciben score
|
||||||
|
etapa: { notIn: ['Descartado', 'Perdido'] },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import { prisma } from './prisma.js'
|
|||||||
|
|
||||||
const ETAPA_SCORE: Record<string, number> = {
|
const ETAPA_SCORE: Record<string, number> = {
|
||||||
NUEVO: 20,
|
NUEVO: 20,
|
||||||
|
'INTENTO DE CONTACTO': 30,
|
||||||
CONTACTADO: 40,
|
CONTACTADO: 40,
|
||||||
INTERESADO: 60,
|
INTERESADO: 60,
|
||||||
AGENDO: 80,
|
'AGENDO CITA': 80,
|
||||||
CALIFICADO: 90,
|
CALIFICADO: 90,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +75,8 @@ export function calcularScore(lead: {
|
|||||||
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
|
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
board: { in: ['LEADS', 'OPORTUNIDADES'] },
|
||||||
|
// Los cerrados (descartados/perdidos) no reciben score ni salen en el ranking
|
||||||
|
etapa: { notIn: ['Descartado', 'Perdido'] },
|
||||||
}
|
}
|
||||||
if (usuarioId) {
|
if (usuarioId) {
|
||||||
where['usuarioId'] = usuarioId
|
where['usuarioId'] = usuarioId
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// ---------- Prisma mockeado ----------
|
||||||
|
// La pasada consulta un admin de respaldo para los planes sin vendedor
|
||||||
|
// (antes caían a 'sin-destinatario' y nadie los veía).
|
||||||
|
const prismaMock = vi.hoisted(() => ({ usuario: { findFirst: vi.fn() } }))
|
||||||
|
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
|
||||||
|
|
||||||
import type { Pago, PlanPago } from './cobranza.service.js'
|
import type { Pago, PlanPago } from './cobranza.service.js'
|
||||||
import {
|
import {
|
||||||
RecordatoriosCobranzaService,
|
RecordatoriosCobranzaService,
|
||||||
@@ -231,7 +238,29 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
|
|||||||
expect(log).toHaveLength(1)
|
expect(log).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sin destinatario (plan sin vendedor): solo registra en el log, sin canal', async () => {
|
it('sin destinatario (plan sin vendedor): el recordatorio cae al administrador de respaldo', async () => {
|
||||||
|
prismaMock.usuario.findFirst.mockResolvedValue({ id: 'admin-qa' })
|
||||||
|
const planesSinVendedor = [
|
||||||
|
plan({
|
||||||
|
id: 'p1',
|
||||||
|
vendedorId: null,
|
||||||
|
pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })],
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const { store, log } = storeFake(planesSinVendedor)
|
||||||
|
const { canal, envios } = canalFake()
|
||||||
|
const svc = new RecordatoriosCobranzaService(store, [canal])
|
||||||
|
|
||||||
|
const r = await svc.ejecutarPasada({ ahora: HOY })
|
||||||
|
expect(r.enviados).toHaveLength(1)
|
||||||
|
expect(r.enviados[0]).toMatchObject({ canal: 'fake', destinatario: 'admin-qa' })
|
||||||
|
expect(envios).toHaveLength(1)
|
||||||
|
expect(envios[0].destinatario).toMatchObject({ vendedorId: 'admin-qa', origen: 'admin-fallback' })
|
||||||
|
expect(log).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sin destinatario y SIN admin en el sistema: solo registra en el log, sin canal', async () => {
|
||||||
|
prismaMock.usuario.findFirst.mockResolvedValue(null)
|
||||||
const planesSinVendedor = [
|
const planesSinVendedor = [
|
||||||
plan({
|
plan({
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
|
|||||||
@@ -130,10 +130,11 @@ export function debeEnviar(
|
|||||||
export interface DestinatarioRecordatorio {
|
export interface DestinatarioRecordatorio {
|
||||||
/** usuarioId del vendedor responsable; null = no hay a quién asignar */
|
/** usuarioId del vendedor responsable; null = no hay a quién asignar */
|
||||||
vendedorId: string | null
|
vendedorId: string | null
|
||||||
origen: 'vendedor' | 'ninguno'
|
origen: 'vendedor' | 'ninguno' | 'admin-fallback'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Regla pura de selección: vendedor del plan → nadie */
|
/** Regla pura de selección: vendedor del plan → nadie (el fallback a admin se
|
||||||
|
* resuelve en la pasada, que tiene acceso a la BD). */
|
||||||
export function elegirDestinatario(vendedorId: string | null): DestinatarioRecordatorio {
|
export function elegirDestinatario(vendedorId: string | null): DestinatarioRecordatorio {
|
||||||
if (vendedorId !== null) return { vendedorId, origen: 'vendedor' }
|
if (vendedorId !== null) return { vendedorId, origen: 'vendedor' }
|
||||||
return { vendedorId: null, origen: 'ninguno' }
|
return { vendedorId: null, origen: 'ninguno' }
|
||||||
@@ -218,9 +219,21 @@ export class RecordatoriosCobranzaService {
|
|||||||
const planes = await this.store.cargarPlanes()
|
const planes = await this.store.cargarPlanes()
|
||||||
const candidatos = candidatosRecordatorio(planes, ahora, diasAntes)
|
const candidatos = candidatosRecordatorio(planes, ahora, diasAntes)
|
||||||
|
|
||||||
|
// Planes sin vendedor asignado (p. ej. cartera importada sin asesor): el
|
||||||
|
// recordatorio cae al administrador para que no se pierda y pueda
|
||||||
|
// reasignar el plan. Antes quedaban en 'sin-destinatario' y nadie los veía.
|
||||||
|
const adminFallback = await prisma.usuario.findFirst({
|
||||||
|
where: { rol: 'admin', activo: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] }
|
const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] }
|
||||||
for (const c of candidatos) {
|
for (const c of candidatos) {
|
||||||
const destinatario = elegirDestinatario(c.vendedorId)
|
let destinatario = elegirDestinatario(c.vendedorId)
|
||||||
|
if (destinatario.origen === 'ninguno' && adminFallback) {
|
||||||
|
destinatario = { vendedorId: adminFallback.id, origen: 'admin-fallback' }
|
||||||
|
}
|
||||||
|
|
||||||
const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo)
|
const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo)
|
||||||
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {
|
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {
|
||||||
|
|||||||
@@ -77,15 +77,17 @@ describe('construirMiTablero', () => {
|
|||||||
captura({ board: 'LEADS' }),
|
captura({ board: 'LEADS' }),
|
||||||
captura({ board: 'LEADS' }),
|
captura({ board: 'LEADS' }),
|
||||||
captura({ board: 'OPORTUNIDADES' }),
|
captura({ board: 'OPORTUNIDADES' }),
|
||||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
|
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE' }),
|
||||||
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
|
captura({ board: 'APARTADO', etapa: 'CANCELO' }),
|
||||||
|
captura({ board: 'APARTADO', etapa: 'ESCRITURADO' }),
|
||||||
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
|
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
|
||||||
]
|
]
|
||||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||||
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
|
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
|
||||||
expect(tablero.conversion.oportunidades).toBe(1)
|
expect(tablero.conversion.oportunidades).toBe(1)
|
||||||
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
|
// Cancelo no cuenta; Firma Cliente y Escriturado ya son ventas
|
||||||
expect(tablero.conversion.vendidos).toBe(1)
|
expect(tablero.conversion.apartados).toBe(0)
|
||||||
|
expect(tablero.conversion.vendidos).toBe(2)
|
||||||
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
|
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -146,9 +148,9 @@ describe('construirMiTablero', () => {
|
|||||||
|
|
||||||
it('agrupa por desarrollo solo con vendidos', () => {
|
it('agrupa por desarrollo solo con vendidos', () => {
|
||||||
const capturas = [
|
const capturas = [
|
||||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'DIAGONAL' }),
|
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'DIAGONAL' }),
|
||||||
captura({ board: 'APARTADO', etapa: 'ESCRITURADO', desarrollo: 'DIAGONAL' }),
|
captura({ board: 'APARTADO', etapa: 'ESCRITURADO', desarrollo: 'DIAGONAL' }),
|
||||||
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'ATELIER' }),
|
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'ATELIER' }),
|
||||||
captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido
|
captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido
|
||||||
]
|
]
|
||||||
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
const tablero = construirMiTablero(capturas, [], [], AHORA)
|
||||||
|
|||||||
@@ -122,13 +122,19 @@ export function construirMiTablero(
|
|||||||
// -- Conversión (funnel) --
|
// -- Conversión (funnel) --
|
||||||
const leads = capturas.filter((c) => c.board === 'LEADS').length
|
const leads = capturas.filter((c) => c.board === 'LEADS').length
|
||||||
const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length
|
const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length
|
||||||
|
// Apartados ACTIVOS del funnel: ni cancelados (Cancelo/Cancelacion Apartado)
|
||||||
|
// ni ya vendidos (Firma Cliente/Escriturado/Titulado pasan a "vendidos")
|
||||||
const apartados = capturas.filter(
|
const apartados = capturas.filter(
|
||||||
(c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO',
|
(c) =>
|
||||||
|
c.board === 'APARTADO' &&
|
||||||
|
!['FIRMA CLIENTE', 'CANCELO', 'CANCELACION APARTADO', 'ESCRITURADO', 'TITULADO'].includes(
|
||||||
|
c.etapa.toUpperCase(),
|
||||||
|
),
|
||||||
).length
|
).length
|
||||||
const vendidos = capturas.filter(
|
const vendidos = capturas.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.board === 'APARTADO' &&
|
c.board === 'APARTADO' &&
|
||||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
|
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()),
|
||||||
).length
|
).length
|
||||||
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
|
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
|
||||||
|
|
||||||
@@ -184,7 +190,7 @@ export function construirMiTablero(
|
|||||||
for (const c of capturas) {
|
for (const c of capturas) {
|
||||||
if (
|
if (
|
||||||
c.board === 'APARTADO' &&
|
c.board === 'APARTADO' &&
|
||||||
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) &&
|
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) &&
|
||||||
c.desarrollo
|
c.desarrollo
|
||||||
) {
|
) {
|
||||||
const dev = c.desarrollo.trim()
|
const dev = c.desarrollo.trim()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef } from 'react'
|
||||||
import SignatureCanvas from 'react-signature-canvas'
|
import SignatureCanvas from 'react-signature-canvas'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,11 +9,10 @@ interface Props {
|
|||||||
export default function SignaturePad({ onFirma }: Props) {
|
export default function SignaturePad({ onFirma }: Props) {
|
||||||
const ref = useRef<SignatureCanvas | null>(null)
|
const ref = useRef<SignatureCanvas | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
// NOTA: no se notifica '' al montar. El pad se desmonta/remonta cada vez
|
||||||
// Notifica firma vacía al montar (estado inicial)
|
// que el asesor sale y vuelve al paso de firmas, y ese reset borraba la
|
||||||
onFirma('')
|
// firma ya capturada (los PDFs salían sin firma). El estado vacío inicial
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// ya lo maneja el formulario padre.
|
||||||
}, [])
|
|
||||||
|
|
||||||
const limpiar = () => {
|
const limpiar = () => {
|
||||||
ref.current?.clear()
|
ref.current?.clear()
|
||||||
|
|||||||
@@ -337,8 +337,8 @@ export default function AltaClientePage() {
|
|||||||
const res = datos.progresoId
|
const res = datos.progresoId
|
||||||
? await completarProgreso(datos.progresoId, alta)
|
? await completarProgreso(datos.progresoId, alta)
|
||||||
: await crearAltaCliente(alta)
|
: await crearAltaCliente(alta)
|
||||||
toast.success("Alta de cliente completada")
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
toast.success('Alta de cliente completada')
|
||||||
const { fallidos } = await subirDocumentos(res.id, alta)
|
const { fallidos } = await subirDocumentos(res.id, alta)
|
||||||
setRegistro(res.id)
|
setRegistro(res.id)
|
||||||
setSinConexion(false)
|
setSinConexion(false)
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export function crearDocumento(
|
|||||||
doc.setFontSize(9)
|
doc.setFontSize(9)
|
||||||
doc.text('_______________________________', 105, y, { align: 'center' })
|
doc.text('_______________________________', 105, y, { align: 'center' })
|
||||||
y += 5
|
y += 5
|
||||||
doc.text(`Recibió — ${plan.clienteNombre}`, 105, y, { align: 'center' })
|
// Quien recibe el pago es FRASA (el cliente es quien paga)
|
||||||
|
doc.text('Recibió — FRASA DESARROLLOS S.A.P.I. DE C.V.', 105, y, { align: 'center' })
|
||||||
|
|
||||||
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
|
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { crearPlanAsync } from '../services/repository'
|
import { crearPlanAsync, listarProyectosAsync } from '../services/repository'
|
||||||
import { generarCalendario } from '../lib/cobranza'
|
import { generarCalendario } from '../lib/cobranza'
|
||||||
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||||
|
|
||||||
const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL'] as const
|
// Proyecto libre (con sugerencias del datalist): la cartera real incluye
|
||||||
|
// desarrollos que un enum hardcodeado no cubría (ZAFIRO, UMBRAL, SOLE,
|
||||||
|
// DIAGONAL 40, SOLANA I/II...), así que ya no se limita a una lista fija.
|
||||||
|
|
||||||
const esquema = z.object({
|
const esquema = z.object({
|
||||||
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
|
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
|
||||||
clienteTelefono: z.string().trim().optional(),
|
clienteTelefono: z.string().trim().optional(),
|
||||||
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
|
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
|
||||||
proyecto: z.enum(PROYECTOS),
|
proyecto: z.string().trim().min(1, 'Proyecto requerido'),
|
||||||
ventaRef: z.string().trim(),
|
ventaRef: z.string().trim(),
|
||||||
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
|
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
|
||||||
engancheTotal: z
|
engancheTotal: z
|
||||||
@@ -46,6 +48,11 @@ export default function NuevoPlanPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [guardando, setGuardando] = useState(false)
|
const [guardando, setGuardando] = useState(false)
|
||||||
const [errorGuardado, setErrorGuardado] = useState<string | null>(null)
|
const [errorGuardado, setErrorGuardado] = useState<string | null>(null)
|
||||||
|
// Proyectos existentes en la cartera: alimentan el datalist de sugerencias
|
||||||
|
const [proyectosSugeridos, setProyectosSugeridos] = useState<string[]>([])
|
||||||
|
useEffect(() => {
|
||||||
|
void listarProyectosAsync().then(setProyectosSugeridos)
|
||||||
|
}, [])
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@@ -56,7 +63,7 @@ export default function NuevoPlanPage() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
clienteNombre: '',
|
clienteNombre: '',
|
||||||
unidadCodigo: '',
|
unidadCodigo: '',
|
||||||
proyecto: 'SENDA',
|
proyecto: '',
|
||||||
ventaRef: '',
|
ventaRef: '',
|
||||||
fechaInicio: fechaISOHoy(),
|
fechaInicio: fechaISOHoy(),
|
||||||
numPagos: 6,
|
numPagos: 6,
|
||||||
@@ -122,13 +129,19 @@ export default function NuevoPlanPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
|
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
|
||||||
<select id="proyecto" className={claseInput} {...register('proyecto')}>
|
<input
|
||||||
{PROYECTOS.map((p) => (
|
id="proyecto"
|
||||||
<option key={p} value={p}>
|
list="proyectos-cobranza"
|
||||||
{p}
|
className={claseInput}
|
||||||
</option>
|
placeholder="Ej. SENDA, ZAFIRO, DIAGONAL 40…"
|
||||||
|
{...register('proyecto')}
|
||||||
|
/>
|
||||||
|
<datalist id="proyectos-cobranza">
|
||||||
|
{proyectosSugeridos.map((p) => (
|
||||||
|
<option key={p} value={p} />
|
||||||
))}
|
))}
|
||||||
</select>
|
</datalist>
|
||||||
|
{errors.proyecto && <p className={claseError}>{errors.proyecto.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>
|
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>
|
||||||
|
|||||||
@@ -365,3 +365,13 @@ export async function actualizarPlanAsync(
|
|||||||
export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean {
|
export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean {
|
||||||
return mios && resultado.origen === 'api' && resultado.planes.length === 0
|
return mios && resultado.origen === 'api' && resultado.planes.length === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /planes — proyectos distintos de la cartera (datalist del alta de plan) */
|
||||||
|
export async function listarProyectosAsync(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const planes = await request<PlanApi[]>('/planes')
|
||||||
|
return [...new Set(planes.map((p) => p.proyecto).filter((p) => typeof p === 'string' && p.trim() !== ''))].sort()
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calcu
|
|||||||
import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas'
|
import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas'
|
||||||
import {
|
import {
|
||||||
getMisCotizaciones,
|
getMisCotizaciones,
|
||||||
|
getCotizacion,
|
||||||
guardarCotizacion,
|
guardarCotizacion,
|
||||||
urlPublicaCotizacion,
|
urlPublicaCotizacion,
|
||||||
ErrorApiCotizacion,
|
ErrorApiCotizacion,
|
||||||
@@ -103,6 +104,9 @@ export default function CotizadorPage() {
|
|||||||
// ligado a la cotización al guardar; la unidad se preselecciona al cargar
|
// ligado a la cotización al guardar; la unidad se preselecciona al cargar
|
||||||
const [oportunidadId, setOportunidadId] = useState<string | null>(null)
|
const [oportunidadId, setOportunidadId] = useState<string | null>(null)
|
||||||
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
|
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
|
||||||
|
// Retomar una cotización llegada por URL (/quote/?cotizacion=<id>, desde la
|
||||||
|
// búsqueda global): se aplica cuando termina de cargar inventario+reglas
|
||||||
|
const [cotizacionPendiente, setCotizacionPendiente] = useState<string | null>(null)
|
||||||
|
|
||||||
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
|
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
|
||||||
// primero disponible para no dejar el cotizador en un desarrollo muerto.
|
// primero disponible para no dejar el cotizador en un desarrollo muerto.
|
||||||
@@ -189,6 +193,8 @@ export default function CotizadorPage() {
|
|||||||
}
|
}
|
||||||
if (unidadParam) setUnidadPendiente(unidadParam)
|
if (unidadParam) setUnidadPendiente(unidadParam)
|
||||||
if (opId) setOportunidadId(opId)
|
if (opId) setOportunidadId(opId)
|
||||||
|
const cotizacionParam = qs.get('cotizacion')
|
||||||
|
if (cotizacionParam) setCotizacionPendiente(cotizacionParam)
|
||||||
// Solo al montar: es el punto de entrada desde Mi día
|
// Solo al montar: es el punto de entrada desde Mi día
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
@@ -221,6 +227,20 @@ export default function CotizadorPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [unidades, cargando, unidadPendiente])
|
}, [unidades, cargando, unidadPendiente])
|
||||||
|
|
||||||
|
// Retoma la cotización pedida por URL cuando el inventario ya cargó
|
||||||
|
useEffect(() => {
|
||||||
|
if (cotizacionPendiente === null || cargando) return
|
||||||
|
const id = cotizacionPendiente
|
||||||
|
setCotizacionPendiente(null)
|
||||||
|
void getCotizacion(id)
|
||||||
|
.then((c) => {
|
||||||
|
if (c) handleRetomar(c)
|
||||||
|
else setAviso(`No se encontró la cotización entre las tuyas (${id}).`)
|
||||||
|
})
|
||||||
|
.catch(() => setAviso('No se pudo cargar la cotización de la liga.'))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [cotizacionPendiente, cargando, unidades])
|
||||||
|
|
||||||
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
|
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
|
||||||
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
|
||||||
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
|
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
|
||||||
|
|||||||
@@ -176,6 +176,15 @@ export async function getMisCotizaciones(): Promise<CotizacionGuardada[]> {
|
|||||||
return (await resp.json()) as CotizacionGuardada[]
|
return (await resp.json()) as CotizacionGuardada[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /cotizaciones/:id — una cotización del vendedor (retomar desde URL) */
|
||||||
|
export async function getCotizacion(id: string): Promise<CotizacionGuardada | null> {
|
||||||
|
const resp = await fetch(`${API_URL}/cotizaciones/${encodeURIComponent(id)}`, conAuth())
|
||||||
|
manejar401(resp)
|
||||||
|
if (resp.status === 404) return null
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
return (await resp.json()) as CotizacionGuardada
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Búsqueda de clientes para el prellenado del cotizador ----------
|
// ---------- Búsqueda de clientes para el prellenado del cotizador ----------
|
||||||
|
|
||||||
export interface LeadResumen {
|
export interface LeadResumen {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export async function getAuditoria(filtros?: {
|
|||||||
}): Promise<AuditoriaItem[]> {
|
}): Promise<AuditoriaItem[]> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (filtros?.entidad) params.set('entidad', filtros.entidad)
|
if (filtros?.entidad) params.set('entidad', filtros.entidad)
|
||||||
if (filtros?.usuario) params.set('usuario', filtros.usuario)
|
if (filtros?.usuario) params.set('usuarioId', filtros.usuario)
|
||||||
if (filtros?.desde) params.set('desde', filtros.desde)
|
if (filtros?.desde) params.set('desde', filtros.desde)
|
||||||
if (filtros?.hasta) params.set('hasta', filtros.hasta)
|
if (filtros?.hasta) params.set('hasta', filtros.hasta)
|
||||||
const qs = params.toString()
|
const qs = params.toString()
|
||||||
|
|||||||
@@ -209,7 +209,16 @@ function FilaOportunidad({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={enviando}
|
disabled={enviando}
|
||||||
onClick={() => (conCita ? onEtapa('Agendo Cita', fechaCita || undefined) : setConCita(true))}
|
onClick={() =>
|
||||||
|
conCita
|
||||||
|
? // Misma regla que el modal: si la op ya pasó por Agendo Cita
|
||||||
|
// (Cotizador/Aparto) NO se retrocede la etapa al confirmar.
|
||||||
|
onEtapa(
|
||||||
|
op.etapa === 'Cotizador' || op.etapa === 'Aparto' ? op.etapa : 'Agendo Cita',
|
||||||
|
fechaCita || undefined,
|
||||||
|
)
|
||||||
|
: setConCita(true)
|
||||||
|
}
|
||||||
className={CLASE_BOTON_ACCION}
|
className={CLASE_BOTON_ACCION}
|
||||||
>
|
>
|
||||||
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD['Agendo Cita']}
|
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD['Agendo Cita']}
|
||||||
@@ -1144,7 +1153,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
// marcarlo como Escriturado o Titulado
|
// marcarlo como Escriturado o Titulado
|
||||||
e.includes('ESCRITURADO') ||
|
e.includes('ESCRITURADO') ||
|
||||||
e.includes('TITULADO') ||
|
e.includes('TITULADO') ||
|
||||||
e.includes('CANCO')
|
e.includes('CANCELO')
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
@@ -1357,7 +1366,14 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
|
|||||||
if (op) setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })
|
if (op) setDescartando({ id: op.id, nombre: op.nombre, fuente: 'oportunidad' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void ejecutar(() => cambiarEtapaOportunidad(id, estado), 'No se pudo actualizar')
|
// No retroceder etapa al soltar en "Agendo Cita" una
|
||||||
|
// op que ya está en Cotizador/Aparto
|
||||||
|
const op = resumen.misOportunidades.items.find((o) => o.id === id)
|
||||||
|
const destino =
|
||||||
|
estado === 'Agendo Cita' && op && (op.etapa === 'Cotizador' || op.etapa === 'Aparto')
|
||||||
|
? op.etapa
|
||||||
|
: estado
|
||||||
|
void ejecutar(() => cambiarEtapaOportunidad(id, destino), 'No se pudo actualizar')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function exportarLeads(leads: LeadMiDia[], nombreArchivo?: string) {
|
|||||||
Email: l.email,
|
Email: l.email,
|
||||||
Estado: l.estado,
|
Estado: l.estado,
|
||||||
Origen: l.origen,
|
Origen: l.origen,
|
||||||
Desarrollo: '',
|
Desarrollo: l.desarrollo,
|
||||||
'Último cambio': l.ultimoCambio ? `${l.ultimoCambio.detalle} · ${l.ultimoCambio.usuario ?? ''}` : '',
|
'Último cambio': l.ultimoCambio ? `${l.ultimoCambio.detalle} · ${l.ultimoCambio.usuario ?? ''}` : '',
|
||||||
Fuente: l.fuente,
|
Fuente: l.fuente,
|
||||||
}))
|
}))
|
||||||
@@ -43,12 +43,10 @@ export function exportarOportunidades(ops: OportunidadMiDia[], nombreArchivo?: s
|
|||||||
Nombre: o.nombre,
|
Nombre: o.nombre,
|
||||||
Teléfono: o.telefono,
|
Teléfono: o.telefono,
|
||||||
Email: o.email,
|
Email: o.email,
|
||||||
Estado: o.etapa || 'Sin etapa',
|
Etapa: o.etapa || 'Sin etapa',
|
||||||
Origen: '',
|
Origen: o.origen,
|
||||||
Desarrollo: o.desarrollo,
|
Desarrollo: o.desarrollo,
|
||||||
'Último cambio': o.ultimoCambio ? `${o.ultimoCambio.detalle} · ${o.ultimoCambio.usuario ?? ''}` : '',
|
'Último cambio': o.ultimoCambio ? `${o.ultimoCambio.detalle} · ${o.ultimoCambio.usuario ?? ''}` : '',
|
||||||
Fuente: '',
|
|
||||||
Etapa: o.etapa,
|
|
||||||
}))
|
}))
|
||||||
const ws = XLSX.utils.json_to_sheet(rows)
|
const ws = XLSX.utils.json_to_sheet(rows)
|
||||||
XLSX.utils.book_append_sheet(wb, ws, 'Oportunidades')
|
XLSX.utils.book_append_sheet(wb, ws, 'Oportunidades')
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Backup diario FRASA — cron: 17 2 * * * /opt/frasa-ecosystem/scripts/backup.sh
|
# Backup diario FRASA — corre en el HOST (el API vive en systemd, no en Docker).
|
||||||
|
#
|
||||||
|
# crontab: 17 2 * * * /root/frasa-ecosystem/scripts/backup.sh >> /var/log/frasa-backup.log 2>&1
|
||||||
|
#
|
||||||
|
# Copia consistente de la base SQLite (con .backup, no un cat del archivo en
|
||||||
|
# uso), los expedientes subidos y el .env; conserva 30 días.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BACKUP_DIR="/backups"
|
RAIZ="/root/frasa-ecosystem"
|
||||||
|
BACKUP_DIR="/root/backups"
|
||||||
FECHA=$(date +%Y%m%d)
|
FECHA=$(date +%Y%m%d)
|
||||||
RETENCION_DIAS=30
|
RETENCION_DIAS=30
|
||||||
|
DB="$RAIZ/api-gateway/prisma/dev.db"
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
# 1. Base de datos SQLite (copia consistente aunque el API esté escribiendo)
|
||||||
|
sqlite3 "$DB" ".backup '$BACKUP_DIR/frasa_db_$FECHA.sqlite'"
|
||||||
|
|
||||||
# 1. Base de datos SQLite del API (mientras no se migre a Postgres)
|
# 2. Expedientes subidos (INE, firmas, comprobantes)
|
||||||
docker compose exec -T api sh -c 'cat /data/dev.db' > "$BACKUP_DIR/frasa_db_$FECHA.sqlite" || true
|
if [ -d "$RAIZ/api-gateway/uploads" ]; then
|
||||||
|
tar czf "$BACKUP_DIR/uploads_$FECHA.tar.gz" -C "$RAIZ/api-gateway" uploads
|
||||||
|
fi
|
||||||
|
|
||||||
# 2. Postgres (cuando el API migre; hoy falla en silencio si no hay datos)
|
# 3. Configuración de entorno (sin la cual el restore no arranca igual)
|
||||||
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-frasa_user}" "${POSTGRES_DB:-frasa_db}" \
|
cp "$RAIZ/.env" "$BACKUP_DIR/env_$FECHA"
|
||||||
> "$BACKUP_DIR/frasa_pg_$FECHA.sql" 2>/dev/null || rm -f "$BACKUP_DIR/frasa_pg_$FECHA.sql"
|
|
||||||
|
|
||||||
# 3. Expedientes subidos (INE, firmas, comprobantes)
|
|
||||||
docker compose exec -T api sh -c 'cd /data && tar czf - uploads' > "$BACKUP_DIR/uploads_$FECHA.tar.gz" || true
|
|
||||||
|
|
||||||
# 4. Retención
|
# 4. Retención
|
||||||
find "$BACKUP_DIR" -name "frasa_*" -mtime +$RETENCION_DIAS -delete
|
find "$BACKUP_DIR" \( -name "frasa_db_*" -o -name "uploads_*" -o -name "env_*" \) -mtime +"$RETENCION_DIAS" -delete
|
||||||
find "$BACKUP_DIR" -name "uploads_*" -mtime +$RETENCION_DIAS -delete
|
|
||||||
|
|
||||||
echo "[$(date -Is)] backup completo en $BACKUP_DIR"
|
echo "[$(date -Is)] backup completo: $BACKUP_DIR (db + uploads + env)"
|
||||||
|
|||||||
Reference in New Issue
Block a user