feat: modulo WhatsApp (WA CRM) con round-robin de conversaciones

- Servicio wacrm en api-gateway (pg pool lazy): round-robin de conversaciones
  sin agente entre vendedores vinculados y miembro-agentes de la cuenta wacrm,
  cron cada 30 s; bandeja del vendedor; estado/config/reasignar para admin.
- Endpoints /api/wacrm/* con guarda de rol admin/manager.
- Usuario.wacrmUserId en schema Prisma (vinculo CRM<->wacrm).
- App apps/wa-crm (/wa-crm/): bandeja + administracion del round-robin.
- Landing: tarjeta WhatsApp; clave 'whatsapp' en MATRIZ de roles.
- Plan de nuevas reglas de negocio del cotizador (docs/PLAN-REGLAS-COTIZADOR.md).
- WACRM_PG_URL apunta a Postgres de Supabase por 127.0.0.1:5433.
- Tests de integracion wacrm (3) y suite completa 764/764 en verde.
This commit is contained in:
2026-09-30 00:30:17 +00:00
parent cb1c36b669
commit e3b5660769
28 changed files with 5744 additions and 1 deletions

View File

@@ -16,6 +16,7 @@
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"node-cron": "^4.6.0",
"pg": "^8.23.0",
"web-push": "^3.6.7",
"zod": "^3.25.76"
},
@@ -27,6 +28,7 @@
"@types/multer": "^2.0.0",
"@types/node": "^22.15.0",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.23.1",
"@types/web-push": "^3.6.4",
"prisma": "^6.14.0",
"tsx": "^4.20.3",
@@ -1086,6 +1088,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -2658,6 +2672,95 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2719,6 +2822,45 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prisma": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
@@ -3098,6 +3240,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
@@ -3600,6 +3751,15 @@
"node": ">=0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@@ -29,6 +29,7 @@
"jsonwebtoken": "^9.0.3",
"multer": "^2.0.2",
"node-cron": "^4.6.0",
"pg": "^8.23.0",
"web-push": "^3.6.7",
"zod": "^3.25.76"
},
@@ -40,6 +41,7 @@
"@types/multer": "^2.0.0",
"@types/node": "^22.15.0",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.23.1",
"@types/web-push": "^3.6.4",
"prisma": "^6.14.0",
"tsx": "^4.20.3",

View File

@@ -318,6 +318,9 @@ model Usuario {
// User ID de Monday (people). null = el usuario aún no tiene cuenta Monday;
// los filtros "míos" de /api/mi-dia responden vacíos con sinMapeoMonday: true
mondayUserId Int?
// ID del usuario en WA CRM (auth.users del Supabase de wacrm). null = sin
// vínculo; el módulo WhatsApp del CRM lo usa para la bandeja y el round-robin
wacrmUserId String?
activo Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -12,6 +12,7 @@ export const MODULOS = [
'cobranza',
'contactos',
'chat',
'whatsapp',
'clientes-maestro',
'captura',
'soporte',
@@ -39,6 +40,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
'mi-tablero',
'contactos',
'chat',
'whatsapp',
'clientes-maestro',
'captura',
'soporte',
@@ -49,7 +51,7 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
administracion_venta: [...MODULOS_VENDEDOR, 'cobranza'],
director_ventas: [...MODULOS_VENDEDOR, 'cobranza', 'tablero-comercial'],
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'whatsapp', 'clientes-maestro', 'captura', 'soporte'],
// manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos
// ni dar de alta desarrollos (módulo exclusivo del admin)
manager: MODULOS.filter((m) => m !== 'desarrollos'),

View File

@@ -0,0 +1,67 @@
/**
* Endpoints del módulo WhatsApp (WA CRM): bandeja del vendedor, estado del
* round-robin para administración, configuración y reasignación manual.
*/
import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod'
import { AppError } from '../utils/errors.js'
import {
configurarWacrm,
estadoWacrm,
ejecutarRoundRobinWacrm,
miBandeja,
reasignarConversacion,
} from '../services/wacrm.service.js'
import * as auditoria from '../services/auditoria.service.js'
/** Admin/manager: estado, configuración y reasignación. */
export function requireRolWacrmAdmin(req: Request, _res: Response, next: NextFunction): void {
const rol = req.usuarioToken?.rol
if (rol !== 'admin' && rol !== 'manager') {
throw new AppError(403, 'Requiere rol de administrador')
}
next()
}
/** Bandeja de WhatsApp del vendedor logueado (vínculo vía wacrmUserId). */
export async function miBandejaEndpoint(req: Request, res: Response): Promise<void> {
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new AppError(401, 'Sesión requerida')
const resultado = await miBandeja(usuarioId)
res.json(resultado)
}
export async function estadoWacrmEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await estadoWacrm())
}
const configSchema = z
.object({
cuentaId: z.string().max(64).optional(),
activo: z.boolean().optional(),
})
.refine((o) => Object.keys(o).length > 0, { message: 'Nada que actualizar' })
export async function configurarWacrmEndpoint(req: Request, res: Response): Promise<void> {
const body = configSchema.parse(req.body)
const resultado = await configurarWacrm(body)
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CONFIG_WACRM', 'configuracion', 'wacrm', JSON.stringify(body))
res.json(resultado)
}
const reasignarSchema = z.object({
conversacionId: z.string().min(10),
vendedorId: z.string().min(10),
})
export async function reasignarWacrmEndpoint(req: Request, res: Response): Promise<void> {
const body = reasignarSchema.parse(req.body)
await reasignarConversacion(body)
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'REASIGNAR_WACRM', 'conversacion', body.conversacionId, body.vendedorId)
res.json({ ok: true })
}
/** Dispara el round-robin a demanda (admin) — útil para pruebas. */
export async function roundRobinWacrmEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await ejecutarRoundRobinWacrm())
}

View File

