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:
2026-09-24 10:54:09 +00:00
parent cfc0acb5d3
commit 8a2584438d
30 changed files with 258 additions and 102 deletions

View File

@@ -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,

View File

@@ -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) }),
}), }),
}), }),
) )

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 },
}) })

View File

@@ -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,
}) })

View File

@@ -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({

View File

@@ -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')
}) })

View File

@@ -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,

View File

@@ -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)
}) })
}) })

View File

@@ -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,
} }
} }

View File

@@ -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

View File

@@ -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'] },
}, },
}) })
}) })

View File

@@ -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

View File

@@ -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',

View File

@@ -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)) {

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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` }
} }

View File

@@ -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>

View File

@@ -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 []
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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')
}} }}
/> />
) : ( ) : (

View File

@@ -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')

View File

@@ -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)"