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' } } },
})
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({
id: conversacion.id,
createdAt: conversacion.createdAt,

View File

@@ -68,7 +68,7 @@ describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => {
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
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 {
apartado: {
id: captura.leadId,
@@ -545,6 +561,7 @@ async function prefillApartadoLocal(itemId: string) {
Email: cliente.email ?? '',
CURP: cliente.curp,
RFC: cliente.rfc,
...contactoExtra,
}
: null,
oportunidad: oportunidadLocal

View File

@@ -149,8 +149,12 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
mondayItemId = opExistente
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
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', {
sombra: !esIdLocal(opExistente),
sombra: false,
usuarioId: req.usuarioToken?.sub,
})
} catch (err) {

View File

@@ -339,9 +339,11 @@ apiRouter.post('/ai/chat/stream', chatIAStream)
apiRouter.get('/ai/conversations', listarConversaciones)
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
apiRouter.post('/ai/documents', subirDocumento)
apiRouter.get('/ai/documents', listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
// Mantenimiento de la base de conocimiento: SOLO admin (admin/manager no
// basta: borrar o inyectar documentos afecta a todos los usuarios del chat).
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
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)

View File

@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { AppError, NotFoundError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js'
import { getUnidadesLocal } from './unidades-local.service.js'
import {
APARTADO_DEFAULT,
@@ -1020,10 +1021,12 @@ export function crearEjecutoresHerramientas(
})
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
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) => {
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 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)
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
const abonos = p.pagos
@@ -1054,7 +1057,8 @@ export function crearEjecutoresHerramientas(
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
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: {
monto: true,
montoPagado: true,
@@ -1505,6 +1509,18 @@ export async function chat(
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
: null
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({
data: { usuarioId: opciones.usuarioId ?? null },
})

View File

@@ -94,7 +94,8 @@ describe('buscar', () => {
prismaMock.usuario.findMany.mockResolvedValue([])
await buscar({ desde, hasta })
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' },
take: 100,
})

View File

@@ -43,7 +43,9 @@ export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
where['createdAt'] = {
...(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({

View File

@@ -155,8 +155,8 @@ describe('buscar (GET /api/buscar)', () => {
])
const r = await buscar('X')
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/app/#leads')
expect(r.find((i) => i.tipo === 'contacto')?.url).toBe('/app/#clientes')
expect(r.find((i) => i.tipo === 'lead')?.url).toBe('/hoy/')
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 === 'unidad')?.url).toBe('/quote/?unidad=X')
})

View File

@@ -88,14 +88,14 @@ export async function buscar(
id: l.leadId,
nombre: l.nombreLead,
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
url: `/app/#leads`,
url: `/hoy/`,
})),
...clientes.map((c) => ({
tipo: 'contacto' as const,
id: c.id,
nombre: c.nombreCompleto,
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
url: `/app/#clientes`,
url: `/`,
})),
...cotizaciones.map((ct) => ({
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 () => {
// 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(
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) }),
@@ -225,13 +226,12 @@ describe('listarContactosLocal', () => {
expect(pagina1.contactos).toHaveLength(100)
expect(pagina1.hayMas).toBe(true)
// La página 2 recibe el siguiente bloque (skip=100, take=101)
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c101', celular: '4770000100' }),
])
// La página 2 trae la fila más vieja (orden desc por fecha: la 101 va
// primero en la página 1)
const pagina2 = await listarContactosLocal(undefined, 2)
expect(pagina2.pagina).toBe(2)
expect(pagina2.contactos).toHaveLength(1)
expect(pagina2.contactos[0]?.id).toBe('c1')
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 paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
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({
where: usuarioId ? { capturadoPorId: usuarioId } : {},
orderBy: { createdAt: 'desc' },
skip,
take: porPagina + 1,
})
const capturas = await prisma.leadCaptura.findMany({
where: {
@@ -105,8 +105,6 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
...(usuarioId ? { usuarioId } : {}),
},
orderBy: { updatedAt: 'desc' },
skip,
take: porPagina + 1,
})
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 hayMas = ordenada.length > porPagina
return {
contactos: ordenada.slice(0, porPagina),
contactos: ordenada.slice(skip, skip + porPagina),
pagina: paginaValida,
porPagina,
hayMas,
hayMas: ordenada.length > skip + porPagina,
}
}

View File

@@ -124,27 +124,11 @@ export class CrmLocalService {
if (oportunidadId !== undefined) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
// Sin fila local (id heredado del CRM anterior): se crea la sombra con
// ese id para que la captura no se pierda en silencio.
// Sin fila local: id inválido o liga guardada de una limpieza anterior.
// Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se
// rechaza para no sembrar registros huérfanos en Oportunidades.
if (!existente) {
await prisma.leadCaptura.create({
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 }
throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`)
}
// Sólo se pisan los campos capturados: lo que no viene en el payload se

View File

@@ -137,6 +137,8 @@ describe('obtenerScores', () => {
where: {
board: { in: ['LEADS', 'OPORTUNIDADES'] },
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> = {
NUEVO: 20,
'INTENTO DE CONTACTO': 30,
CONTACTADO: 40,
INTERESADO: 60,
AGENDO: 80,
'AGENDO CITA': 80,
CALIFICADO: 90,
}
@@ -74,6 +75,8 @@ export function calcularScore(lead: {
export async function obtenerScores(usuarioId?: string): Promise<LeadScore[]> {
const where: Record<string, unknown> = {
board: { in: ['LEADS', 'OPORTUNIDADES'] },
// Los cerrados (descartados/perdidos) no reciben score ni salen en el ranking
etapa: { notIn: ['Descartado', 'Perdido'] },
}
if (usuarioId) {
where['usuarioId'] = usuarioId

View File

@@ -1,4 +1,11 @@
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 {
RecordatoriosCobranzaService,
@@ -231,7 +238,29 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
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 = [
plan({
id: 'p1',

View File

@@ -130,10 +130,11 @@ export function debeEnviar(
export interface DestinatarioRecordatorio {
/** usuarioId del vendedor responsable; null = no hay a quién asignar */
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 {
if (vendedorId !== null) return { vendedorId, origen: 'vendedor' }
return { vendedorId: null, origen: 'ninguno' }
@@ -218,9 +219,21 @@ export class RecordatoriosCobranzaService {
const planes = await this.store.cargarPlanes()
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: [] }
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)
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {

View File

@@ -77,15 +77,17 @@ describe('construirMiTablero', () => {
captura({ board: 'LEADS' }),
captura({ board: 'LEADS' }),
captura({ board: 'OPORTUNIDADES' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE' }),
captura({ board: 'APARTADO', etapa: 'CANCELO' }),
captura({ board: 'APARTADO', etapa: 'ESCRITURADO' }),
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
]
const tablero = construirMiTablero(capturas, [], [], AHORA)
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
expect(tablero.conversion.oportunidades).toBe(1)
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
expect(tablero.conversion.vendidos).toBe(1)
// Cancelo no cuenta; Firma Cliente y Escriturado ya son ventas
expect(tablero.conversion.apartados).toBe(0)
expect(tablero.conversion.vendidos).toBe(2)
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
})
@@ -146,9 +148,9 @@ describe('construirMiTablero', () => {
it('agrupa por desarrollo solo con vendidos', () => {
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: 'FIRMA CLIENTE (VENTA)', desarrollo: 'ATELIER' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'ATELIER' }),
captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido
]
const tablero = construirMiTablero(capturas, [], [], AHORA)

View File

@@ -122,13 +122,19 @@ export function construirMiTablero(
// -- Conversión (funnel) --
const leads = capturas.filter((c) => c.board === 'LEADS').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(
(c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO',
(c) =>
c.board === 'APARTADO' &&
!['FIRMA CLIENTE', 'CANCELO', 'CANCELACION APARTADO', 'ESCRITURADO', 'TITULADO'].includes(
c.etapa.toUpperCase(),
),
).length
const vendidos = capturas.filter(
(c) =>
c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()),
).length
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
@@ -184,7 +190,7 @@ export function construirMiTablero(
for (const c of capturas) {
if (
c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) &&
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) &&
c.desarrollo
) {
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'
interface Props {
@@ -9,11 +9,10 @@ interface Props {
export default function SignaturePad({ onFirma }: Props) {
const ref = useRef<SignatureCanvas | null>(null)
useEffect(() => {
// Notifica firma vacía al montar (estado inicial)
onFirma('')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// NOTA: no se notifica '' al montar. El pad se desmonta/remonta cada vez
// que el asesor sale y vuelve al paso de firmas, y ese reset borraba la
// firma ya capturada (los PDFs salían sin firma). El estado vacío inicial
// ya lo maneja el formulario padre.
const limpiar = () => {
ref.current?.clear()

View File

@@ -337,8 +337,8 @@ export default function AltaClientePage() {
const res = datos.progresoId
? await completarProgreso(datos.progresoId, alta)
: await crearAltaCliente(alta)
toast.success("Alta de cliente completada")
if (res.ok) {
toast.success('Alta de cliente completada')
const { fallidos } = await subirDocumentos(res.id, alta)
setRegistro(res.id)
setSinConexion(false)

View File

@@ -92,7 +92,8 @@ export function crearDocumento(
doc.setFontSize(9)
doc.text('_______________________________', 105, y, { align: 'center' })
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` }
}

View File

@@ -1,20 +1,22 @@
import toast from "react-hot-toast"
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useNavigate } from 'react-router-dom'
import { crearPlanAsync } from '../services/repository'
import { crearPlanAsync, listarProyectosAsync } from '../services/repository'
import { generarCalendario } from '../lib/cobranza'
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({
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
clienteTelefono: z.string().trim().optional(),
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(),
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
engancheTotal: z
@@ -46,6 +48,11 @@ export default function NuevoPlanPage() {
const navigate = useNavigate()
const [guardando, setGuardando] = useState(false)
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 {
register,
handleSubmit,
@@ -56,7 +63,7 @@ export default function NuevoPlanPage() {
defaultValues: {
clienteNombre: '',
unidadCodigo: '',
proyecto: 'SENDA',
proyecto: '',
ventaRef: '',
fechaInicio: fechaISOHoy(),
numPagos: 6,
@@ -122,13 +129,19 @@ export default function NuevoPlanPage() {
</div>
<div>
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
<select id="proyecto" className={claseInput} {...register('proyecto')}>
{PROYECTOS.map((p) => (
<option key={p} value={p}>
{p}
</option>
<input
id="proyecto"
list="proyectos-cobranza"
className={claseInput}
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>
<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 {
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 {
getMisCotizaciones,
getCotizacion,
guardarCotizacion,
urlPublicaCotizacion,
ErrorApiCotizacion,
@@ -103,6 +104,9 @@ export default function CotizadorPage() {
// ligado a la cotización al guardar; la unidad se preselecciona al cargar
const [oportunidadId, setOportunidadId] = 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
// primero disponible para no dejar el cotizador en un desarrollo muerto.
@@ -189,6 +193,8 @@ export default function CotizadorPage() {
}
if (unidadParam) setUnidadPendiente(unidadParam)
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
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -221,6 +227,20 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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
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

View File

@@ -176,6 +176,15 @@ export async function getMisCotizaciones(): Promise<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 ----------
export interface LeadResumen {

View File

@@ -188,7 +188,7 @@ export async function getAuditoria(filtros?: {
}): Promise<AuditoriaItem[]> {
const params = new URLSearchParams()
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?.hasta) params.set('hasta', filtros.hasta)
const qs = params.toString()

View File

@@ -209,7 +209,16 @@ function FilaOportunidad({
<button
type="button"
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}
>
{conCita ? 'Confirmar cita' : ETIQUETA_FASE_OPORTUNIDAD['Agendo Cita']}
@@ -1144,7 +1153,7 @@ export default function MiDiaPage({ accionesSesion }: { accionesSesion: Acciones
// marcarlo como Escriturado o Titulado
e.includes('ESCRITURADO') ||
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' })
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,
Estado: l.estado,
Origen: l.origen,
Desarrollo: '',
Desarrollo: l.desarrollo,
'Último cambio': l.ultimoCambio ? `${l.ultimoCambio.detalle} · ${l.ultimoCambio.usuario ?? ''}` : '',
Fuente: l.fuente,
}))
@@ -43,12 +43,10 @@ export function exportarOportunidades(ops: OportunidadMiDia[], nombreArchivo?: s
Nombre: o.nombre,
Teléfono: o.telefono,
Email: o.email,
Estado: o.etapa || 'Sin etapa',
Origen: '',
Etapa: o.etapa || 'Sin etapa',
Origen: o.origen,
Desarrollo: o.desarrollo,
'Último cambio': o.ultimoCambio ? `${o.ultimoCambio.detalle} · ${o.ultimoCambio.usuario ?? ''}` : '',
Fuente: '',
Etapa: o.etapa,
}))
const ws = XLSX.utils.json_to_sheet(rows)
XLSX.utils.book_append_sheet(wb, ws, 'Oportunidades')

View File

@@ -1,26 +1,32 @@
#!/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
BACKUP_DIR="/backups"
RAIZ="/root/frasa-ecosystem"
BACKUP_DIR="/root/backups"
FECHA=$(date +%Y%m%d)
RETENCION_DIAS=30
DB="$RAIZ/api-gateway/prisma/dev.db"
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)
docker compose exec -T api sh -c 'cat /data/dev.db' > "$BACKUP_DIR/frasa_db_$FECHA.sqlite" || true
# 2. Expedientes subidos (INE, firmas, comprobantes)
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)
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-frasa_user}" "${POSTGRES_DB:-frasa_db}" \
> "$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
# 3. Configuración de entorno (sin la cual el restore no arranca igual)
cp "$RAIZ/.env" "$BACKUP_DIR/env_$FECHA"
# 4. Retención
find "$BACKUP_DIR" -name "frasa_*" -mtime +$RETENCION_DIAS -delete
find "$BACKUP_DIR" -name "uploads_*" -mtime +$RETENCION_DIAS -delete
find "$BACKUP_DIR" \( -name "frasa_db_*" -o -name "uploads_*" -o -name "env_*" \) -mtime +"$RETENCION_DIAS" -delete
echo "[$(date -Is)] backup completo en $BACKUP_DIR"
echo "[$(date -Is)] backup completo: $BACKUP_DIR (db + uploads + env)"