@@ -3,6 +3,7 @@ import { crearApp } from './app.js'
import { config } from './config/index.js'
import { automatizacionesService } from './services/automatizaciones.service.js'
import * as configService from './services/config.service.js'
import { ejecutarRoundRobinWacrm } from './services/wacrm.service.js'
import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
const app = crearApp()
@@ -64,6 +65,18 @@ if (config.automatizaciones.enabled) {
})
// eslint-disable-next-line no-console
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`)
// Round-robin de conversaciones entrantes de WhatsApp (WA CRM). Cada 30 s
// revisa conversaciones sin agente de la cuenta configurada y las turna
// entre los vendedores vinculados. Silencioso cuando no hay nada que hacer.
cron.schedule('*/30 * * * * *', () => {
void ejecutarRoundRobinWacrm().catch((err: unknown) => {
// 503 = wacrm aún no configurado: se espera, no es error de log ruidoso
const e = err as { status?: number }
if (e?.status && e.status < 500) return
console.error('[cron wacrm] error en round-robin WhatsApp:', err)
})
})
}
// Precarga de configuraciones desde DB (modo_local_forzado, etc.)

View File

@@ -96,6 +96,14 @@ import {
subirDocumento,
} from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js'
import {
configurarWacrmEndpoint,
estadoWacrmEndpoint,
miBandejaEndpoint,
reasignarWacrmEndpoint,
requireRolWacrmAdmin,
roundRobinWacrmEndpoint,
} from '../controllers/wacrm.controller.js'
import { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
import {
requireRolReportes,
@@ -286,6 +294,15 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint)
// ── Módulo WhatsApp (WA CRM) ─────────────────────────────────────────
// Bandeja del vendedor logueado (vínculo Usuario.wacrmUserId)
apiRouter.get('/wacrm/mi-bandeja', miBandejaEndpoint)
// Administración del round-robin (admin/manager)
apiRouter.get('/wacrm/estado', requireRolWacrmAdmin, estadoWacrmEndpoint)
apiRouter.post('/wacrm/config', requireRolWacrmAdmin, configurarWacrmEndpoint)
apiRouter.post('/wacrm/reasignar', requireRolWacrmAdmin, reasignarWacrmEndpoint)
apiRouter.post('/wacrm/round-robin', requireRolWacrmAdmin, roundRobinWacrmEndpoint)
// Precarga de documentos del expediente desde "Mis apartados": se guardan en
// el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)

View File

@@ -0,0 +1,155 @@
/**
* Tests de integración del servicio wacrm contra el Supabase real.
*
* Se saltan automáticamente si WACRM_PG_URL no está definida (CI/entornos
* sin wacrm). Crean datos de prueba en la base de wacrm y los limpian al
* final (afterAll) aunque fallen las assertions.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Pool } from 'pg'
import { randomUUID } from 'node:crypto'
import { prisma } from './prisma.js'
import { ejecutarRoundRobinWacrm, miBandeja, setWacrmPoolForTesting } from './wacrm.service.js'
import { actualizarConfigs, obtenerConfig } from './config.service.js'
const HABILITADO = Boolean(process.env.WACRM_PG_URL)
describe.skipIf(!HABILITADO)('wacrm.service (integración)', () => {
let pool: Pool
let cuentaId: string
let authUsers: string[] = []
const idsUsuariosCrm: string[] = []
const idsConversaciones: string[] = []
const CLAVES_CONFIG = ['wacrm_cuenta_id', 'wacrm_rr_activo', 'wacrm_rr_idx']
const configPrev: Record<string, string | null> = {}
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.WACRM_PG_URL, max: 2 })
setWacrmPoolForTesting(pool as unknown as never)
// Usuarios auth de prueba (2) + cuenta + perfiles agente
for (let i = 0; i < 2; i++) {
const uid = randomUUID()
authUsers.push(uid)
await pool.query(
`INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, created_at, updated_at)
VALUES ($1, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', $2, 'x', now(), now())`,
[uid, `wacrm-test-${i}-${randomUUID()}@test.local`],
)
}
// handle_new_user crea automáticamente una cuenta personal + profile por
// usuario: reusamos la cuenta del primer usuario y movemos al segundo.
const { rows: cuentas } = await pool.query(
`SELECT id FROM accounts WHERE owner_user_id = $1`,
[authUsers[0]],
)
cuentaId = cuentas[0].id
// Primero mover el profile ( CASCADE del account borraría el profile ),
// luego eliminar la cuenta personal sobrante del segundo usuario.
await pool.query(
`UPDATE profiles SET account_id = $1, account_role = 'agent' WHERE user_id = $2`,
[cuentaId, authUsers[1]],
)
await pool.query(`DELETE FROM accounts WHERE owner_user_id = $1`, [authUsers[1]])
// Vendedores CRM vinculados a esos usuarios auth
for (let i = 0; i < 2; i++) {
const u = await prisma.usuario.create({
data: {
username: `wacrmtest${i}-${randomUUID().slice(0, 8)}`,
nombre: `Vendedor Test ${i}`,
passwordHash: 'x',
rol: 'vendedor',
wacrmUserId: authUsers[i],
updatedAt: new Date(),
},
})
idsUsuariosCrm.push(u.id)
}
// Guarda config previa y activa round-robin sobre la cuenta de prueba
for (const k of CLAVES_CONFIG) configPrev[k] = await obtenerConfig(k)
await actualizarConfigs({ 'wacrm_cuenta_id': cuentaId, 'wacrm_rr_activo': '1', 'wacrm_rr_idx': '0' })
}, 60_000)
afterAll(async () => {
await pool.query('DELETE FROM conversations WHERE account_id = $1', [cuentaId])
await pool.query('DELETE FROM contacts WHERE account_id = $1', [cuentaId])
await pool.query('DELETE FROM profiles WHERE user_id = ANY($1)', [authUsers])
await pool.query('DELETE FROM accounts WHERE id = $1', [cuentaId])
await pool.query('DELETE FROM auth.users WHERE id = ANY($1)', [authUsers])
await prisma.usuario.deleteMany({ where: { id: { in: idsUsuariosCrm } } })
const restaurar: Record<string, string> = {}
for (const k of CLAVES_CONFIG) if (configPrev[k] !== null) restaurar[k] = configPrev[k] as string
if (Object.keys(restaurar).length > 0) await actualizarConfigs(restaurar)
for (const k of CLAVES_CONFIG) {
if (configPrev[k] === null) await prisma.configuracionSistema.deleteMany({ where: { clave: k } })
}
setWacrmPoolForTesting(null)
await pool.end()
}, 60_000)
async function crearConversacion(telefono: string): Promise<string> {
const contactId = randomUUID()
await pool.query(
`INSERT INTO contacts (id, user_id, phone, name, account_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, now(), now())`,
[contactId, authUsers[0], telefono, `Contacto ${telefono}`, cuentaId],
)
const convId = randomUUID()
await pool.query(
`INSERT INTO conversations (id, user_id, contact_id, status, account_id, created_at, updated_at)
VALUES ($1, $2, $3, 'open', $4, now(), now())`,
[convId, authUsers[0], contactId, cuentaId],
)
idsConversaciones.push(convId)
return convId
}
it('round-robin turna conversaciones sin asignar entre los vendedores', async () => {
await crearConversacion('521550000001')
await crearConversacion('521550000002')
await crearConversacion('521550000003')
const r = await ejecutarRoundRobinWacrm()
expect(r.asignados).toBe(3)
const { rows } = await pool.query(
`SELECT assigned_agent_id FROM conversations WHERE account_id = $1 ORDER BY created_at`,
[cuentaId],
)
const asignados = rows.map((r2: { assigned_agent_id: string }) => r2.assigned_agent_id)
// 3 conversaciones, 2 vendedores: reparte 2-1 y todos quedan asignados
expect(asignados.every((a: string | null) => a !== null)).toBe(true)
expect(new Set(asignados).size).toBe(2)
// Segunda pasada: nada nuevo que asignar
const r2 = await ejecutarRoundRobinWacrm()
expect(r2.asignados).toBe(0)
}, 30_000)
it('miBandeja devuelve solo las conversaciones del vendedor', async () => {
const bandeja0 = await miBandeja(idsUsuariosCrm[0])
const bandeja1 = await miBandeja(idsUsuariosCrm[1])
expect(bandeja0.vinculado).toBe(true)
expect(bandeja1.vinculado).toBe(true)
expect(bandeja0.conversaciones.length + bandeja1.conversaciones.length).toBe(3)
// Sin solapamiento
const ids0 = new Set(bandeja0.conversaciones.map((c) => c.id))
for (const c of bandeja1.conversaciones) expect(ids0.has(c.id)).toBe(false)
expect(bandeja0.conversaciones[0]?.contacto.telefono).toBeTruthy()
}, 30_000)
it('miBandeja marca como no vinculado a un usuario sin wacrmUserId', async () => {
const u = await prisma.usuario.create({
data: {
username: `wacrmsinvinc-${randomUUID().slice(0, 8)}`,
nombre: 'Sin Vinculo',
passwordHash: 'x',
rol: 'vendedor',
updatedAt: new Date(),
},
})
idsUsuariosCrm.push(u.id)
const bandeja = await miBandeja(u.id)
expect(bandeja.vinculado).toBe(false)
expect(bandeja.conversaciones).toEqual([])
}, 30_000)
})

View File

