Files
FRASA-CAS/api-gateway/src/services/ai.service.ts
Consult AS Technology 8a2584438d 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.
2026-09-24 10:54:09 +00:00

1577 lines
60 KiB
TypeScript

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,
calcularPropuesta,
obtenerPrecioLista,
TIPO_PROPUESTA_API,
totalPagado,
type TipoPropuestaApi,
type UnidadCotizable,
} from './cotizacion.service.js'
// ---------- Cliente NAN (API OpenAI-compatible: chat + embeddings) ----------
export interface MensajeChat {
rol: 'system' | 'user' | 'assistant'
contenido: string
}
// Mensaje en el formato crudo de la API (incluye tool calls y respuestas de tools)
export interface MensajeApi {
role: 'system' | 'user' | 'assistant' | 'tool'
content: string | null
tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[]
tool_call_id?: string
}
export interface ToolCallSolicitado {
id: string
nombre: string
argumentosJson: string
}
export interface RespuestaCruda {
contenido: string | null
toolCalls: ToolCallSolicitado[]
}
/** Definición de herramienta en el formato function-calling de OpenAI */
export interface HerramientaDef {
type: 'function'
function: { name: string; description: string; parameters: Record<string, unknown> }
}
export interface NanClient {
chat(mensajes: MensajeChat[]): Promise<string>
embed(textos: string[]): Promise<number[][]>
/** Chat con herramientas (function calling); opcional para compat con clientes simples */
chatConTools?(
mensajes: MensajeApi[],
herramientas: HerramientaDef[],
onDelta?: (textoParcial: string) => void,
): Promise<RespuestaCruda>
}
interface NanOpciones {
apiKey: string
baseUrl: string
chatModel: string
embedModel: string
}
async function postJson(url: string, apiKey: string, body: unknown): Promise<unknown> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
})
if (!res.ok) {
const detalle = await res.text().catch(() => '')
throw new AppError(502, `Proveedor IA respondió ${res.status}: ${detalle.slice(0, 200)}`)
}
return res.json()
}
/** Acumulador de un stream OpenAI-compatible (deltas de texto y de tool_calls) */
async function leerStream(
res: globalThis.Response,
onDelta: (texto: string) => void,
): Promise<{ contenido: string; toolCalls: ToolCallSolicitado[] }> {
const reader = res.body?.getReader()
if (!reader) throw new AppError(502, 'El proveedor IA no devolvió stream')
const decoder = new TextDecoder()
let buffer = ''
let contenido = ''
const toolCalls: ToolCallSolicitado[] = []
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lineas = buffer.split('\n')
buffer = lineas.pop() ?? ''
for (const linea of lineas) {
const limpia = linea.trim()
if (!limpia.startsWith('data:')) continue
const payload = limpia.slice(5).trim()
if (payload === '' || payload === '[DONE]') continue
let json: {
choices?: {
delta?: {
content?: string | null
tool_calls?: { index?: number; id?: string; function?: { name?: string; arguments?: string } }[]
}
}[]
}
try {
json = JSON.parse(payload) as typeof json
} catch {
continue
}
const delta = json.choices?.[0]?.delta
if (!delta) continue
if (typeof delta.content === 'string' && delta.content !== '') {
contenido += delta.content
onDelta(delta.content)
}
for (const tc of delta.tool_calls ?? []) {
const idx = tc.index ?? 0
toolCalls[idx] ??= { id: tc.id ?? `call_${idx}`, nombre: '', argumentosJson: '' }
if (tc.id) toolCalls[idx]!.id = tc.id
if (tc.function?.name) toolCalls[idx]!.nombre += tc.function.name
if (tc.function?.arguments) toolCalls[idx]!.argumentosJson += tc.function.arguments
}
}
}
return { contenido, toolCalls: toolCalls.filter((t): t is ToolCallSolicitado => t !== undefined) }
}
export function crearNanClient(opciones: NanOpciones): NanClient {
return {
async chat(mensajes) {
const data = (await postJson(`${opciones.baseUrl}/chat/completions`, opciones.apiKey, {
model: opciones.chatModel,
messages: mensajes.map((m) => ({ role: m.rol, content: m.contenido })),
temperature: 0.2,
})) as { choices?: { message?: { content?: string } }[] }
const contenido = data.choices?.[0]?.message?.content
if (!contenido) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
return contenido
},
async chatConTools(mensajes, herramientas, onDelta) {
const body: Record<string, unknown> = {
model: opciones.chatModel,
messages: mensajes,
temperature: 0.2,
}
// Sin herramientas registradas el proveedor responde texto plano (cierre del loop)
if (herramientas.length > 0) {
body['tools'] = herramientas
body['tool_choice'] = 'auto'
}
if (onDelta) body['stream'] = true
const res = await fetch(`${opciones.baseUrl}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opciones.apiKey}` },
body: JSON.stringify(body),
})
if (!res.ok) {
const detalle = await res.text().catch(() => '')
throw new AppError(502, `Proveedor IA respondió ${res.status}: ${detalle.slice(0, 200)}`)
}
// Streaming real cuando el proveedor lo soporta; si devuelve JSON, se usa tal cual
const tipo = res.headers.get('content-type') ?? ''
if (onDelta && tipo.includes('text/event-stream')) {
const { contenido, toolCalls } = await leerStream(res, onDelta)
return { contenido: contenido === '' ? null : contenido, toolCalls }
}
const data = (await res.json()) as {
choices?: {
message?: {
content?: string | null
tool_calls?: { id: string; type: string; function: { name: string; arguments: string } }[]
}
}[]
}
const message = data.choices?.[0]?.message
if (!message) throw new AppError(502, 'El proveedor IA no devolvió respuesta')
const toolCalls = (message.tool_calls ?? [])
.filter((t) => t.type === 'function')
.map((t) => ({ id: t.id, nombre: t.function.name, argumentosJson: t.function.arguments }))
return { contenido: message.content ?? null, toolCalls }
},
async embed(textos) {
if (textos.length === 0) return []
const data = (await postJson(`${opciones.baseUrl}/embeddings`, opciones.apiKey, {
model: opciones.embedModel,
input: textos,
})) as { data?: { embedding?: number[] }[] }
const embeddings = (data.data ?? []).map((d) => d.embedding ?? [])
if (embeddings.length !== textos.length || embeddings.some((e) => e.length === 0)) {
throw new AppError(502, 'El proveedor IA no devolvió los embeddings esperados')
}
return embeddings
},
}
}
// Singleton perezoso: no exige NAN_API_KEY en procesos que no usan IA
let clientePorDefecto: NanClient | null = null
function clienteNan(): NanClient {
clientePorDefecto ??= crearNanClient({
apiKey: config.ai.apiKey,
baseUrl: config.ai.baseUrl,
chatModel: config.ai.chatModel,
embedModel: config.ai.embedModel,
})
return clientePorDefecto
}
// ---------- Chunking (por párrafos, con solape) ----------
const TAMANO_CHUNK = 700
const SOLAPE_CHUNK = 120
/** Corta un texto largo en pedazos de ≤ tamanoMax con solape de caracteres */
function cortarDuro(texto: string, tamanoMax: number, solape: number): string[] {
const pedazos: string[] = []
let i = 0
while (i < texto.length) {
pedazos.push(texto.slice(i, i + tamanoMax))
i += tamanoMax - solape
}
return pedazos
}
/**
* Fragmenta un documento en chunks de ~tamanoMax caracteres respetando los
* párrafos (separados por líneas en blanco); un párrafo que excede el tamaño
* se corta duro. Cada chunk nuevo arranca con la cola del anterior (solape).
*/
export function fragmentar(texto: string, tamanoMax = TAMANO_CHUNK, solape = SOLAPE_CHUNK): string[] {
const parrafos = texto
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter((p) => p.length > 0)
const chunks: string[] = []
let actual = ''
const cerrar = () => {
if (actual.trim().length > 0) chunks.push(actual.trim())
actual = actual.slice(-solape)
}
for (const parrafo of parrafos) {
if (parrafo.length > tamanoMax) {
cerrar()
chunks.push(...cortarDuro(parrafo, tamanoMax, solape))
actual = (chunks[chunks.length - 1] ?? '').slice(-solape)
continue
}
const candidato = actual.length > 0 ? `${actual}\n\n${parrafo}` : parrafo
if (candidato.length > tamanoMax && actual.trim().length > 0) {
cerrar()
actual = `${actual} ${parrafo}`
} else {
actual = candidato
}
}
if (actual.trim().length > 0) chunks.push(actual.trim())
return chunks
}
// ---------- Similitud coseno y ranking ----------
export function similitudCoseno(a: number[], b: number[]): number {
let dot = 0
let normA = 0
let normB = 0
for (let i = 0; i < a.length; i++) {
const va = a[i] ?? 0
const vb = b[i] ?? 0
dot += va * vb
normA += va * va
normB += vb * vb
}
if (normA === 0 || normB === 0) return 0
return dot / (Math.sqrt(normA) * Math.sqrt(normB))
}
export interface ChunkRecuperado {
titulo: string
texto: string
score: number
}
export interface ChunkIndexado {
titulo: string
texto: string
embedding: number[]
}
/** Top-k por coseno, filtrado por umbral mínimo, ordenado de mayor a menor score */
export function rankearChunks(
chunks: ChunkIndexado[],
consulta: number[],
topK = 4,
minScore = config.ai.minScore,
): ChunkRecuperado[] {
return chunks
.map((c) => ({ titulo: c.titulo, texto: c.texto, score: similitudCoseno(consulta, c.embedding) }))
.filter((c) => c.score >= minScore)
.sort((a, b) => b.score - a.score)
.slice(0, topK)
}
// ---------- Prompt ----------
export function construirSystemPrompt(contexto: ChunkRecuperado[]): string {
const bloques = contexto
.map((c, i) => `[${i + 1}] Fuente: ${c.titulo}\n${c.texto}`)
.join('\n\n---\n\n')
return [
'Eres Chat Frasa, el asistente interno de Frasa Desarrollos (desarrolladora de vivienda en Tijuana, B.C.).',
'Atiendes a vendedores, administración y dirección: tu trabajo es responder CUALQUIER pregunta sobre el',
'negocio — catálogo, precios, clientes, leads, apartados, escrituración, cobranza, obra, citas y soporte.',
'',
'FUENTES DE INFORMACIÓN (en orden de prioridad):',
'1) HERRAMIENTAS: son la fuente viva del sistema. Úsalas SIEMPRE que la pregunta sea sobre datos',
' concretos (personas, unidades, dinero, fechas, cantidades). Nunca inventes cifras, nombres ni fechas.',
'2) CONTEXTO (documentos internos): úsalo para procesos, políticas, requisitos y explicaciones.',
'',
'HERRAMIENTAS DISPONIBLES:',
'- buscar_unidad / contar_unidades: inventario por desarrollo, modelo, precio y estatus.',
'- cotizar_unidad: números de una unidad (enganche, mensualidades, pago final).',
'- buscar_contactos: cualquier persona del embudo con su perfilamiento completo (nombre, teléfono o email).',
'- ver_cliente: ficha completa de un cliente del alta (CURP, RFC, proyecto, unidad, cotizaciones).',
'- ver_bitacora: notas y seguimientos de un contacto, con fecha y autor.',
'- ver_apartados / ver_escrituracion: pipeline Enkontrol — fase, cliente, unidad y fechas.',
'- ver_cobranza: sin parámetros da el resumen global; con el nombre del cliente, su estado de cuenta',
' (pagado, saldo, abonos con fecha, vencidos y próximo pago).',
'- ver_captura: datos por departamento de una unidad o desarrollo (obra, ventas, escrituración, finanzas,',
' postventa) incluyendo trámites de obra vencidos.',
'- ver_calendario: citas y actividades de los próximos días.',
'- ver_soporte: solicitudes internas por estado y departamento.',
'- estadisticas: conteos y totales (leads nuevos por periodo y vendedor, pipeline por etapa, afluencias',
' que califican, inventario por estatus, resumen de cobranza y ranking de vendedores).',
'',
'CÓMO RESPONDER:',
'- Piensa qué datos necesitas y llama a las herramientas necesarias (puedes encadenar varias).',
'- Si una herramienta devuelve error o vacío, dilo con honestidad y sugiere la alternativa.',
'- Presenta los números con formato claro (moneda MXN con separador de miles y 2 decimales; fechas dd/mm/aaaa).',
'- Puedes usar tablas markdown cuando haya varios registros.',
'- Al final, si usaste documentos del contexto, cita las fuentes así: "Fuentes: <título 1>, <título 2>".',
'- Responde en español, claro y breve; explica los términos técnicos en lenguaje sencillo.',
'- Las cotizaciones son ORIENTATIVAS: el PDF oficial se genera en el cotizador (/quote/).',
'- No repitas datos personales innecesarios; el usuario ya trabaja con esos clientes.',
'',
contexto.length > 0
? `CONTEXTO:\n\n${bloques}`
: 'CONTEXTO: (vacío — no se encontró información relevante en la base de conocimiento)',
].join('\n')
}
// ---------- Indexación de documentos (dedup por hash) ----------
export function hashContenido(contenido: string): string {
return crypto.createHash('sha256').update(contenido, 'utf8').digest('hex')
}
export type AccionIndexacion = 'crear' | 'actualizar' | 'omitir'
/** Decide qué hacer con un documento según su hash: idempotente si no cambió */
export function decidirIndexacion(
existente: { hash: string } | null,
hashNuevo: string,
): AccionIndexacion {
if (!existente) return 'crear'
return existente.hash === hashNuevo ? 'omitir' : 'actualizar'
}
export interface ResultadoIndexacion {
titulo: string
estado: 'creado' | 'actualizado' | 'sin-cambios'
chunks: number
}
/** Indexa (o re-indexa si cambió el contenido) un documento de la base de conocimiento */
export async function indexarDocumento(
titulo: string,
contenido: string,
client: NanClient = clienteNan(),
): Promise<ResultadoIndexacion> {
const hash = hashContenido(contenido)
const existente = await prisma.aIDocumento.findUnique({
where: { titulo },
include: { chunks: { select: { id: true } } },
})
const accion = decidirIndexacion(existente, hash)
if (accion === 'omitir' && existente) {
return { titulo, estado: 'sin-cambios', chunks: existente.chunks.length }
}
const textos = fragmentar(contenido)
const embeddings = await client.embed(textos)
if (accion === 'actualizar' && existente) {
await prisma.aIChunk.deleteMany({ where: { documentoId: existente.id } })
await prisma.aIDocumento.update({
where: { id: existente.id },
data: {
contenido,
hash,
chunks: {
create: textos.map((texto, orden) => ({
texto,
orden,
embedding: JSON.stringify(embeddings[orden] ?? []),
})),
},
},
})
invalidarCacheChunks()
return { titulo, estado: 'actualizado', chunks: textos.length }
}
await prisma.aIDocumento.create({
data: {
titulo,
contenido,
hash,
chunks: {
create: textos.map((texto, orden) => ({
texto,
orden,
embedding: JSON.stringify(embeddings[orden] ?? []),
})),
},
},
})
invalidarCacheChunks()
return { titulo, estado: 'creado', chunks: textos.length }
}
// ---------- Herramientas (function calling) ----------
import { VOCABULARIO } from '../config/vocabulario.js'
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
const TIPOS_PROPUESTA_VALIDOS = Object.keys(TIPO_PROPUESTA_API) as TipoPropuestaApi[]
/** Contexto del usuario que pregunta: escopa los datos por rol */
export interface ContextoUsuario {
sub: string
rol: string
}
/** admin / director_ventas / direccion_general ven los datos de todos */
export function esDireccion(rol: string | undefined): boolean {
return (
rol === undefined || rol === 'admin' || rol === 'manager' || rol === 'director_ventas' || rol === 'direccion_general'
)
}
const FASES_APARTADO: readonly string[] = VOCABULARIO.FASES_APARTADO
const FASES_ESCRITURACION: readonly string[] = VOCABULARIO.FASES_ESCRITURACION
export const HERRAMIENTAS: HerramientaDef[] = [
{
type: 'function',
function: {
name: 'cotizar_unidad',
description:
'Cotiza una unidad con el motor del cotizador (enganche, mensualidad, pago final, total). ' +
'Úsala cuando pidan números de una unidad concreta. Requiere el código de la unidad (p. ej. SOLANA-6).',
parameters: {
type: 'object',
properties: {
codigo: { type: 'string', description: 'Código de la unidad, p. ej. SOLANA-6' },
tipoPropuesta: {
type: 'string',
enum: TIPOS_PROPUESTA_VALIDOS,
description: 'Esquema de pago; default BANCARIO',
},
apartado: { type: 'number', description: `Monto del apartado en MXN; default ${APARTADO_DEFAULT}` },
acabados: { type: 'boolean', description: 'Incluir cocina y closet (default false)' },
},
required: ['codigo'],
},
},
},
{
type: 'function',
function: {
name: 'buscar_unidad',
description:
'Busca hasta 5 unidades del inventario con filtros (desarrollo, modelo, precio máximo, estatus). ' +
'Por default solo DISPONIBLES; usa estatus para ver APARTADO, VENDIDO, ESCRITURADO o BLOQUEADO.',
parameters: {
type: 'object',
properties: {
desarrollo: { type: 'string', description: 'Desarrollo: SENDA, ATELIER, ESTANZA, DIAGONAL, SOLANA, SOLE, MUTUO' },
modelo: { type: 'string', description: 'Texto contenido en el nombre del modelo' },
precioMax: { type: 'number', description: 'Precio de lista máximo en MXN (sin acabados)' },
estatus: { type: 'string', description: 'Estatus exacto (DISPONIBLE por default)' },
},
},
},
},
{
type: 'function',
function: {
name: 'contar_unidades',
description:
'Cuenta unidades del inventario por desarrollo y estatus (p. ej. cuántas vendidas hay en SENDA). ' +
'Devuelve totales y el desglose por estatus.',
parameters: {
type: 'object',
properties: {
desarrollo: { type: 'string', description: 'Desarrollo a contar (opcional: todos)' },
estatus: { type: 'string', description: 'Filtrar por un estatus exacto (opcional)' },
},
},
},
},
{
type: 'function',
function: {
name: 'buscar_contactos',
description:
'Busca contactos (leads, afluencias, apartados) por nombre, teléfono o email, con su perfilamiento ' +
'completo (edad, estado civil, ocupación, rango salarial, crédito, califica, dependientes, para qué lo quiere). ' +
'Opcionalmente filtra por etapa o desarrollo. Úsala para cualquier pregunta sobre una persona.',
parameters: {
type: 'object',
properties: {
busqueda: { type: 'string', description: 'Nombre, teléfono o email (parcial)' },
etapa: { type: 'string', description: 'Etapa exacta del embudo (opcional)' },
desarrollo: { type: 'string', description: 'Desarrollo de interés (opcional)' },
limite: { type: 'number', description: 'Máximo de resultados (default 10, tope 25)' },
},
},
},
},
{
type: 'function',
function: {
name: 'ver_cliente',
description:
'Información completa de un cliente del alta: datos personales, proyecto, unidad, cotizaciones y su lead.',
parameters: {
type: 'object',
properties: { nombre: { type: 'string', description: 'Nombre del cliente (búsqueda parcial)' } },
required: ['nombre'],
},
},
},
{
type: 'function',
function: {
name: 'ver_cobranza',
description:
'Sin parámetros: resumen global de cobranza (planes activos, adeudo, vencidos, detalle). ' +
'Con `cliente`: el estado de cuenta de ese cliente (plan, pagado, saldo, abonos con fecha, mora y próximo pago).',
parameters: {
type: 'object',
properties: { cliente: { type: 'string', description: 'Nombre del cliente (parcial), opcional' } },
},
},
},
{
type: 'function',
function: {
name: 'ver_apartados',
description:
'Apartados en proceso (pipeline Enkontrol) con su fase, cliente, unidad y vendedor. ' +
'Opcionalmente filtra por fase: ' + FASES_APARTADO.join(', '),
parameters: {
type: 'object',
properties: { fase: { type: 'string', description: 'Fase exacta del apartado (opcional)' } },
},
},
},
{
type: 'function',
function: {
name: 'ver_escrituracion',
description:
'Clientes en escrituración con su estatus (Venta, Seguimiento, Integración, Autorizado, ' +
'X Escriturar, Escriturado, Titulado, Cancelo) y las fechas de cada uno.',
parameters: { type: 'object', properties: {} },
},
},
{
type: 'function',
function: {
name: 'ver_captura',
description:
'Datos de captura por departamento (obra, ventas, escrituración, finanzas, postventa) de una unidad o ' +
'de un desarrollo: fechas de trámites, estatus de venta, pagos y trámites de obra vencidos.',
parameters: {
type: 'object',
properties: {
desarrollo: { type: 'string', description: 'Desarrollo (opcional si se da unidad)' },
unidad: { type: 'string', description: 'Código de unidad exacto (opcional)' },
},
},
},
},
{
type: 'function',
function: {
name: 'ver_bitacora',
description:
'Bitácora de un contacto: todas sus notas y seguimientos con fecha y quién los registró.',
parameters: {
type: 'object',
properties: {
contacto: { type: 'string', description: 'Nombre o teléfono del contacto' },
},
required: ['contacto'],
},
},
},
{
type: 'function',
function: {
name: 'ver_calendario',
description:
'Citas y actividades agendadas de los próximos días (por default 7). A los vendedores les muestra las ' +
'suyas; a dirección, las de todo el equipo.',
parameters: {
type: 'object',
properties: { dias: { type: 'number', description: 'Días hacia adelante (default 7, tope 60)' } },
},
},
},
{
type: 'function',
function: {
name: 'ver_soporte',
description:
'Solicitudes internas de soporte con su estado, prioridad, departamento y solicitante.',
parameters: {
type: 'object',
properties: {
estado: { type: 'string', description: 'ABIERTA | EN_PROCESO | CERRADA (default ABIERTA)' },
departamento: { type: 'string', description: 'Departamento responsable (opcional)' },
},
},
},
},
{
type: 'function',
function: {
name: 'estadisticas',
description:
'Números del negocio. consulta: ' +
'leads_nuevos (altas por periodo y vendedor) · pipeline (registros por etapa del embudo) · ' +
'calificados (afluencias que califican) · unidades_por_estatus (inventario) · ' +
'cobranza_resumen (adeudo y vencidos) · ranking_vendedores (productividad por vendedor, solo dirección). ' +
'Úsala para cualquier pregunta de "cuántos/cuánto".',
parameters: {
type: 'object',
properties: {
consulta: {
type: 'string',
enum: ['leads_nuevos', 'pipeline', 'calificados', 'unidades_por_estatus', 'cobranza_resumen', 'ranking_vendedores'],
},
dias: { type: 'number', description: 'Ventana en días para leads_nuevos y calificados (default 30)' },
desarrollo: { type: 'string', description: 'Filtro de desarrollo (unidades_por_estatus, calificados)' },
},
required: ['consulta'],
},
},
},
]
export interface ArgsCotizarUnidad {
codigo?: string
tipoPropuesta?: string
apartado?: number
acabados?: boolean
}
export interface ArgsBuscarUnidad {
desarrollo?: string
modelo?: string
precioMax?: number
estatus?: string
}
export interface ArgsContarUnidades {
desarrollo?: string
estatus?: string
}
export interface ArgsBuscarContactos {
busqueda?: string
etapa?: string
desarrollo?: string
limite?: number
}
export interface ArgsBuscarLeads {
busqueda: string
}
export interface ArgsVerCliente {
nombre: string
}
export interface ArgsVerOportunidades {
etapa?: string
}
export interface ArgsVerCobranza {
cliente?: string
}
export interface ArgsVerCaptura {
desarrollo?: string
unidad?: string
}
export interface ArgsVerBitacora {
contacto: string
}
export interface ArgsVerCalendario {
dias?: number
}
export interface ArgsVerSoporte {
estado?: string
departamento?: string
}
export interface ArgsEstadisticas {
consulta: 'leads_nuevos' | 'pipeline' | 'calificados' | 'unidades_por_estatus' | 'cobranza_resumen' | 'ranking_vendedores'
dias?: number
desarrollo?: string
}
/** Ejecutores de herramientas. Los nuevos son opcionales para no romper clientes/tests previos. */
export interface EjecutoresHerramientas {
cotizar_unidad(args: ArgsCotizarUnidad): Promise<unknown>
buscar_unidad(args: ArgsBuscarUnidad): Promise<unknown>
ver_cliente(args: ArgsVerCliente): Promise<unknown>
ver_cobranza(args?: ArgsVerCobranza): Promise<unknown>
/** Legado: sustituido por buscar_contactos (el modelo ya no lo ve) */
buscar_leads?(args: ArgsBuscarLeads): Promise<unknown>
/** Legado: sustituido por buscar_contactos (el modelo ya no lo ve) */
ver_oportunidades?(args: ArgsVerOportunidades): Promise<unknown>
contar_unidades?(args: ArgsContarUnidades): Promise<unknown>
buscar_contactos?(args: ArgsBuscarContactos): Promise<unknown>
ver_apartados?(args: { fase?: string }): Promise<unknown>
ver_escrituracion?(): Promise<unknown>
ver_captura?(args: ArgsVerCaptura): Promise<unknown>
ver_bitacora?(args: ArgsVerBitacora): Promise<unknown>
ver_calendario?(args: ArgsVerCalendario): Promise<unknown>
ver_soporte?(args: ArgsVerSoporte): Promise<unknown>
estadisticas?(args: ArgsEstadisticas): Promise<unknown>
}
/** Redondeo monetario a 2 decimales para los números que lee el modelo */
const r2 = (x: number | null): number | null => (x === null ? null : Math.round(x * 100) / 100)
const MS_DIA = 24 * 60 * 60 * 1000
function perfilPlano(datosJson: string | null): Record<string, string> {
if (!datosJson) return {}
try {
const datos = JSON.parse(datosJson) as Record<string, unknown>
const plano: Record<string, string> = {}
for (const k of ['numDependientes', 'edad', 'estadoCivil', 'tipoEmpleo', 'ocupacion', 'rangoSalarial', 'credito', 'tipoEconomia', 'califica', 'loQuierePara']) {
const v = datos[k]
if (v != null && typeof v !== 'object' && String(v).trim() !== '') plano[k] = String(v)
}
return plano
} catch {
return {}
}
}
/**
* Ejecutores reales: inventario desde el espejo local de unidades (o el
* buscador inyectado en tests), cálculo con el motor de cotización portado de
* los Excel y consultas al CRM local (contactos, apartados, cobranza, captura,
* bitácora, calendario, soporte y estadísticas) escopadas por rol.
*/
export function crearEjecutoresHerramientas(
buscarUnidades: () => Promise<UnidadCotizable[]> = () => getUnidadesLocal(),
contexto?: ContextoUsuario,
): EjecutoresHerramientas {
const direccion = esDireccion(contexto?.rol)
const mio = contexto?.sub
/** Filtro de propiedad: dirección ve todo; el resto solo lo suyo */
const filtroPropio = (): Record<string, unknown> => (direccion || mio === undefined ? {} : { usuarioId: mio })
async function nombrePorUsuarioId(): Promise<Map<string, string>> {
const usuarios = await prisma.usuario.findMany({ select: { id: true, nombre: true } })
return new Map(usuarios.map((u) => [u.id, u.nombre]))
}
async function unidadPorApartadoId(): Promise<Map<string, string>> {
const clientes = await prisma.cliente.findMany({
where: { mondayApartadoId: { not: null } },
select: { mondayApartadoId: true, unidadCodigo: true },
})
return new Map(clientes.filter((c) => c.mondayApartadoId !== null).map((c) => [c.mondayApartadoId!, c.unidadCodigo ?? '']))
}
return {
async cotizar_unidad(args) {
const codigo = (args.codigo ?? '').trim().toUpperCase()
if (codigo === '') return { error: 'Falta el código de la unidad' }
const unidades = await buscarUnidades()
const unidad = unidades.find((u) => u.codigo.toUpperCase() === codigo)
if (!unidad) return { error: `La unidad ${codigo} no existe en el inventario` }
if (unidad.status !== 'DISPONIBLE') {
return { error: `La unidad ${codigo} no está DISPONIBLE (status actual: ${unidad.status})` }
}
const tipo = (args.tipoPropuesta ?? 'BANCARIO').trim().toUpperCase()
if (!TIPOS_PROPUESTA_VALIDOS.includes(tipo as TipoPropuestaApi)) {
return { error: `tipoPropuesta inválido: ${tipo}. Válidos: ${TIPOS_PROPUESTA_VALIDOS.join(', ')}` }
}
const apartado =
typeof args.apartado === 'number' && Number.isFinite(args.apartado) && args.apartado > 0
? args.apartado
: APARTADO_DEFAULT
const acabados = args.acabados === true
const propuesta = calcularPropuesta(unidad, tipo as TipoPropuestaApi, acabados, apartado)
return {
unidad: unidad.codigo,
desarrollo: unidad.desarrollo,
modelo: unidad.modelo,
esquema: propuesta.titulo,
precioLista: r2(obtenerPrecioLista(unidad, acabados)),
descuento: propuesta.descuento,
precioFinal: r2(propuesta.precioFinal),
apartado: r2(apartado),
enganche: r2(propuesta.enganche),
numMensualidades: propuesta.numMensualidades,
mensualidad: r2(propuesta.mensualidad),
mensualidadesTotal: r2(propuesta.mensualidadesTotal),
pagoFinal: r2(propuesta.pagoFinal),
totalPagado: r2(totalPagado(propuesta, apartado)),
nota: 'Cotización orientativa; el PDF oficial se genera en el cotizador (/quote/).',
}
},
async buscar_unidad(args) {
const unidades = await buscarUnidades()
const estatus = (args.estatus ?? 'DISPONIBLE').trim().toUpperCase()
let lista = unidades.filter((u) => (u.status || '').toUpperCase() === estatus)
const desarrollo = args.desarrollo?.trim().toUpperCase()
if (desarrollo) lista = lista.filter((u) => (u.desarrollo || '').toUpperCase() === desarrollo)
const modelo = args.modelo?.trim().toLowerCase()
if (modelo) lista = lista.filter((u) => u.modelo.toLowerCase().includes(modelo))
if (typeof args.precioMax === 'number' && Number.isFinite(args.precioMax)) {
lista = lista.filter((u) => u.precioSinAcabados <= args.precioMax!)
}
return lista.slice(0, 5).map((u) => ({
codigo: u.codigo,
desarrollo: u.desarrollo,
modelo: u.modelo,
estatus: u.status,
precio: r2(u.precioSinAcabados),
precioConAcabados: r2(u.precioConAcabados),
supConstruccion: u.supConstruccion,
}))
},
async contar_unidades(args) {
const unidades = await buscarUnidades()
const desarrollo = args.desarrollo?.trim().toUpperCase()
const estatus = args.estatus?.trim().toUpperCase()
let lista = unidades
if (desarrollo) lista = lista.filter((u) => (u.desarrollo || '').toUpperCase() === desarrollo)
if (estatus) lista = lista.filter((u) => (u.status || '').toUpperCase() === estatus)
const porEstatus: Record<string, number> = {}
const porDesarrollo: Record<string, number> = {}
for (const u of lista) {
porEstatus[u.status] = (porEstatus[u.status] ?? 0) + 1
porDesarrollo[u.desarrollo] = (porDesarrollo[u.desarrollo] ?? 0) + 1
}
return { total: lista.length, porEstatus, porDesarrollo }
},
/** Legado (búsqueda ligera): el modelo usa buscar_contactos */
async buscar_leads(args) {
const termino = (args.busqueda ?? '').trim()
if (!termino) return { error: 'Falta el texto de búsqueda' }
const leads = await prisma.leadCaptura.findMany({
where: {
...filtroPropio(),
OR: [
{ nombreLead: { contains: termino } },
{ telefono: { contains: termino } },
{ email: { contains: termino } },
],
},
take: 10,
orderBy: { createdAt: 'desc' },
})
return leads.map((l) => ({
nombre: l.nombreLead,
telefono: l.telefono,
email: l.email,
estado: l.etapa,
origen: l.origen,
desarrollo: l.desarrollo,
board: l.board,
}))
},
async buscar_contactos(args) {
const termino = (args.busqueda ?? '').trim()
const where: Record<string, unknown> = { ...filtroPropio() }
if (termino !== '') {
where['OR'] = [
{ nombreLead: { contains: termino } },
{ telefono: { contains: termino } },
{ email: { contains: termino } },
]
}
if (args.etapa?.trim()) where['etapa'] = args.etapa.trim()
if (args.desarrollo?.trim()) where['desarrollo'] = args.desarrollo.trim().toUpperCase()
const limite = Math.min(Math.max(args.limite ?? 10, 1), 25)
const filas = await prisma.leadCaptura.findMany({
where,
take: limite,
orderBy: { updatedAt: 'desc' },
})
const nombres = await nombrePorUsuarioId()
return filas.map((c) => ({
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
tablero: c.board,
etapa: c.etapa,
origen: c.origen ?? '',
desarrollo: c.desarrollo ?? '',
modelo: c.modelo ?? '',
vendedor: nombres.get(c.usuarioId) ?? '(sin asignar)',
motivo: c.motivo ?? undefined,
capturado: c.createdAt.toISOString().slice(0, 10),
perfil: perfilPlano(c.datosJson),
}))
},
async ver_cliente(args) {
const nombre = (args.nombre ?? '').trim()
if (!nombre) return { error: 'Falta el nombre del cliente' }
const cliente = await prisma.cliente.findFirst({ where: { nombreCompleto: { contains: nombre } } })
if (!cliente) return { error: `No se encontró ningún cliente con nombre "${nombre}"` }
const [leads, cotizaciones] = await Promise.all([
prisma.leadCaptura.findMany({
where: { nombreLead: { contains: cliente.nombreCompleto } },
orderBy: { createdAt: 'desc' },
take: 5,
}),
prisma.cotizacion.findMany({
where: { clienteNombre: { contains: cliente.nombreCompleto } },
orderBy: { createdAt: 'desc' },
take: 10,
}),
])
return {
id: cliente.id,
nombre: cliente.nombreCompleto,
curp: cliente.curp,
rfc: cliente.rfc,
email: cliente.email,
celular: cliente.celular,
proyecto: cliente.proyecto,
unidadCodigo: cliente.unidadCodigo,
perfil: perfilPlano(cliente.payloadJson),
lead: leads.length > 0
? {
etapa: leads[0]!.etapa,
board: leads[0]!.board,
origen: leads[0]!.origen,
}
: null,
cotizaciones: cotizaciones.map((c) => ({
unidad: c.unidadCodigo,
tipoPropuesta: c.tipoPropuesta,
precioFinal: r2(c.precioFinal),
enganche: r2(c.enganche),
mensualidad: r2(c.mensualidad),
createdAt: c.createdAt,
})),
}
},
async ver_oportunidades(args) {
const where: Record<string, unknown> = { board: 'OPORTUNIDADES', ...filtroPropio() }
if (args.etapa) where['etapa'] = args.etapa.trim()
const oportunidades = await prisma.leadCaptura.findMany({ where, take: 20, orderBy: { createdAt: 'desc' } })
return oportunidades.map((o) => ({
nombre: o.nombreLead,
etapa: o.etapa,
desarrollo: o.desarrollo,
telefono: o.telefono,
email: o.email,
}))
},
async ver_cobranza(args) {
const cliente = (args?.cliente ?? '').trim()
// Estado de cuenta de un cliente
if (cliente !== '') {
const planes = await prisma.planPago.findMany({
where: {
clienteNombre: { contains: cliente },
...(direccion || mio === undefined ? {} : { vendedorId: mio }),
},
include: {
pagos: { include: { aplicaciones: { orderBy: { fechaPago: 'desc' }, take: 6 } }, orderBy: { numPago: 'asc' } },
},
})
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 < 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
.flatMap((x) => x.aplicaciones.map((a) => ({ fecha: a.fechaPago.toISOString().slice(0, 10), monto: r2(a.monto) })))
.sort((a, b) => (b.fecha ?? '').localeCompare(a.fecha ?? ''))
.slice(0, 8)
return {
cliente: p.clienteNombre,
unidad: p.unidadCodigo,
desarrollo: p.proyecto,
planTotal: r2(p.engancheTotal),
mensualidad: r2(p.mensualidad),
numPagos: p.numPagos,
pagado: r2(pagado),
saldo: r2(saldo),
pagosVencidos: vencidos.length,
montoVencido: r2(vencidos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)),
proximoVencimiento: proximo ? proximo.fechaVencimiento.toISOString().slice(0, 10) : null,
estatus: p.status === 'CANCELADO' ? 'CANCELADO' : saldo <= 0.005 ? 'LIQUIDADO' : 'ACTIVO',
abonos,
}
})
}
// Resumen global
const alcance = direccion || mio === undefined ? {} : { vendedorId: mio }
const [planesActivos, planesCancelados, todosPagos] = await Promise.all([
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
prisma.pago.findMany({
// 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,
fechaVencimiento: true,
plan: { select: { clienteNombre: true, unidadCodigo: true } },
},
}),
])
const pagosVencidos = todosPagos.filter((p) => p.montoPagado < p.monto)
const montoAdeudado = pagosVencidos.reduce((sum, p) => sum + (p.monto - p.montoPagado), 0)
return {
planesActivos,
planesCancelados,
pagosVencidos: pagosVencidos.length,
montoAdeudado: r2(montoAdeudado),
detalle: pagosVencidos.slice(0, 10).map((p) => ({
cliente: p.plan.clienteNombre,
unidad: p.plan.unidadCodigo,
montoAdeudado: r2(p.monto - p.montoPagado),
vencimiento: p.fechaVencimiento,
})),
}
},
async ver_apartados(args) {
const fase = args.fase?.trim()
const where: Record<string, unknown> = {
board: 'APARTADO',
etapa: fase ? fase : { in: [...FASES_APARTADO] },
...filtroPropio(),
}
const filas = await prisma.leadCaptura.findMany({ where, orderBy: { updatedAt: 'desc' }, take: 40 })
const [nombres, unidades] = await Promise.all([nombrePorUsuarioId(), unidadPorApartadoId()])
return filas.map((c) => ({
cliente: c.nombreLead,
telefono: c.telefono ?? '',
unidad: unidades.get(c.leadId) ?? '',
fase: c.etapa,
desarrollo: c.desarrollo ?? '',
vendedor: nombres.get(c.usuarioId) ?? '(sin asignar)',
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : null,
ultimoCambio: c.updatedAt.toISOString().slice(0, 10),
}))
},
async ver_escrituracion() {
const filas = await prisma.leadCaptura.findMany({
where: { board: 'APARTADO', etapa: { in: [...FASES_ESCRITURACION] }, ...filtroPropio() },
orderBy: { updatedAt: 'desc' },
take: 60,
})
const [nombres, unidades] = await Promise.all([nombrePorUsuarioId(), unidadPorApartadoId()])
return filas.map((c) => ({
cliente: c.nombreLead,
telefono: c.telefono ?? '',
unidad: unidades.get(c.leadId) ?? '',
estatus: c.etapa,
vendedor: nombres.get(c.usuarioId) ?? '(sin asignar)',
fechaUltimoCambio: c.updatedAt.toISOString().slice(0, 10),
}))
},
async ver_captura(args) {
const donde: Record<string, unknown> = {}
if (args.desarrollo?.trim()) donde['desarrollo'] = args.desarrollo.trim().toUpperCase()
if (args.unidad?.trim()) donde['unidadCodigo'] = args.unidad.trim()
if (Object.keys(donde).length === 0) {
return { error: 'Indica un desarrollo o una unidad para consultar la captura' }
}
// Los vendedores solo ven la captura del departamento de Ventas
if (!direccion) donde['departamento'] = 'VENTAS'
const filas = await prisma.capturaDepartamento.findMany({ where: donde, orderBy: [{ unidadCodigo: 'asc' }, { campo: 'asc' }] })
const porUnidad = new Map<string, Record<string, string | null>>()
for (const f of filas) {
const campos = porUnidad.get(f.unidadCodigo) ?? {}
campos[f.campo] = f.valor
porUnidad.set(f.unidadCodigo, campos)
}
const hoy = new Date().toISOString().slice(0, 10)
const tramitesObra = ['certificacion_gas', 'to_c4_fianza', 'regimen_inscrito', 'no_oficial', 'clave_catastral', 'listas_avaluo']
return [...porUnidad.entries()].slice(0, 40).map(([unidad, campos]) => {
const vencidos = tramitesObra
.filter((b) => {
const fecha = campos[`${b}_fecha`]
return fecha != null && fecha < hoy && campos[b] !== 'true'
})
.map((b) => `${b}: fecha ${campos[`${b}_fecha`]}`)
return {
unidad,
desarrollo: filas.find((f) => f.unidadCodigo === unidad)?.desarrollo ?? '',
campos,
tramitesObraVencidos: vencidos,
}
})
},
async ver_bitacora(args) {
const contacto = (args.contacto ?? '').trim()
if (!contacto) return { error: 'Falta el nombre o teléfono del contacto' }
const filas = await prisma.leadCaptura.findMany({
where: {
...filtroPropio(),
OR: [{ nombreLead: { contains: contacto } }, { telefono: { contains: contacto } }],
},
take: 5,
})
if (filas.length === 0) return { error: `No se encontró ningún contacto con "${contacto}"` }
const leadIds = filas.map((f) => f.leadId)
const claves = filas.map((f) => calcularClienteClave(f.nombreLead, f.telefono))
const notas = await prisma.recordatorioVendedor.findMany({
where: { OR: [{ leadId: { in: leadIds } }, { clienteClave: { in: claves } }] },
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(args.contacto ? 15 : 10, 1), 30),
})
const nombres = await nombrePorUsuarioId()
return {
contactos: filas.map((f) => ({ nombre: f.nombreLead, etapa: f.etapa, tablero: f.board, telefono: f.telefono ?? '' })),
notas: notas.map((n) => ({
fecha: n.createdAt.toISOString().slice(0, 10),
nota: n.titulo,
autor: nombres.get(n.usuarioId) ?? '(sin nombre)',
completada: n.completado,
})),
}
},
async ver_calendario(args) {
const dias = Math.min(Math.max(args.dias ?? 7, 1), 60)
const hoy = new Date()
const fin = new Date(hoy.getTime() + dias * MS_DIA)
const filas = await prisma.actividadLocal.findMany({
where: {
estado: 'ABIERTA',
fechaVencimiento: { gte: new Date(hoy.toISOString().slice(0, 10) + 'T00:00:00.000Z'), lte: fin },
...(direccion || mio === undefined ? {} : { usuarioId: mio }),
},
orderBy: { fechaVencimiento: 'asc' },
take: 50,
})
const nombres = direccion ? await nombrePorUsuarioId() : null
return {
rango: `hoy a ${fin.toISOString().slice(0, 10)}`,
pendientes: filas.map((a) => ({
titulo: a.titulo,
tipo: a.tipo,
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : null,
descripcion: a.descripcion ?? undefined,
vendedor: nombres ? (nombres.get(a.usuarioId) ?? '') : undefined,
})),
}
},
async ver_soporte(args) {
const estado = (args.estado ?? 'ABIERTA').trim().toUpperCase()
const where: Record<string, unknown> = { estado }
if (args.departamento?.trim()) where['departamento'] = args.departamento.trim()
if (!direccion && mio !== undefined) where['usuarioId'] = mio
const filas = await prisma.solicitudSoporte.findMany({ where, orderBy: { createdAt: 'desc' }, take: 30 })
return filas.map((s) => ({
titulo: s.titulo,
departamento: s.departamento,
prioridad: s.prioridad,
estado: s.estado,
solicitante: s.solicitante || s.solicitanteEmail || '',
responsable: s.responsable ?? '',
fechaSolicitud: s.fechaSolicitud.toISOString().slice(0, 10),
fechaLimite: s.fechaLimite ? s.fechaLimite.toISOString().slice(0, 10) : null,
}))
},
async estadisticas(args) {
const dias = Math.min(Math.max(args.dias ?? 30, 1), 365)
const desde = new Date(Date.now() - dias * MS_DIA)
if (args.consulta === 'leads_nuevos') {
const filas = await prisma.leadCaptura.findMany({
where: { createdAt: { gte: desde }, ...filtroPropio() },
select: { usuarioId: true, board: true, etapa: true },
})
const leads = filas.filter((f) => f.board === 'LEADS')
if (!direccion) return { periodo: `${dias} días`, leadsNuevos: leads.length }
const nombres = await nombrePorUsuarioId()
const porVendedor: Record<string, number> = {}
for (const f of leads) {
const n = nombres.get(f.usuarioId) ?? '(sin asignar)'
porVendedor[n] = (porVendedor[n] ?? 0) + 1
}
return { periodo: `${dias} días`, totalLeads: leads.length, porVendedor }
}
if (args.consulta === 'pipeline') {
const filas = await prisma.leadCaptura.findMany({ where: filtroPropio(), select: { board: true, etapa: true } })
const conteo: Record<string, Record<string, number>> = {}
for (const f of filas) {
const tablero = f.board
const etapa = f.etapa.trim() === '' ? '(sin etapa)' : f.etapa
conteo[tablero] = conteo[tablero] ?? {}
conteo[tablero]![etapa] = (conteo[tablero]![etapa] ?? 0) + 1
}
return { total: filas.length, porTableroYEtapa: conteo }
}
if (args.consulta === 'calificados') {
const where: Record<string, unknown> = { board: 'OPORTUNIDADES', createdAt: { gte: desde }, ...filtroPropio() }
if (args.desarrollo?.trim()) where['desarrollo'] = args.desarrollo.trim().toUpperCase()
const filas = await prisma.leadCaptura.findMany({ where, select: { datosJson: true, desarrollo: true } })
let si = 0, no = 0, porConfirmar = 0, sinDato = 0
for (const f of filas) {
const califica = perfilPlano(f.datosJson)['califica'] ?? ''
if (califica.toLowerCase().startsWith('s')) si++
else if (califica.toLowerCase().startsWith('n')) no++
else if (califica.toLowerCase().startsWith('p')) porConfirmar++
else sinDato++
}
return { periodo: `${dias} días`, afluencias: filas.length, califican: si, noCalifican: no, porConfirmar, sinDato }
}
if (args.consulta === 'unidades_por_estatus') {
const where = args.desarrollo?.trim() ? { desarrollo: args.desarrollo.trim().toUpperCase() } : {}
const filas = await prisma.unidad.findMany({ where, select: { desarrollo: true, status: true, modelo: true } })
const porEstatus: Record<string, number> = {}
const porDesarrollo: Record<string, Record<string, number>> = {}
const modelos: Record<string, string[]> = {}
for (const f of filas) {
porEstatus[f.status] = (porEstatus[f.status] ?? 0) + 1
porDesarrollo[f.desarrollo] = porDesarrollo[f.desarrollo] ?? {}
porDesarrollo[f.desarrollo]![f.status] = (porDesarrollo[f.desarrollo]![f.status] ?? 0) + 1
if (f.modelo) {
modelos[f.desarrollo] = modelos[f.desarrollo] ?? []
if (!modelos[f.desarrollo]!.includes(f.modelo)) modelos[f.desarrollo]!.push(f.modelo)
}
}
return { total: filas.length, porEstatus, porDesarrollo, modelos }
}
if (args.consulta === 'cobranza_resumen') {
const planes = await prisma.planPago.findMany({
where: direccion || mio === undefined ? {} : { vendedorId: mio },
include: { pagos: true },
})
const hoy = new Date()
let saldo = 0, vencidos = 0, montoVencido = 0, vencenHoy = 0
for (const p of planes) {
for (const x of p.pagos) {
const falta = x.monto - x.montoPagado
if (falta > 0) saldo += falta
if (x.fechaVencimiento < hoy && falta > 0) {
vencidos++
montoVencido += falta
}
if (x.fechaVencimiento.toDateString() === hoy.toDateString() && falta > 0) vencenHoy++
}
}
return {
planes: planes.length,
saldoTotal: r2(saldo),
pagosVencidos: vencidos,
montoVencido: r2(montoVencido),
pagosQueVencenHoy: vencenHoy,
}
}
// ranking_vendedores (solo dirección)
if (!direccion) return { error: 'El ranking de vendedores solo está disponible para dirección' }
const [filas, usuarios] = await Promise.all([
prisma.leadCaptura.findMany({ select: { usuarioId: true, board: true, etapa: true, createdAt: true } }),
prisma.usuario.findMany({ where: { rol: 'vendedor', activo: true }, select: { id: true, nombre: true } }),
])
const ranking = usuarios.map((u) => {
const propios = filas.filter((f) => f.usuarioId === u.id)
return {
vendedor: u.nombre,
leads: propios.filter((f) => f.board === 'LEADS').length,
afluencias: propios.filter((f) => f.board === 'OPORTUNIDADES').length,
apartados: propios.filter((f) => f.board === 'APARTADO').length,
nuevos30dias: propios.filter((f) => f.createdAt >= desde).length,
}
}).sort((a, b) => b.apartados - a.apartados || b.leads - a.leads)
return { periodo30dias: `${dias} días`, ranking }
},
}
}
/** Ejecuta un tool call contra los ejecutores; nunca lanza (el error va al modelo) */
export async function ejecutarToolCall(
call: ToolCallSolicitado,
ejecutores: EjecutoresHerramientas,
): Promise<string> {
let args: Record<string, unknown>
try {
args = JSON.parse(call.argumentosJson || '{}') as Record<string, unknown>
} catch {
return JSON.stringify({ error: `Argumentos inválidos para ${call.nombre}` })
}
const ejecutor = ejecutores[call.nombre as keyof EjecutoresHerramientas]
if (typeof ejecutor !== 'function') return JSON.stringify({ error: `Herramienta desconocida: ${call.nombre}` })
try {
return JSON.stringify(await (ejecutor as (a: Record<string, unknown>) => Promise<unknown>)(args))
} catch (err) {
return JSON.stringify({ error: err instanceof Error ? err.message : String(err) })
}
}
// ---------- Índice de chunks en memoria (evita releer todo en cada mensaje) ----------
let cacheChunks: { count: number; datos: ChunkIndexado[] } | null = null
/** Invalida el índice en memoria (lo usa indexarDocumento al re-indexar) */
export function invalidarCacheChunks(): void {
cacheChunks = null
}
/**
* Chunks con embeddings listos para el ranking. Se cachean en memoria y se
* refrescan cuando cambia el número de chunks (reindexación) — antes se leía
* y parseaba la tabla completa en CADA mensaje.
*/
async function chunksIndexados(): Promise<ChunkIndexado[]> {
// El conteo es la clave de invalidación; si falla (mocks/DB sin count) se relee
let count: number | null = null
try {
count = await prisma.aIChunk.count()
} catch {
count = null
}
if (count !== null && cacheChunks !== null && cacheChunks.count === count) return cacheChunks.datos
const filas = await prisma.aIChunk.findMany({
include: { documento: { select: { titulo: true } } },
orderBy: [{ documentoId: 'asc' }, { orden: 'asc' }],
})
const datos = filas.map((c) => ({
titulo: c.documento.titulo,
texto: c.texto,
embedding: JSON.parse(c.embedding) as number[],
}))
if (count !== null) cacheChunks = { count, datos }
return datos
}
// ---------- Chat RAG ----------
const HISTORIAL_MAX = 10
const TOP_K = 4
export interface FuenteCitada {
titulo: string
score: number
}
export interface RespuestaChat {
sessionId: string
respuesta: string
fuentes: FuenteCitada[]
/** Herramientas de datos que se consultaron para responder */
herramientasUsadas: string[]
}
/** Fuentes únicas por título conservando el mejor score */
export function dedupeFuentes(chunks: ChunkRecuperado[]): FuenteCitada[] {
const porTitulo = new Map<string, number>()
for (const c of chunks) {
porTitulo.set(c.titulo, Math.max(porTitulo.get(c.titulo) ?? 0, c.score))
}
return [...porTitulo.entries()].map(([titulo, score]) => ({ titulo, score: Math.round(score * 1000) / 1000 }))
}
export interface OpcionesChat {
/** sub del JWT: la conversación nueva queda ligada al usuario (memoria por usuario) */
usuarioId?: string | null
/** Ejecutores de herramientas (default: inventario local + motor de cotización) */
ejecutores?: EjecutoresHerramientas
/** Usuario que pregunta (sub+rol): escopa las herramientas por rol */
contexto?: ContextoUsuario | undefined
/** Recibe el texto conforme llega (streaming del proveedor) */
onDelta?: (textoParcial: string) => void
}
/** Máximo de rondas de ejecución de herramientas por mensaje */
export const MAX_RONDAS_TOOLS = 6
/** Tope de caracteres por resultado de herramienta (evita reventar el contexto) */
export const MAX_TOOL_RESULT_CHARS = 4000
/**
* Loop de tool calling: si el modelo pide herramientas, se ejecutan y se hace
* otra llamada con los resultados (máx MAX_RONDAS_TOOLS rondas; después se
* fuerza el cierre sin herramientas). Devuelve el texto y las herramientas
* usadas. Sin chatConTools en el cliente, chat plano.
*/
async function responderConHerramientas(
mensajes: MensajeApi[],
client: NanClient,
ejecutores: EjecutoresHerramientas,
onDelta?: (textoParcial: string) => void,
): Promise<{ texto: string; usadas: string[] }> {
if (!client.chatConTools) {
// Cliente simple (tests legados): chat plano sin tools
const texto = await client.chat(
mensajes
.filter((m) => m.role !== 'tool')
.map((m) => ({
rol: m.role === 'assistant' ? ('assistant' as const) : m.role === 'system' ? ('system' as const) : ('user' as const),
contenido: m.content ?? '',
})),
)
return { texto, usadas: [] }
}
let herramientas = HERRAMIENTAS
const usadas: string[] = []
for (let ronda = 0; ronda <= MAX_RONDAS_TOOLS; ronda++) {
const resp = await client.chatConTools(mensajes, herramientas, onDelta)
if (resp.toolCalls.length === 0) {
if (resp.contenido !== null) return { texto: resp.contenido, usadas }
throw new AppError(502, 'El proveedor IA no devolvió respuesta')
}
if (ronda === MAX_RONDAS_TOOLS) break // no debería pasar: la última llamada va sin tools
mensajes.push({
role: 'assistant',
content: resp.contenido,
tool_calls: resp.toolCalls.map((t) => ({
id: t.id,
type: 'function' as const,
function: { name: t.nombre, arguments: t.argumentosJson },
})),
})
for (const call of resp.toolCalls) {
usadas.push(call.nombre)
let resultado = await ejecutarToolCall(call, ejecutores)
if (resultado.length > MAX_TOOL_RESULT_CHARS) {
resultado = `${resultado.slice(0, MAX_TOOL_RESULT_CHARS)}…(resultado truncado)`
}
mensajes.push({ role: 'tool', tool_call_id: call.id, content: resultado })
}
if (ronda === MAX_RONDAS_TOOLS - 1) herramientas = [] // cierre: forzar texto plano
}
throw new AppError(502, 'El proveedor IA no cerró la respuesta tras usar herramientas')
}
export async function chat(
sessionId: string | null,
mensaje: string,
client: NanClient = clienteNan(),
opciones: OpcionesChat = {},
): Promise<RespuestaChat> {
let conversacion = sessionId
? 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 },
})
await prisma.aIMensaje.create({
data: { conversacionId: conversacion.id, rol: 'user', contenido: mensaje },
})
// Recuperación: embedding de la consulta contra el índice en memoria
const [consultaEmb] = await client.embed([mensaje])
const chunks = await chunksIndexados()
const top = consultaEmb ? rankearChunks(chunks, consultaEmb, TOP_K) : []
// Historial: últimos N mensajes (incluye el que acabamos de persistir)
const historial = await prisma.aIMensaje.findMany({
where: { conversacionId: conversacion.id },
orderBy: { createdAt: 'asc' },
take: -HISTORIAL_MAX,
})
const mensajes: MensajeApi[] = [
{ role: 'system', content: construirSystemPrompt(top) },
...historial.map((m) => ({ role: m.rol === 'assistant' ? ('assistant' as const) : ('user' as const), content: m.contenido })),
]
// Streaming: si el proveedor no emitió deltas, se manda el texto completo al final
let emitido = false
const onDelta = opciones.onDelta
? (texto: string) => {
emitido = true
opciones.onDelta!(texto)
}
: undefined
const { texto: respuesta, usadas } = await responderConHerramientas(
mensajes,
client,
opciones.ejecutores ?? crearEjecutoresHerramientas(undefined, opciones.contexto),
onDelta,
)
if (opciones.onDelta && !emitido) opciones.onDelta(respuesta)
const fuentes = dedupeFuentes(top)
await prisma.aIMensaje.create({
data: {
conversacionId: conversacion.id,
rol: 'assistant',
contenido: respuesta,
fuentes: JSON.stringify(fuentes),
},
})
return { sessionId: conversacion.id, respuesta, fuentes, herramientasUsadas: usadas }
}