@@ -0,0 +1,284 @@
/**
* Servicio de integración con WA CRM (wacrm — WhatsApp).
*
* wacrm vive en su propia base (Supabase Postgres self-hosted, esquema
* public) y comparte usuarios con el CRM mediante la columna
* `Usuario.wacrmUserId` (id de auth.users en Supabase).
*
* Responsabilidades:
* - Round-robin: cada conversación entrante sin `assigned_agent_id` se
* asigna al siguiente vendedor del pool (rol 'vendedor' vinculado Y
* miembro agente/admin/owner de la cuenta wacrm configurada).
* - Bandeja: exponer "mis conversaciones" al vendedor logueado.
* - Admin: estado general, configuración de cuenta/activo y reasignación.
*
* La conexión es lazy: si WACRM_PG_URL no está definida o la base no
* responde, las funciones lanzan AppError 503 (el resto del CRM sigue
* funcionando igual).
*/
import { Pool } from 'pg'
import { prisma } from './prisma.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
import { obtenerConfig, actualizarConfigs } from './config.service.js'
// ── Config en ConfiguracionSistema ─────────────────────────────────────
export const CLAVE_CUENTA = 'wacrm_cuenta_id'
export const CLAVE_RR_ACTIVO = 'wacrm_rr_activo'
const CLAVE_RR_IDX = 'wacrm_rr_idx'
// ── Pool lazy ──────────────────────────────────────────────────────────
let pool: Pool | null = null
let poolUrlUsada = ''
function obtenerPool(): Pool {
const url = process.env.WACRM_PG_URL ?? ''
if (!url) throw new AppError(503, 'WA CRM no configurado (falta WACRM_PG_URL)')
if (!pool || poolUrlUsada !== url) {
pool?.end().catch(() => undefined)
pool = new Pool({ connectionString: url, max: 3, idleTimeoutMillis: 30_000 })
poolUrlUsada = url
// No dejar que un error de conexión tumbe el proceso
pool.on('error', (err) => console.error('[wacrm] error en pool pg:', err.message))
}
return pool
}
/** Para tests: inyectar un pool falso / resetear al real. */
export function setWacrmPoolForTesting(fake: Pool | null): void {
pool = fake
// Marcar la URL como "ya cargada" para que obtenerPool() no cierre el pool
// inyectado al comparar contra la URL del entorno.
poolUrlUsada = fake ? (process.env.WACRM_PG_URL ?? '') : ''
}
interface ConversacionBandeja {
id: string
status: string
lastMessage: string | null
lastMessageAt: string | null
unreadCount: number
contacto: { nombre: string | null; telefono: string }
}
// ── Helpers internos ───────────────────────────────────────────────────
/** Vendedores CRM vinculados a wacrm que además son agentes de la cuenta. */
async function poolVendedoresCuenta(cuentaId: string) {
const vendedores = await prisma.usuario.findMany({
where: { rol: 'vendedor', activo: true, wacrmUserId: { not: null } },
orderBy: { nombre: 'asc' },
select: { id: true, nombre: true, wacrmUserId: true },
})
if (vendedores.length === 0) return []
const ids = vendedores.map((v) => v.wacrmUserId as string)
const { rows } = await obtenerPool().query(
`SELECT user_id FROM profiles
WHERE account_id = $1 AND account_role IN ('owner','admin','agent')
AND user_id = ANY($2::uuid[])`,
[cuentaId, ids],
)
const miembros = new Set(rows.map((r: { user_id: string }) => r.user_id))
return vendedores.filter((v) => miembros.has(v.wacrmUserId as string))
}
// ── Round-robin ────────────────────────────────────────────────────────
/**
* Asigna las conversaciones sin agente de la cuenta configurada,
* turnándose entre los vendedores del pool. Devuelve cuántas asignó.
*/
export async function ejecutarRoundRobinWacrm(): Promise<{ asignados: number }> {
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
const activo = (await obtenerConfig(CLAVE_RR_ACTIVO)) === '1'
if (!activo || !cuentaId) return { asignados: 0 }
const poolVendedores = await poolVendedoresCuenta(cuentaId)
if (poolVendedores.length === 0) return { asignados: 0 }
const db = obtenerPool()
const { rows: sinAsignar } = await db.query(
`SELECT id FROM conversations
WHERE account_id = $1 AND assigned_agent_id IS NULL AND status = 'open'
ORDER BY created_at ASC LIMIT 50`,
[cuentaId],
)
let idx = Number.parseInt((await obtenerConfig(CLAVE_RR_IDX)) ?? '0', 10)
if (!Number.isFinite(idx) || idx < 0) idx = 0
let asignados = 0
for (const conv of sinAsignar as { id: string }[]) {
const vend = poolVendedores[idx % poolVendedores.length]!
idx += 1
const r = await db.query(
`UPDATE conversations SET assigned_agent_id = $2, updated_at = now()
WHERE id = $1 AND assigned_agent_id IS NULL`,
[conv.id, vend.wacrmUserId],
)
if (r.rowCount === 1) {
asignados += 1
console.log(`[wacrm round-robin] conversación ${conv.id} → ${vend.nombre}`)
}
}
if (asignados > 0 || sinAsignar.length > 0) {
await actualizarConfigs({ [CLAVE_RR_IDX]: String(idx) })
}
return { asignados }
}
// ── Bandeja del vendedor ───────────────────────────────────────────────
/** Conversaciones de WhatsApp asignadas al usuario del CRM (por su vínculo). */
export async function miBandeja(usuarioCrmId: string): Promise<{
vinculado: boolean
conversaciones: ConversacionBandeja[]
}> {
const usuario = await prisma.usuario.findUnique({ where: { id: usuarioCrmId } })
if (!usuario?.wacrmUserId) return { vinculado: false, conversaciones: [] }
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
const params: string[] = [usuario.wacrmUserId]
let filtroCuenta = ''
if (cuentaId) {
params.push(cuentaId)
filtroCuenta = 'AND c.account_id = $2'
}
const { rows } = await obtenerPool().query(
`SELECT c.id, c.status, c.last_message_text, c.last_message_at, c.unread_count,
ct.name AS contacto_nombre, ct.phone AS contacto_telefono
FROM conversations c JOIN contacts ct ON ct.id = c.contact_id
WHERE c.assigned_agent_id = $1 ${filtroCuenta}
ORDER BY c.last_message_at DESC NULLS LAST, c.created_at DESC
LIMIT 100`,
params,
)
return {
vinculado: true,
conversaciones: rows.map((r) => ({
id: r.id,
status: r.status,
lastMessage: r.last_message_text,
lastMessageAt: r.last_message_at ? new Date(r.last_message_at).toISOString() : null,
unreadCount: r.unread_count ?? 0,
contacto: { nombre: r.contacto_nombre, telefono: r.contacto_telefono },
})),
}
}
// ── Admin: estado / configuración / reasignación ───────────────────────
export async function estadoWacrm(): Promise<{
cuentaId: string | null
roundRobinActivo: boolean
sinAsignar: number
totalConversaciones: number
vendedores: Array<{
id: string
nombre: string
wacrmUserId: string
esMiembro: boolean
asignadas: number
}>
}> {
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
const roundRobinActivo = (await obtenerConfig(CLAVE_RR_ACTIVO)) === '1'
if (!cuentaId) {
return { cuentaId: null, roundRobinActivo, sinAsignar: 0, totalConversaciones: 0, vendedores: [] }
}
const db = obtenerPool()
const { rows: conteos } = await db.query(
`SELECT
COUNT(*) FILTER (WHERE assigned_agent_id IS NULL AND status = 'open') AS sin_asignar,
COUNT(*) AS total
FROM conversations WHERE account_id = $1`,
[cuentaId],
)
const candidatos = await prisma.usuario.findMany({
where: { rol: 'vendedor', activo: true, wacrmUserId: { not: null } },
orderBy: { nombre: 'asc' },
select: { id: true, nombre: true, wacrmUserId: true },
})
const { rows: miembros } = await db.query(
`SELECT user_id FROM profiles
WHERE account_id = $1 AND account_role IN ('owner','admin','agent')
AND user_id = ANY($2::uuid[])`,
[cuentaId, candidatos.map((v) => v.wacrmUserId as string)],
)
const setMiembros = new Set(miembros.map((r: { user_id: string }) => r.user_id))
const { rows: asignadas } = await db.query(
`SELECT assigned_agent_id AS uid, COUNT(*)::int AS n
FROM conversations WHERE account_id = $1 AND assigned_agent_id IS NOT NULL
GROUP BY assigned_agent_id`,
[cuentaId],
)
const mapAsignadas = new Map(asignadas.map((r: { uid: string; n: number }) => [r.uid, r.n]))
return {
cuentaId,
roundRobinActivo,
sinAsignar: Number(conteos[0]?.sin_asignar ?? 0),
totalConversaciones: Number(conteos[0]?.total ?? 0),
vendedores: candidatos.map((v) => ({
id: v.id,
nombre: v.nombre,
wacrmUserId: v.wacrmUserId as string,
esMiembro: setMiembros.has(v.wacrmUserId as string),
asignadas: mapAsignadas.get(v.wacrmUserId as string) ?? 0,
})),
}
}
export async function configurarWacrm(datos: {
cuentaId?: string | undefined
activo?: boolean | undefined
}): Promise<{ cuentaId: string | null; activo: boolean }> {
const cambios: Record<string, string> = {}
if (datos.cuentaId !== undefined) {
if (datos.cuentaId && !/^[0-9a-f-]{36}$/i.test(datos.cuentaId)) {
throw new BadRequestError('cuentaId debe ser un UUID válido (o vacío para quitar)')
}
if (datos.cuentaId) {
// Verificar que la cuenta exista en wacrm antes de guardarla
const { rows } = await obtenerPool().query(`SELECT id FROM accounts WHERE id = $1`, [
datos.cuentaId,
])
if (rows.length === 0) throw new NotFoundError('La cuenta no existe en WA CRM')
}
cambios[CLAVE_CUENTA] = datos.cuentaId
}
if (datos.activo !== undefined) cambios[CLAVE_RR_ACTIVO] = datos.activo ? '1' : '0'
if (Object.keys(cambios).length > 0) await actualizarConfigs(cambios)
const [cuentaId, activo] = await Promise.all([
obtenerConfig(CLAVE_CUENTA),
obtenerConfig(CLAVE_RR_ACTIVO),
])
return { cuentaId, activo: activo === '1' }
}
export async function reasignarConversacion(datos: {
conversacionId: string
vendedorId: string
}): Promise<void> {
if (!/^[0-9a-f-]{36}$/i.test(datos.conversacionId)) {
throw new BadRequestError('conversacionId debe ser un UUID')
}
const vendedor = await prisma.usuario.findFirst({
where: { id: datos.vendedorId, rol: 'vendedor', activo: true },
})
if (!vendedor?.wacrmUserId) throw new NotFoundError('Vendedor sin vínculo a WA CRM')
const cuentaId = await obtenerConfig(CLAVE_CUENTA)
const db = obtenerPool()
if (cuentaId) {
const { rows } = await db.query(
`SELECT 1 FROM profiles WHERE account_id = $1 AND user_id = $2
AND account_role IN ('owner','admin','agent')`,
[cuentaId, vendedor.wacrmUserId],
)
if (rows.length === 0) throw new BadRequestError('El vendedor no es miembro de la cuenta WA CRM')
}
const r = await db.query(
`UPDATE conversations SET assigned_agent_id = $2, updated_at = now() WHERE id = $1`,
[datos.conversacionId, vendedor.wacrmUserId],
)
if (r.rowCount === 0) throw new NotFoundError('Conversación no encontrada')
}

View File

@@ -39,6 +39,13 @@ export const MODULOS: Modulo[] = [
claves: ['chat'],
url: '/ai/',
},
{
id: 'whatsapp',
nombre: 'WhatsApp',
descripcion: 'Bandeja compartida de WhatsApp: conversaciones asignadas y round-robin',
claves: ['whatsapp'],
url: '/wa-crm/',
},
{
id: 'tableros',
nombre: 'Tableros',

13
apps/wa-crm/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1B4332" />
<title>CRM360 FRASA — WhatsApp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3883
apps/wa-crm/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
apps/wa-crm/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "wa-crm",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.3.0",
"lucide-react": "^1.41.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hot-toast": "^2.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.3",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,32 @@
import { Sun, Moon } from "lucide-react"
import { useEffect, useState } from "react"
function getStoredTheme(): boolean {
if (typeof window === "undefined") return false
try {
const stored = localStorage.getItem("frasa_theme")
if (stored) return stored === "dark"
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false
} catch {
return false
}
}
export default function DarkModeToggle() {
const [dark, setDark] = useState(getStoredTheme)
useEffect(() => {
document.documentElement.classList.toggle("dark", dark)
try {
localStorage.setItem("frasa_theme", dark ? "dark" : "light")
} catch {
// localStorage no disponible (entorno de test)
}
}, [dark])
return (
<button onClick={() => setDark(!dark)} className="rounded-lg p-2 text-[#64748B] hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]" aria-label="Cambiar tema">
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
)
}

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
/**
* Pantalla cuando no hay sesión: se indica que el login vive en la landing
* principal (misma sesión compartida vía localStorage 'frasa_auth').
*/
export default function SinSesion({ children }: { children?: ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#F8FAFC] px-4 dark:bg-[#0F172A]">
<div className="w-full max-w-md rounded-xl border border-[#E5E7EB] bg-white p-8 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<h1 className="text-xl font-bold text-[#111827] dark:text-[#E2E8F0]">
Inicia sesión desde la página principal
</h1>
<p className="mt-3 text-base text-[#64748B] dark:text-[#94A3B8]">
Usa el mismo usuario y contraseña del CRM para entrar a tu bandeja de WhatsApp.
</p>
<a
href="/"
className="mt-6 inline-block rounded-lg bg-accent px-6 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover"
>
Ir a la página principal
</a>
{children}
</div>
</div>
)
}

20
apps/wa-crm/src/index.css Normal file
View File

@@ -0,0 +1,20 @@
@custom-variant dark (&:where(.dark, .dark *));
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-accent: #2563EB;
--color-accent-hover: #1D4ED8;
}
@layer base {
body {
@apply bg-[#F8FAFC] text-[#111827] antialiased;
}
*:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.35);
}
}

16
apps/wa-crm/src/main.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Toaster } from 'react-hot-toast'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/700.css'
import './index.css'
import WaCrmPage from './pages/WaCrmPage'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<WaCrmPage />
<Toaster position="top-right" toastOptions={{ duration: 3000 }} />
</StrictMode>,
)

View File

@@ -0,0 +1,525 @@
import { useCallback, useEffect, useState } from 'react'
import { RefreshCw, ExternalLink, MessageSquare, Send, Users } from 'lucide-react'
import toast from 'react-hot-toast'
import SinSesion from '../components/SinSesion'
import DarkModeToggle from '../components/DarkModeToggle'
import { getSesion, logout, type Sesion } from '../services/auth'
import {
dispararRoundRobin,
guardarConfigWacrm,
obtenerEstadoWacrm,
obtenerMiBandeja,
reasignarConversacion,
type EstadoWacrm,
type MiBandeja,
} from '../services/wacrm'
function esAdmin(sesion: Sesion): boolean {
return sesion.usuario.rol === 'admin' || sesion.usuario.rol === 'manager'
}
/** Fecha relativa en español corto: "hace 5 min", "ayer 14:30", "12/sep". */
export function fechaRelativa(iso: string | null): string {
if (!iso) return ''
const fecha = new Date(iso)
if (Number.isNaN(fecha.getTime())) return ''
const ahora = Date.now()
const diffMs = ahora - fecha.getTime()
const min = Math.floor(diffMs / 60000)
if (min < 1) return 'ahora'
if (min < 60) return `hace ${min} min`
const horas = Math.floor(min / 60)
if (horas < 24) return `hace ${horas} h`
const dias = Math.floor(horas / 24)
if (dias === 1) return 'ayer'
if (dias < 7) return `hace ${dias} días`
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'short' })
}
type Tab = 'bandeja' | 'admin'
export default function WaCrmPage() {
const [sesion] = useState<Sesion | null>(() => getSesion())
const [tab, setTab] = useState<Tab>('bandeja')
if (!sesion) {
return <SinSesion />
}
return (
<div className="min-h-screen bg-[#F8FAFC] text-[#111827] dark:bg-[#0F172A] dark:text-[#E2E8F0]">
<div className="border-b border-[#E5E7EB] bg-white dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-2 px-4 py-3">
<div className="flex min-w-0 items-center gap-3">
<MessageSquare className="h-6 w-6 shrink-0 text-accent" />
<div className="min-w-0">
<h1 className="truncate text-lg font-bold">WhatsApp</h1>
<p className="truncate text-xs text-[#64748B] dark:text-[#94A3B8]">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<DarkModeToggle />
<button
type="button"
onClick={() => {
logout()
window.location.reload()
}}
className="rounded-md px-3 py-2 text-sm font-medium text-accent transition-colors hover:text-accent-hover"
>
Salir
</button>
</div>
</div>
<div className="mx-auto flex max-w-5xl gap-1 px-4 pb-0">
<TabBoton activo={tab === 'bandeja'} onClick={() => setTab('bandeja')}>
Mi bandeja
</TabBoton>
{esAdmin(sesion) && (
<TabBoton activo={tab === 'admin'} onClick={() => setTab('admin')}>
Administración
</TabBoton>
)}
</div>
</div>
<main className="mx-auto max-w-5xl px-4 py-6">
{tab === 'bandeja' ? <VistaBandeja /> : <VistaAdmin />}
</main>
</div>
)
}
function TabBoton({
activo,
onClick,
children,
}: {
activo: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-t-lg border-b-2 px-4 py-2.5 text-base font-semibold transition-colors ${
activo
? 'border-accent text-accent'
: 'border-transparent text-[#64748B] hover:text-[#111827] dark:text-[#94A3B8] dark:hover:text-[#E2E8F0]'
}`}
>
{children}
</button>
)
}
// ---------------------------------------------------------------------------
// Vista: Mi bandeja (todos los roles)
// ---------------------------------------------------------------------------
function VistaBandeja() {
const [bandeja, setBandeja] = useState<MiBandeja | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null)
const cargar = useCallback(async () => {
setCargando(true)
setError(null)
try {
setBandeja(await obtenerMiBandeja())
} catch (e) {
setError(e instanceof Error ? e.message : 'No se pudo cargar la bandeja')
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
return (
<section>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-bold">Mis conversaciones</h2>
<button
type="button"
onClick={() => void cargar()}
disabled={cargando}
className="inline-flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-white px-4 py-2.5 text-base font-semibold text-[#111827] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#E2E8F0] dark:hover:bg-[#334155]"
>
<RefreshCw className={`h-4 w-4 ${cargando ? 'animate-spin' : ''}`} />
Actualizar
</button>
</div>
{cargando && bandeja === null && (
<p className="py-8 text-center text-base text-[#64748B] dark:text-[#94A3B8]">
Cargando tu bandeja…
</p>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-base text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
)}
{!cargando && bandeja && !bandeja.vinculado && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-5 text-base text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<p className="font-semibold">Tu usuario no está vinculado a WA CRM.</p>
<p className="mt-1">
Para ver tus conversaciones de WhatsApp aquí, un administrador debe vincular tu
usuario del CRM con tu usuario de WA CRM. Avísale a administración.
</p>
</div>
)}
{!cargando && bandeja && bandeja.vinculado && bandeja.conversaciones.length === 0 && (
<p className="rounded-lg border border-[#E5E7EB] bg-white p-6 text-center text-base text-[#64748B] dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#94A3B8]">
No tienes conversaciones asignadas por ahora.
</p>
)}
{bandeja && bandeja.vinculado && bandeja.conversaciones.length > 0 && (
<ul className="space-y-3">
{bandeja.conversaciones.map((conv) => (
<li
key={conv.id}
className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-lg font-semibold">
{conv.contacto.nombre || conv.contacto.telefono}
</p>
<p className="truncate text-sm text-[#64748B] dark:text-[#94A3B8]">
{conv.contacto.telefono}
</p>
</div>
{conv.unreadCount > 0 && (
<span className="rounded-full bg-accent px-2.5 py-0.5 text-sm font-bold text-white">
{conv.unreadCount}
</span>
)}
</div>
{conv.lastMessage && (
<p className="mt-2 line-clamp-2 text-base text-[#374151] dark:text-[#CBD5E1]">
{conv.lastMessage}
</p>
)}
<div className="mt-3 flex items-center justify-between gap-3">
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">
{conv.status} · {fechaRelativa(conv.lastMessageAt)}
</span>
<a
href="/wa/inbox"
className="inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-base font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Send className="h-4 w-4" />
Abrir en WA CRM
</a>
</div>
</li>
))}
</ul>
)}
</section>
)
}
// ---------------------------------------------------------------------------
// Vista: Administración (admin/manager)
// ---------------------------------------------------------------------------
function VistaAdmin() {
const [estado, setEstado] = useState<EstadoWacrm | null>(null)
const [sinPermiso, setSinPermiso] = useState(false)
const [error, setError] = useState<string | null>(null)
const [cuentaId, setCuentaId] = useState('')
const [guardandoCuenta, setGuardandoCuenta] = useState(false)
const [cambiandoToggle, setCambiandoToggle] = useState(false)
const [asignando, setAsignando] = useState(false)
const [convReasignar, setConvReasignar] = useState('')
const [vendedorReasignar, setVendedorReasignar] = useState('')
const [reasignando, setReasignando] = useState(false)
const cargar = useCallback(async () => {
setError(null)
try {
const data = await obtenerEstadoWacrm()
setEstado(data)
setCuentaId(data.cuentaId ?? '')
} catch (e) {
if (e instanceof Error && e.message.includes('403')) {
setSinPermiso(true)
} else {
setError(e instanceof Error ? e.message : 'No se pudo cargar el estado')
}
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
if (sinPermiso) {
return (
<p className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-base text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
Tu rol no tiene permisos de administración de WA CRM.
</p>
)
}
async function toggleRoundRobin(activo: boolean) {
setCambiandoToggle(true)
try {
const res = await guardarConfigWacrm({ activo })
setEstado((prev) => (prev ? { ...prev, roundRobinActivo: res.activo } : prev))
toast.success(activo ? 'Round-robin activado' : 'Round-robin desactivado')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar')
} finally {
setCambiandoToggle(false)
}
}
async function guardarCuenta() {
const valor = cuentaId.trim()
if (!valor) {
toast.error('Escribe el ID de la cuenta (UUID de wacrm)')
return
}
setGuardandoCuenta(true)
try {
const res = await guardarConfigWacrm({ cuentaId: valor })
setEstado((prev) => (prev ? { ...prev, cuentaId: res.cuentaId } : prev))
toast.success('Cuenta guardada')
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo guardar la cuenta')
} finally {
setGuardandoCuenta(false)
}
}
async function asignarAhora() {
setAsignando(true)
try {
const res = await dispararRoundRobin()
toast.success(`Asignadas ${res.asignados} conversaciones`)
await cargar()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo ejecutar la asignación')
} finally {
setAsignando(false)
}
}
async function reasignar() {
const conv = convReasignar.trim()
if (!conv || !vendedorReasignar) {
toast.error('Escribe el ID de la conversación y elige un vendedor')
return
}
setReasignando(true)
try {
await reasignarConversacion(conv, vendedorReasignar)
toast.success('Conversación reasignada')
setConvReasignar('')
setVendedorReasignar('')
await cargar()
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo reasignar')
} finally {
setReasignando(false)
}
}
if (error) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-base text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
)
}
if (!estado) {
return (
<p className="py-8 text-center text-base text-[#64748B] dark:text-[#94A3B8]">
Cargando estado…
</p>
)
}
return (
<section className="space-y-6">
{/* Resumen */}
<div className="grid gap-3 sm:grid-cols-3">
<TarjetaResumen titulo="Sin asignar" valor={estado.sinAsignar} />
<TarjetaResumen titulo="Total conversaciones" valor={estado.totalConversaciones} />
<TarjetaResumen
titulo="Round-robin"
valor={estado.roundRobinActivo ? 'Activo' : 'Inactivo'}
/>
</div>
{/* Configuración */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]">
<h3 className="flex items-center gap-2 text-lg font-bold">
<Users className="h-5 w-5 text-accent" />
Configuración
</h3>
<div className="mt-4 flex items-center justify-between gap-3">
<div>
<p className="text-base font-semibold">Round-robin automático</p>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Asigna las conversaciones nuevas entre los vendedores de la cuenta.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={estado.roundRobinActivo}
disabled={cambiandoToggle}
onClick={() => void toggleRoundRobin(!estado.roundRobinActivo)}
className={`relative h-8 w-14 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
estado.roundRobinActivo ? 'bg-accent' : 'bg-[#CBD5E1] dark:bg-[#475569]'
}`}
>
<span
className={`absolute top-1 h-6 w-6 rounded-full bg-white shadow transition-all ${
estado.roundRobinActivo ? 'left-7' : 'left-1'
}`}
/>
</button>
</div>
<div className="mt-5">
<label htmlFor="cuenta-id" className="text-base font-semibold">
Cuenta de wacrm (ID / UUID)
</label>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Es el identificador de la cuenta de WA CRM donde viven las conversaciones.
</p>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<input
id="cuenta-id"
type="text"
value={cuentaId}
onChange={(e) => setCuentaId(e.target.value)}
placeholder="p. ej. 3f2b8c1a-…"
className="w-full flex-1 rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-base dark:border-[#475569] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
/>
<button
type="button"
onClick={() => void guardarCuenta()}
disabled={guardandoCuenta}
className="rounded-lg bg-accent px-5 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{guardandoCuenta ? 'Guardando…' : 'Guardar cuenta'}
</button>
</div>
</div>
<div className="mt-5 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Asigna ahora las conversaciones sin vendedor según el round-robin.
</p>
<button
type="button"
onClick={() => void asignarAhora()}
disabled={asignando}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-accent px-5 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
<ExternalLink className="h-4 w-4" />
{asignando ? 'Asignando…' : 'Asignar ahora'}
</button>
</div>
</div>
{/* Vendedores */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]">
<h3 className="text-lg font-bold">Vendedores</h3>
<div className="mt-3 overflow-x-auto">
<table className="w-full min-w-[480px] text-left text-base">
<thead>
<tr className="border-b border-[#E5E7EB] text-sm text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]">
<th className="px-2 py-2 font-semibold">Nombre</th>
<th className="px-2 py-2 font-semibold">En la cuenta</th>
<th className="px-2 py-2 font-semibold">Asignadas</th>
</tr>
</thead>
<tbody>
{estado.vendedores.map((v) => (
<tr
key={v.id}
className="border-b border-[#F1F5F9] last:border-0 dark:border-[#0F172A]"
>
<td className="px-2 py-2.5">{v.nombre}</td>
<td className="px-2 py-2.5">
{v.esMiembro ? (
<span className="rounded-full bg-green-100 px-2.5 py-0.5 text-sm font-semibold text-green-700 dark:bg-green-900/50 dark:text-green-300">
Sí
</span>
) : (
<span className="rounded-full bg-[#F1F5F9] px-2.5 py-0.5 text-sm font-semibold text-[#64748B] dark:bg-[#334155] dark:text-[#94A3B8]">
No
</span>
)}
</td>
<td className="px-2 py-2.5">{v.asignadas}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Reasignar */}
<div className="mt-5 rounded-lg border border-[#E5E7EB] p-4 dark:border-[#334155]">
<p className="text-base font-semibold">Reasignar conversación</p>
<div className="mt-2 grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
<input
type="text"
value={convReasignar}
onChange={(e) => setConvReasignar(e.target.value)}
placeholder="ID de la conversación"
className="rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-base dark:border-[#475569] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
/>
<select
value={vendedorReasignar}
onChange={(e) => setVendedorReasignar(e.target.value)}
className="rounded-lg border border-[#E5E7EB] bg-white px-4 py-3 text-base dark:border-[#475569] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Elige vendedor…</option>
{estado.vendedores.map((v) => (
<option key={v.id} value={v.id}>
{v.nombre}
</option>
))}
</select>
<button
type="button"
onClick={() => void reasignar()}
disabled={reasignando}
className="rounded-lg bg-accent px-5 py-3 text-base font-semibold text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{reasignando ? 'Reasignando…' : 'Reasignar'}
</button>
</div>
</div>
</div>
</section>
)
}
function TarjetaResumen({ titulo, valor }: { titulo: string; valor: number | string }) {
return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#334155] dark:bg-[#1E293B]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">{titulo}</p>
<p className="mt-1 text-2xl font-bold">{valor}</p>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { conAuth, getSesion, getToken, logout, manejar401 } from './auth'
/** Stub sencillo de localStorage para correr en entorno node */
function crearStorageStub(): Storage {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => void mapa.set(k, v),
removeItem: (k: string) => void mapa.delete(k),
clear: () => mapa.clear(),
key: () => null,
get length() {
return mapa.size
},
} as Storage
}
const sesionOk = {
token: 'jwt-de-prueba',
usuario: { nombre: 'MARIA LOPEZ', rol: 'vendedor', asignacion: 'MONTEBELLO' },
}
beforeEach(() => {
vi.stubGlobal('localStorage', crearStorageStub())
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('almacenamiento de sesión', () => {
it('sin sesión: getSesion/getToken devuelven null', () => {
expect(getSesion()).toBeNull()
expect(getToken()).toBeNull()
})
it('JSON corrupto en storage se trata como "sin sesión"', () => {
localStorage.setItem('frasa_auth', '{basura')
expect(getSesion()).toBeNull()
})
it('sesión válida: devuelve token y usuario', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
expect(getToken()).toBe('jwt-de-prueba')
expect(getSesion()?.usuario.rol).toBe('vendedor')
})
it('logout limpia la sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
logout()
expect(getSesion()).toBeNull()
})
})
describe('conAuth', () => {
it('sin token devuelve el init tal cual', () => {
const init = { method: 'POST', headers: { 'Content-Type': 'application/json' } }
expect(conAuth(init)).toBe(init)
expect(conAuth()).toEqual({})
})
it('con token agrega Authorization: Bearer conservando headers', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
const resultado = conAuth({ headers: { 'Content-Type': 'application/json' } })
expect(resultado.headers).toEqual({
'Content-Type': 'application/json',
Authorization: 'Bearer jwt-de-prueba',
})
})
})
describe('manejar401', () => {
it('con 401 limpia la sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
manejar401(new Response('', { status: 401 }))
expect(getSesion()).toBeNull()
})
it('con otro status no toca la sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
manejar401(new Response('', { status: 403 }))
expect(getSesion()).not.toBeNull()
})
})

View File

@@ -0,0 +1,52 @@
// Sesión de autenticación contra el API Gateway (JWT guardado en localStorage)
// (la URL base del API la usa services/wacrm.ts vía VITE_API_URL)
const STORAGE_KEY = 'frasa_auth'
export interface UsuarioSesion {
nombre: string
rol: string
asignacion: string | null
acciones?: string[]
}
export interface Sesion {
token: string
usuario: UsuarioSesion
}
export function getSesion(): Sesion | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const sesion = JSON.parse(raw) as Sesion
if (typeof sesion?.token !== 'string' || !sesion.usuario) return null
return sesion
} catch {
return null
}
}
export function getToken(): string | null {
return getSesion()?.token ?? null
}
export function logout(): void {
localStorage.removeItem(STORAGE_KEY)
}
/**
* Agrega Authorization: Bearer a un RequestInit si hay sesión.
* Sin token devuelve el init tal cual.
*/
export function conAuth(init: RequestInit = {}): RequestInit {
const token = getToken()
if (!token) return init
return { ...init, cache: "no-store", headers: { ...(init.headers as Record<string, string>), Authorization: `Bearer ${token}` } }
}
/** Si el API responde 401: cierra sesión y recarga para volver al login */
export function manejar401(res: Response): void {
if (res.status !== 401) return
logout()
if (typeof window !== 'undefined') window.location.reload()
}

View File

@@ -0,0 +1,106 @@
// Llamadas al API Gateway para el módulo WhatsApp (WA CRM)
import { conAuth, manejar401 } from './auth'
const API_URL = import.meta.env.VITE_API_URL || '/api'
export interface ConversacionBandeja {
id: string
status: string
lastMessage: string | null
lastMessageAt: string | null
unreadCount: number
contacto: { nombre: string | null; telefono: string }
}
export interface MiBandeja {
vinculado: boolean
conversaciones: ConversacionBandeja[]
}
export interface VendedorWacrm {
id: string
nombre: string
wacrmUserId: string | null
esMiembro: boolean
asignadas: number
}
export interface EstadoWacrm {
cuentaId: string | null
roundRobinActivo: boolean
sinAsignar: number
totalConversaciones: number
vendedores: VendedorWacrm[]
}
export interface ConfigWacrm {
cuentaId: string | null
activo: boolean
}
function errorDeApi(res: Response, fallback: string): Error {
return new Error(`${fallback} (error ${res.status})`)
}
async function parseError(res: Response, fallback: string): Promise<Error> {
const body = (await res.json().catch(() => null)) as { error?: string; message?: string } | null
return new Error(body?.error ?? body?.message ?? `${fallback} (error ${res.status})`)
}
export async function obtenerMiBandeja(): Promise<MiBandeja> {
const res = await fetch(`${API_URL}/wacrm/mi-bandeja`, conAuth())
manejar401(res)
if (!res.ok) throw await parseError(res, 'No se pudo cargar la bandeja')
return (await res.json()) as MiBandeja
}
export async function obtenerEstadoWacrm(): Promise<EstadoWacrm> {
const res = await fetch(`${API_URL}/wacrm/estado`, conAuth())
manejar401(res)
if (res.status === 403) throw errorDeApi(res, 'Sin permisos de administración')
if (!res.ok) throw await parseError(res, 'No se pudo cargar el estado')
return (await res.json()) as EstadoWacrm
}
export async function guardarConfigWacrm(cambios: {
cuentaId?: string
activo?: boolean
}): Promise<ConfigWacrm> {
const res = await fetch(
`${API_URL}/wacrm/config`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cambios),
}),
)
manejar401(res)
if (!res.ok) throw await parseError(res, 'No se pudo guardar la configuración')
return (await res.json()) as ConfigWacrm
}
export async function dispararRoundRobin(): Promise<{ asignados: number }> {
const res = await fetch(
`${API_URL}/wacrm/round-robin`,
conAuth({ method: 'POST' }),
)
manejar401(res)
if (!res.ok) throw await parseError(res, 'No se pudo ejecutar la asignación')
return (await res.json()) as { asignados: number }
}
export async function reasignarConversacion(
conversacionId: string,
vendedorId: string,
): Promise<void> {
const res = await fetch(
`${API_URL}/wacrm/reasignar`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversacionId, vendedorId }),
}),
)
manejar401(res)
if (!res.ok) throw await parseError(res, 'No se pudo reasignar la conversación')
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "vitest.config.ts"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// La app se sirve detrás de NGINX bajo el prefijo /wa-crm
export default defineConfig({
base: '/wa-crm/',
plugins: [react(), tailwindcss()],
})

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
// Config de tests separada para no mezclar los tipos de Vite y Vitest
export default defineConfig({
test: {
environment: 'node',
},
})

54
docs/MODULO-WHATSAPP.md Normal file
View File

@@ -0,0 +1,54 @@
# Módulo WhatsApp (WA CRM) — CRM360 FRASA
Integración entre el CRM y [wacrm](https://wacrm.tech) (WhatsApp CRM
self-hosted en `/root/wacrm`, datos en Supabase self-hosted `/root/supabase`).
## Cómo funciona
- **Vínculo de usuarios**: cada `Usuario` del CRM tiene `wacrmUserId` (id de
`auth.users` en Supabase). Los 30 usuarios activos del CRM están dados de
alta en wacrm y su perfil pertenece a la **cuenta compartida**
(`7b17a9ab-6723-43aa-a9da-28e97f0071ae`, del usuario administrador).
Vendedores = rol `agent`, managers/admins = rol `admin`.
- **Round-robin**: el API Gateway (`frasa-api`) revisa cada 30 s las
conversaciones de la cuenta compartida que llegan sin asignar
(`assigned_agent_id IS NULL`, estatus `open`) y las turna entre los
vendedores del pool (rol `vendedor`, activos, vinculados y miembros agente
de la cuenta). El índice del turno vive en `ConfiguracionSistema`
(`wacrm_rr_idx`), así que sobrevive reinicios.
- **Bandeja del vendedor**: `GET /api/wacrm/mi-bandeja` devuelve las
conversaciones asignadas al usuario logueado (contacto, último mensaje,
no leídos). La app `apps/wa-crm` (`/wa-crm/`) las muestra con liga a
`/wa/inbox` para responder desde wacrm.
- **Administración** (rol admin/manager, pestaña en `/wa-crm/`):
- Estado: sin asignar, total, pool con "es miembro" y cuántas tiene cada uno.
- Activar/desactivar round-robin y cambiar la cuenta (`POST /api/wacrm/config`).
- "Asignar ahora" dispara el reparto a demanda (`POST /api/wacrm/round-robin`).
- Reasignar una conversación a cualquier vendedor (`POST /api/wacrm/reasignar`).
## Conexión técnica
- `WACRM_PG_URL` en `/root/frasa-ecosystem/.env` apunta al Postgres de
Supabase por `127.0.0.1:5433` (puerto publicado solo en localhost del
contenedor `supabase-db`; el 5432 es Supavisor y exige tenant).
- El servicio `api-gateway/src/services/wacrm.service.ts` usa `pg` (Pool lazy;
sin URL lanza 503 y el resto del CRM sigue igual).
- Al escribir (`UPDATE conversations`) se hace como superusuario postgres,
bypass RLS — solo el API lo hace, nunca el cliente.
## Puesta en marcha del número
El round-robin está activo sobre la cuenta compartida. Falta conectar el
número de WhatsApp (API oficial de Meta) en wacrm: credenciales y pasos en
`/root/wacrm/ENV-PENDIENTES.md`. Cuando Meta entregue el número, conectarlo
desde **wacrm → Settings → WhatsApp** con la cuenta del administrador.
## Tests
`api-gateway/src/services/wacrm.service.test.ts` — integración real contra
Supabase (se auto-salta si no hay `WACRM_PG_URL`); crea usuarios/cuenta/
conversaciones de prueba y los limpia. Correr:
```bash
cd api-gateway && npx vitest run src/services/wacrm.service.test.ts
```

View File

@@ -0,0 +1,125 @@
# Plan — Nuevas reglas de negocio del Cotizador FRASA
Fuente: `Reglas de negocio Cotizador FRASA .xlsx` (entregado por el cliente).
Alcance: aplicar estas reglas al cotizador web (`apps/cotizador`) y a su espejo
del lado del API (`api-gateway/src/services/cotizacion-esquema.service.ts`).
## 1. Lectura de las reglas (interpretación)
### Globales (todos los tipos)
| Concepto | Regla |
|---|---|
| Apartado | $15,000 (cantidad fija, ya confirmada por el cliente) |
| Gastos de escrituración | 5.5 % del precio de venta |
| Gastos — opción del cliente | Puede **incluirlos** en el pago de firma (toggle). Si los incluye, se liquidan junto con el enganche para firma de contrato |
| Mensualidades a diferir | Número de **meses calendario completos** entre la fecha de cotización y la fecha de entrega de la vivienda (ya implementado vía captura) |
| Mensualidad a pagar | Enganche diferido ÷ mensualidades |
| Enganche total | El **% total que el cliente elige pagar** como enganche (incluye el apartado). Tope por tipo de crédito |
### Tipos de crédito
| Tipo | Rango enganche **para firma** | Tope enganche **total** | Aforo | Notaría |
|---|---|---|---|---|
| Bancario | 2.5 % – 15 % | 15 % | 84.5 % | No aplica |
| Infonavit-Cofinavit | 2.5 % – 15 % | 15 % | 89.5 % | No aplica |
| Semi Contado | 50 % – 85 % | 85 % | No aplica | Debe quedar **15 %** para notaría |
| Contado | 86 % – 95 % | 95 % | No aplica | Debe quedar **mínimo 5 %** para notaría |
### Fórmulas
```
gastos = 0.055 × precio
firma = % enganche firma elegido (dentro del rango del tipo)
totalEng = % enganche total elegido (≤ tope del tipo; firma ≤ totalEng)
diferido = totalEng − firma (lo que se difiere en mensualidades)
mensualidad = diferido × precio ÷ N_meses
saldoPorPagar = precio + gastos − (firma% + diferido%) × precio
(= precio + gastos − totalEng% × precio)
```
- **Bancario / Infonavit-Cofinavit**: `monto a financiar = saldoPorPagar`.
Validación de **aforo**: `saldoPorPagar ≤ aforo × precio`; si se rebasa, la
combinación elegida no es viable y hay que pedir más enganche.
- **Semi Contado**: no hay financiamiento. Restricción: el saldo por pagar debe
dejar **≥ 15 %** para el pago en notaría (`saldoPorPagar ≥ 0.15 × precio`,
equivalente a `totalEng ≤ 0.85 + gastos%`). El "monto pago en notaría" = 15 %.
- **Contado**: sin mensualidades (diferido = 0; todo el enganche se paga a la
firma). Restricción: saldo ≥ 5 % para notaría (`totalEng ≤ 0.95 + gastos%`).
### Diferencias contra el esquema actual
| Aspecto | Esquema actual (FRASA) | Nuevo |
|---|---|---|
| Enganche firma | fijo 2.5 % | rango por tipo (2.5–15 / 50–85 / 86–95 %) |
| Enganche diferible | fijo 7.5 % editable | **total elegido − firma** |
| Tope enganche | 90 % global | por tipo: 15 / 15 / 85 / 95 % |
| Aforo | no se valida | validación 84.5 / 89.5 % (bancario/infonavit) |
| Gastos 5.5 % | siempre aparte | **toggle**: incluidos en firma u aparte |
| Notaría | no existe | piso 15 % (semi) / 5 % (contado) |
| Anticipo adicional | campo extra (decisión previa: sin espacio separado) | se absorbe en "enganche total" |
## 2. Puntos a confirmar con el cliente (antes o durante la implementación)
1. **Apartado dentro del %**: se interpreta que el % de enganche total es
dinero total (el apartado de $15,000 forma parte de él), según la columna
K ("% de enganche elegido incluido el apartado"). Confirmar.
2. **Semi Contado / Contado**: ¿el "pago en notaría" (15 % / 5 %) es parte del
saldo por pagar que el cliente liquida al final? Se asume que sí.
3. **Aforo**: si el financiamiento calculado supera el aforo, ¿bloquear la
opción o mostrarla en rojo como "no viable"? Se propone mostrar no viable.
4. **Rango de enganche para firma en créditos**: ¿el 2.5 % mínimo sigue
incluyendo el apartado como parte del pago a firma (como hoy)? Se propone
que sí.
## 3. Plan de implementación
### 3.1 Motor de cálculo — `esquema-frasa.ts` (app) y `cotizacion-esquema.service.ts` (API, espejo)
1. Nuevo tipo `TipoCredito = 'bancario' | 'infonavit' | 'contado' | 'semicontado'`
(reemplaza/usa el catálogo actual de opciones de crédito).
2. Tabla de reglas por tipo (única fuente de verdad en ambos lados, misma
constante): `REGLAS_TIPO` con `{rangoFirmaMin, rangoFirmaMax, topeTotal,
aforo, pisoNotaria, permiteDiferir}`.
3. Entrada del motor: `{precio, apartado, tipo, pctFirma, pctTotal, incluirGastosEnFirma, meses}`.
4. Salida: `{gastos, montoFirma, montoDiferido, mensualidad, saldoPorPagar,
montoFinanciar, pagoNotaria, viable, motivos[]}` con validaciones:
- firma dentro de rango; total ≤ tope; total ≥ firma;
- si diferido > 0 → meses ≥ 1 (tope por fecha de entrega, ya existente);
- aforo (bancario/infonavit): `saldo ≤ aforo × precio`;
- piso notaría (semi/contado): `saldo ≥ piso × precio`.
5. Quitar del esquema actual lo que queda reemplazado: enganche fijo 2.5/7.5,
tope global 90 %, `engancheAdicional` (el "total elegido" lo absorbe).
### 3.2 UI del cotizador (`apps/cotizador`)
1. Selector de **tipo de crédito** con las 4 opciones y sus topes visibles.
2. **Slider/input de enganche total** (0 → tope del tipo) y **slider/input de
enganche a firma** (rango del tipo, ≤ total).
3. **Toggle "Incluir gastos de escrituración en el pago de firma"**.
4. Tabla de resultados: apartado, firma, diferido, mensualidad (×N meses),
gastos, saldo, monto a financiar (o pago en notaría), y badge **viable /
no viable** con motivo (aforo excedido, piso notaría, etc.).
5. Las 4 opciones de crédito del guardado existente pasan a ser las 4
combinaciones tipo+porcentajes que el vendedor guarde, no 4 presets fijos.
### 3.3 Persistencia y reportes
1. `Cotizacion`: columnas nuevas `tipoCredito`, `pctFirma`, `pctTotal`,
`incluirGastosFirma` (migración SQLite manual, estilo existente). Mantener
`opcionesCredito` JSON para compatibilidad de lectura histórica.
2. PDF: sección de desglose según nuevo esquema (firma / diferido / notaría /
financiamiento, viable o no).
### 3.4 Pruebas
- Unitarias del motor: casos dorados por tipo (límites de rango, tope, aforo
justo arriba/abajo, pisos de notaría, toggle de gastos).
- Smoke API: `POST` cotización con cada tipo → verificar montos y `viable`.
- Regresión: cotizaciones históricas existentes siguen leyéndose.
### 3.5 Entrega
- Migración + build de `api-gateway` y `apps/cotizador`.
- Actualización de guía de usuario del cotizador (sección reglas).
- Resumen de cambios para el cliente con los puntos 2 resueltos.