Compare commits

...

32 Commits

Author SHA1 Message Date
e3b5660769 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.
2026-09-30 00:30:17 +00:00
cb1c36b669 feat(cotizador): enganche editable directo en su renglon (min 7.5%, tope 90% de anticipo); se quita el espacio separado de adicional 2026-09-29 21:28:53 +00:00
013e13b9df feat(cotizador): enganche adicional hasta 90% de anticipo total (UI, opciones de credito, PDF, API y persistencia) 2026-09-29 21:08:02 +00:00
ed367582c3 feat(cotizador): tope de mensualidades = meses que faltan para la entrega (UI y API); nunca se rebasa la fecha de entrega 2026-09-29 20:50:25 +00:00
4e3ce67ae7 feat(cotizador): mensualidades del diferimiento = meses faltantes para la entrega estimada de la unidad (captura de obra) 2026-09-29 20:11:24 +00:00
1c1e6b04e4 fix(admin): reasignar mueve toda la cadena del cliente (lead+oportunidad+apartado) y sus actividades pendientes; 404 si el id no existe 2026-09-29 19:42:42 +00:00
17afcc9d8f fix(alta-cliente/afluencia): telefono limitado a 10 digitos, normalizacion de valores heredados con lada duplicada o caracteres invisibles 2026-09-29 19:33:36 +00:00
438e3c85af fix(mi-dia): telefono de captura de lead limitado a 10 digitos (solo numeros, validacion al enviar) 2026-09-29 19:28:09 +00:00
9bdd666626 fix(busqueda): telefono por digitos normalizados (espacios/ladas/invisibles) y nombres sin acento 2026-09-29 19:08:51 +00:00
2533a05aab fix(desarrollos): un par canonico de estatus de unidad (APARTADO/VENDIDO); normaliza variantes APARTADA/VENDIDA en BD 2026-09-29 17:27:40 +00:00
4a3ddd2f35 feat(cotizador): 4 opciones de credito sobre el esquema FRASA (saldo 84.5% con mensualidad estimada)
- Nuevo modulo puro lib/opciones-credito.ts: Bancario, Infonavit-Cofinavit,
  Semi Contado (1%) y Contado (2%), con descuentos del Excel del cliente.
  Cada opcion recalcula el esquema FRASA 2026 sobre el precio final y
  estima la mensualidad del credito (anualidad con tasa y plazo ajustables;
  % de contado para la Semi). Ajustes validados (tasa 0-50%, plazo 1-35).
- Nuevo componente OpcionesCredito: grid de 4 tarjetas con tasa/plazo/% de
  contado editables, mensualidad estimada y boton 'Usar en el PDF'.
- CotizadorPage: estado de ajustes y opcion elegida, memo con try/catch
  (defaults de respaldo), restauracion defensiva al retomar cotizacion.
- PDF: seccion 'PROPUESTA DE FINANCIAMIENTO ELEGIDA' con renglones del
  credito (o pago de contado y ahorro para Contado) + disclaimer.
- API: columna Cotizacion.opcionesCredito (TEXT, JSON {elegida, ajustes})
  con migracion manual aplicada en SQLite; zod max 4000; se persiste crudo
  y los GET devuelven la fila completa.

Tests: cotizador 149/149 (19 nuevos en opciones-credito, 2 en pdf),
API 746/746 (3 nuevos en cotizaciones). Smoke real POST+GET en produccion
local con unidad ATELIER-34 (201 con opcionesCredito; fila de prueba borrada).
2026-09-28 19:56:37 +00:00
61d1b84fc3 feat: esquema unico de cotizacion FRASA 2026 (reemplaza las 4 propuestas)
Flujo aprobado por el cliente (28 sep 2026), porcentajes globales para
todos los desarrollos:
- Apartado: cantidad libre (sin minimo), se acredita dentro del enganche
  de firma; el excedente se descuenta del enganche
- Enganche de firma: 2.5% del precio (saldo a firma = max(0, 2.5% - apartado))
- Enganche: 7.5%, diferible en N mensualidades (N a eleccion del vendedor, 1-36)
- Gastos de escrituracion: 5.5%, pago unico a la firma o diferido con el
  enganche (toggle)

- lib/esquema-frasa.ts (frontend) + cotizacion-esquema.service.ts (backend):
  modulos puros espejo, caso dorado M/apartado 5k/N=14 en tests de ambos
- Migracion 20260928120000: 7 columnas nuevas en Cotizacion (esquema,
  saldoFirma, engancheDiferible, escrituracion, etc.); legacy se rellena
  para compatibilidad de reportes (tipoPropuesta=FRASA_2026)
- UI: panel de linea de tiempo (Hoy -> Firma -> Mensualidades) sustituye a
  propuestas/comparadores; PDF y WhatsApp con el esquema unico
- API recalcula server-side; smoke end-to-end verificado contra inventario
- 743 tests API + 128 cotizador en verde
2026-09-28 13:00:22 +00:00
23d288d0aa feat: columna Asesor en export CSV de contactos
Nombre del usuario que dio de alta cada contacto (cliente: capturadoPor;
lead/oportunidad/apartado: usuarioId). Resuelto con un solo findMany de
Usuario a mapa, sin N+1.
2026-09-26 22:00:29 +00:00
ea03e6eeba chore: limpieza de 26 registros duplicados (motivo Duplicado)
Post-fix del dedup telefonoNorm: descarte de los leads/oportunidades/
apartados duplicados acumulados antes del 26/09. Regla: conservar la
cadena mas avanzada por telefono; el resto queda con etapa terminal y
motivo 'Duplicado' (sin borrar, con entrada de auditoria). Script
idempotente con validacion de board+etapa esperados.
2026-09-26 21:37:07 +00:00
db28af1c58 fix: dedup de leads por telefonoNorm + lock anti-carrera (duplicados)
- Migracion: columna LeadCaptura.telefonoNorm (ultimos 10 digitos) con
  backfill de los 873 registros e indice
- buscarPorTelefono usa telefonoNorm exacto: la busqueda anterior
  'telefono contains' fallaba con telefonos guardados con espacios
  ('+52 664 225 7841' no contiene '6642257841') y dejaba pasar duplicados
- Mutex en memoria por telefono/email: dos envios simultaneos ya no pasan
  juntos la validacion
- convertirLead/apartar propagan telefonoNorm; email se guarda en minusculas
- 8 tests nuevos (normalizacion, dedup con espacios, lock)
2026-09-26 21:19:31 +00:00
19a8a5b037 docs: quitar apartado de control de versiones del documento de cobro 2026-09-26 01:09:47 +00:00
3e8dca3c37 docs(alcance): version 1.5 - app Android $20,000 y rediseño sin Monday $20,000, partidas restantes agrupadas por seccion; total sigue $70,000 2026-09-25 23:38:22 +00:00
5d1b366cbd docs(alcance): version 1.4 - importe total $70,000 IVA incluido por todo el desarrollo adicional, sin modulo de buro (se cotiza aparte) 2026-09-25 23:02:36 +00:00
6435086181 feat(alta-clientes): telefono obligatorio en alta y afluencia, importacion de informacion Enkontrol por ID en pantalla final y campos de obra movidos a la seccion Obra en captura 2026-09-25 21:41:59 +00:00
2400ecf6ff feat(crm): fecha de alta en cards y bitacora, click en kanban abre bitacora, tab Descartados con reasignacion para admin, tareas al calendario, estatus No asistio y edicion de unidades del inventario 2026-09-25 18:28:14 +00:00
7f663208a0 feat(mi-dia): gate de afluencia para apartar, pestana Afluencia en bitacora, cita cliente con fecha en calendario, filtros por desarrollo/origen corregidos, rangos salariales nuevos y soporte como acceso directo 2026-09-25 10:23:24 +00:00
8a5db114ec feat(mi-dia): captura rapida arriba de actividades, filtro admin por vendedor, hora en notas, orden Intento->Contactado y kanban de oportunidades alineado 2026-09-25 09:21:23 +00:00
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
cfc0acb5d3 fix(crm): re-captura tras limpieza, bitacora compartida, origen en oportunidades y privacidad del buscador
1. Re-captura: el dedup por telefono/email ya no considera etapas cerradas
   (Descartado, Perdido, Cancelo, Cancelacion Apartado). Los restos de la
   ultima limpieza (17 filas de APARTADO cancelados sin contraparte activa)
   bloqueaban a los vendedores al re-capturar; los registros activos siguen
   bloqueando con 409 como siempre.
2. Bitacora compartida: GET /recordatorios con leadId/clienteClave devuelve las
   notas de TODOS los usuarios de ese cliente (antes cada quien solo veia las
   suyas). Mi dia pide la bitacora con esos filtros; la lista personal sigue
   siendo propia.
3. Origen en oportunidades: el resumen de mi-dia ya devuelve origen y la card
   de cada oportunidad lo muestra (telefono · desarrollo · canal · modelo).
4. Privacidad del buscador: /leads/buscar y /buscar filtran por dueno cuando
   el rol es vendedor (leads por usuarioId, contactos por capturadoPorId,
   cotizaciones por vendedorId). Los roles de supervision siguen viendo todo.
   Ya no aparecen leads de otros asesores con nombre y telefono al teclear.

Verificado en vivo contra el API: 10/10 comprobaciones (re-captura del
telefono cancelado 201, duplicado activo 409, nota del admin visible para la
vendedora, origen presente en 27/27 oportunidades, buscador del vendedor sin
datos ajenos y del admin completo). 716 pruebas del API y 32 de mi-dia en verde.
Los tests de roles ahora usan prisma mockeado (la BD viva cambia cuando el
cliente edita permisos desde la UI).
2026-09-24 09:49:41 +00:00
d5276f4466 docs: reacomodar importes con app Android (20,000) y buro de credito (15,000) aparte, mas 3,000 de cambios web (v1.3)
- Aplicacion para Android con precio propio de $20,000.00, dentro del paquete.
- Modulo de buro de credito con precio de $15,000.00, aparte del paquete.
- Cambios en la pagina web: $3,000.00 aparte del paquete (partida 39 nueva).
- Las otras 37 partidas reacomodadas para que el paquete siga sumando $70,000.00
  con IVA incluido (verificado sumando la tabla).
- Resumen de importes en tres bloques: paquete $70,000, conceptos aparte $18,000 y
  total $88,000.00 con IVA incluido (subtotal $75,862.07 + IVA $12,137.93).
- Portada con los tres importes, fecha 23 de septiembre y control de versiones 1.3.
- PDF (19 paginas) y pagina web regenerados y publicados.
2026-09-23 00:03:22 +00:00
9d97197ba1 docs: fijar importes del desarrollo adicional (70,000 IVA incluido) e incluir app Android (v1.2)
- Seccion 10 con el importe de cada una de las 38 partidas, ya con IVA incluido;
  la suma cuadra exactamente con el total (se verifico sumando la tabla).
- Seccion 12 nueva: suma de partidas $70,000.00, subtotal $60,344.83, IVA 16%
  $9,655.17 y total a pagar $70,000.00 (setenta mil pesos 00/100 M.N.).
- Partida 38 nueva: aplicacion para Android, incluida en el importe y pendiente
  de ejecutar (hoy el sistema se instala desde el navegador como PWA).
- El modulo de buro de credito queda fuera del importe y marcado como "se cotiza
  por separado", por depender del contrato y las credenciales del buro.
- Portada con la version 1.2 y el importe total; control de versiones actualizado.
- PDF (18 paginas) y pagina web regenerados.
2026-09-22 09:59:20 +00:00
e963267f9f docs: agregar el modulo de conexion a buro de credito al documento de extras (v1.1)
- Seccion 6.9 nueva: la solicitud queda registrada como partida 37, marcada como
  PENDIENTE DE EJECUTAR (no entregada) para no inflar lo facturable.
- Incluye el alcance propuesto (consulta desde alta y contactos, consentimiento
  del titular, folio y reporte en el expediente, control de consumo y permisos)
  y los requisitos que debe entregar el cliente: contrato, credenciales de la API
  del buro y definicion de la informacion que quiere ver.
- Partida 6.9-37 en la tabla de facturacion y punto 5 en los pendientes abiertos.
- Control de versiones en la seccion 1, fecha de emision 22 de septiembre y
  cronologia actualizada. PDF (17 paginas) y pagina web regenerados.
2026-09-22 04:03:03 +00:00
e8d1c57ad5 docs: publicar el documento de alcance como pagina web privada en /doc/alcance-2f9k7d/
- scripts/generar-web-documento.py arma la pagina (barra con logotipo, boton de
  descarga del PDF, hoja centrada, tablas con scroll en pantallas chicas, modo
  oscuro automatico y estilos de impresion) y reutiliza el generador de PDF.
- La pagina se sirve con un prefijo privado en NGINX: no se enlaza desde ninguna
  app, lleva X-Robots-Tag y meta noindex, y la cache se desactiva para que los
  cambios se vean al momento.
- Se sincroniza deploy/nginx-host.conf con lo que estaba instalado en el host
  (faltaba el cache de assets immutable) y se agrega la ruta privada.
- El logotipo se reduce a tamano de pantalla: venia de un JPEG de 959 px y era el
  90% del peso del PDF (495 KB -> 240 KB), sin cambiar el diseno.
2026-09-21 19:16:48 +00:00
31abeec362 docs: generador reutilizable de PDF con portada institucional
Script que convierte cualquier documento de docs/ a PDF (portada con logotipo y
tabla de datos, encabezados de tabla repetidos, pie numerado y control de saltos
de pagina). Genera el logotipo con transparencia real: el PNG del repo trae fondo
gris y sobre la portada blanca dibujaba un rectangulo.

Con esto el PDF del documento de alcance se puede regenerar tras cualquier
cambio del markdown:
  /root/.venv-tools/bin/python scripts/generar-pdf-documento.py docs/ALCANCE-VS-ENTREGADO.md
2026-09-21 19:06:57 +00:00
7c9ed699c4 docs: alcance contratado vs. desarrollo entregado (soporte para facturar el desarrollo adicional)
Documento en markdown y PDF con:
- El alcance originalmente contratado (4 apps, API Gateway puente a Monday,
  infraestructura Docker/NGINX/SSL y checklist de 10 dias) y lo diferido (WA CRM).
- El estado entregado: 7 aplicaciones en produccion, 12 modulos, 137 endpoints,
  33 tablas, 7 roles, 1,203 pruebas automatizadas y las metricas de uso real.
- 36 partidas de desarrollo adicional solicitado fuera del alcance, con evidencia
  en commits del repositorio, mas 11 retrabajos por cambios de sentido.
- Tabla de partidas con unidad de cobro sugerida y columnas de horas e importe
  en blanco para acordarlas con el cliente.
- Seccion de pendientes abiertos (WA CRM no solicitado, reglas de SOLE y MUTUO,
  contactos del micrositio, migracion opcional a PostgreSQL/Redis).
2026-09-21 19:05:44 +00:00
d6548999bf feat(desarrollos): modulo de alta de desarrollos con inventario, reglas del cotizador por desarrollo y micrositio publico
Modulo nuevo (solo admin) para dar de alta desarrollos sin tocar codigo:
- CRUD de la ficha, modelos e inventario (alta manual y generador por rango)
- Reglas del cotizador por desarrollo, editables y persistidas en la BD
- El cotizador consume las reglas desde /desarrollos/reglas-cotizador y cae a
  las constantes con aviso cuando un desarrollo aun no tiene reglas guardadas
- Micrositio publico en /d/:clave (sin sesion) con ficha, modelos y
  disponibilidad; jamas expone datos de clientes ni cotizaciones

Integracion:
- La landing monta el modulo en el grid (clave 'desarrollos', solo admin) y el
  micrositio fuera del login; el rol manager NO accede (matriz y API con 403)
- La tabla de roles de Configuracion ahora lista todos los modulos
- Se alinea la FK de ReglaCotizadorDesarrollo con la BD viva (Desarrollo.id)

Verificado: 708 pruebas del API, 115 del cotizador y 89 de la landing en verde;
12/12 cotizaciones identicas antes y despues del cambio de reglas; prueba en
vivo del API con alta, modelos, inventario, reglas, micrositio y baja (30/30).
2026-09-21 09:52:47 +00:00
2fc80e7f9e feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol 2026-09-21 08:34:53 +00:00
223 changed files with 26288 additions and 1857 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

@@ -15,6 +15,7 @@
"seed:ai": "tsx scripts/seed-ai.ts",
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
"seed:config": "tsx scripts/seed-config.js",
"seed:desarrollos": "tsx scripts/seed-desarrollos.js",
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
"prisma:migrate": "prisma migrate dev",
"seed:catalogos": "node scripts/actualizar-catalogos-ia.js"
@@ -28,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"
},
@@ -39,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

@@ -0,0 +1,58 @@
-- Módulo Desarrollos: catálogo de desarrollos (ficha, modelos y reglas del
-- cotizador) más el piso (`nivel`) del inventario.
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
-- CreateTable Desarrollo
CREATE TABLE IF NOT EXISTS "Desarrollo" (
"id" TEXT NOT NULL PRIMARY KEY,
"clave" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"estado" TEXT NOT NULL DEFAULT 'ACTIVO',
"tipo" TEXT NOT NULL DEFAULT 'VERTICAL',
"constructora" TEXT,
"direccion" TEXT,
"zona" TEXT,
"descripcion" TEXT,
"amenidades" TEXT,
"fechaInicioObra" DATETIME,
"fechaEntregaProgramada" DATETIME,
"fechaEntregaReprogramada" DATETIME,
"activoEnCotizador" BOOLEAN NOT NULL DEFAULT true,
"activoEnAlta" BOOLEAN NOT NULL DEFAULT true,
"prefijoUnidadPdf" TEXT,
"imagenUrl" TEXT,
"logoUrl" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "Desarrollo_clave_key" ON "Desarrollo"("clave");
CREATE INDEX IF NOT EXISTS "Desarrollo_estado_idx" ON "Desarrollo"("estado");
-- CreateTable ModeloDesarrollo
CREATE TABLE IF NOT EXISTS "ModeloDesarrollo" (
"id" TEXT NOT NULL PRIMARY KEY,
"desarrolloId" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"recamaras" INTEGER,
"banos" REAL,
"estacionamientos" INTEGER,
"supConstruccion" REAL,
"activo" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "ModeloDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_nombre_key" ON "ModeloDesarrollo"("desarrolloId", "nombre");
CREATE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_idx" ON "ModeloDesarrollo"("desarrolloId");
-- CreateTable ReglaCotizadorDesarrollo
-- `desarrolloId` guarda el uuid de `Desarrollo.id` (lo que escriben el seed y
-- PUT /desarrollos/:id/reglas). El endpoint del cotizador traduce uuid → clave.
CREATE TABLE IF NOT EXISTS "ReglaCotizadorDesarrollo" (
"desarrolloId" TEXT NOT NULL PRIMARY KEY,
"parametrosJson" TEXT NOT NULL,
"updatedAt" DATETIME NOT NULL,
"updatedPorId" TEXT,
CONSTRAINT "ReglaCotizadorDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- AlterTable Unidad: piso del inventario ('' si no aplica)
ALTER TABLE "Unidad" ADD COLUMN "nivel" TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,20 @@
-- Dedup de leads/afluencias: teléfono normalizado (solo dígitos, últimos 10)
-- en su propia columna indexada. La búsqueda anterior `telefono contains`
-- fallaba contra teléfonos guardados con espacios ("+52 664 225 7841" no
-- contiene "6642257841"), permitiendo duplicados.
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
ALTER TABLE "LeadCaptura" ADD COLUMN "telefonoNorm" TEXT;
-- Backfill: últimos 10 dígitos cuando hay al menos 10; si no, todos los
-- dígitos (coincide con normalizaTelefono() del servicio).
UPDATE "LeadCaptura"
SET "telefonoNorm" = CASE
WHEN length(replace(replace(replace(replace(replace(coalesce("telefono", ''), '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')) >= 10
THEN substr(replace(replace(replace(replace(replace("telefono", '+', ''), ' ', ''), '-', ''), '(', ''), ')', ''), -10)
WHEN length(replace(replace(replace(replace(replace(coalesce("telefono", ''), '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')) >= 7
THEN replace(replace(replace(replace(replace("telefono", '+', ''), ' ', ''), '-', ''), '(', ''), ')', '')
ELSE NULL
END;
CREATE INDEX IF NOT EXISTS "LeadCaptura_telefonoNorm_idx" ON "LeadCaptura"("telefonoNorm");

View File

@@ -0,0 +1,14 @@
-- Esquema de cotización único FRASA 2026 (aprobado por el cliente el 28 sep 2026):
-- apartado libre + enganche de firma 2.5% + enganche 7.5% diferible +
-- escrituración 5.5% (a la firma o diferida con el enganche). Las columnas
-- legacy (tipoPropuesta, descuento, precioFinal, enganche, mensualidad,
-- pagoFinal) se siguen llenando para compatibilidad de reportes.
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
ALTER TABLE "Cotizacion" ADD COLUMN "esquema" TEXT NOT NULL DEFAULT 'FRASA_2026';
ALTER TABLE "Cotizacion" ADD COLUMN "saldoFirma" REAL;
ALTER TABLE "Cotizacion" ADD COLUMN "pctEngancheFirma" REAL;
ALTER TABLE "Cotizacion" ADD COLUMN "engancheDiferible" REAL;
ALTER TABLE "Cotizacion" ADD COLUMN "pctEscrituracion" REAL;
ALTER TABLE "Cotizacion" ADD COLUMN "escrituracionDiferida" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Cotizacion" ADD COLUMN "escrituracion" REAL;

View File

@@ -0,0 +1,7 @@
-- Opciones de credito del saldo (84.5%) sobre el esquema FRASA 2026: las 4
-- propuestas del Excel del cliente (Bancario, Infonavit-Cofinavit, Semi
-- Contado, Contado) con la opcion elegida y los ajustes del vendedor.
-- Se guarda como JSON crudo {elegida, ajustes}; null en filas anteriores.
-- Patron manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
ALTER TABLE "Cotizacion" ADD COLUMN "opcionesCredito" TEXT;

View File

@@ -0,0 +1,6 @@
-- Enganche adicional (anticipo extra) del esquema FRASA 2026: el cliente puede
-- dar hasta 90% de anticipo total (esquema + adicional), quedando un mínimo
-- del 10% del precio por financiar. 0 por default (no aplica a filas viejas).
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
ALTER TABLE "Cotizacion" ADD COLUMN "engancheAdicional" REAL NOT NULL DEFAULT 0;

View File

@@ -21,7 +21,7 @@ model Cotizacion {
clienteNombre String
clienteEmail String?
clienteTelefono String?
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO | FRASA_2026 (esquema único)
acabados Boolean
apartado Float // TODO prod: Decimal en Postgres
descuento Float // fracción (0.02 = 2%)
@@ -31,6 +31,20 @@ model Cotizacion {
numMensualidades Int
mensualidad Float? // null => N/A (Contado)
pagoFinal Float
// Esquema único FRASA 2026 (apartado + firma 2.5% + enganche 7.5% +
// escrituración 5.5%). Las filas legacy tienen esquema='FRASA_2026' por el
// DEFAULT pero saldoFirma/engancheDiferible/escrituracion null.
esquema String @default("FRASA_2026")
saldoFirma Float? // lo que se paga a la firma por el enganche de firma (>= 0)
pctEngancheFirma Float? // 0.025 (constante del esquema; se persiste por si cambia)
engancheDiferible Float? // enganche del 7.5%, neto del excedente del apartado
pctEscrituracion Float? // 0.055
escrituracionDiferida Boolean @default(false)
escrituracion Float? // gastos de escrituración (5.5% del precio)
engancheAdicional Float? @default(0) // anticipo extra; el total no puede pasar del 90% del precio
// Opciones de credito del saldo (84.5%): JSON {elegida, ajustes} serializado;
// el cotizador manda el ajuste del vendedor y se persiste crudo.
opcionesCredito String?
mondayItemId String?
createdAt DateTime @default(now())
@@ -173,6 +187,10 @@ model LeadCaptura {
usuarioId String // Usuario.id del capturista
nombreLead String
telefono String?
// Teléfono normalizado (solo dígitos, últimos 10) para el dedup de
// capturas: la búsqueda por `telefono contains` fallaba con formatos
// con espacios. Se mantiene en cada escritura vía normalizaTelefono().
telefonoNorm String?
// Board del embudo donde vive el registro: LEADS | OPORTUNIDADES | APARTADO
board String @default("LEADS")
// Etapa/estado local actual (NUEVO al capturar)
@@ -198,6 +216,7 @@ model LeadCaptura {
@@index([usuarioId])
@@index([mondayItemId])
@@index([nombreLead])
@@index([telefonoNorm])
}
// Espejo local del inventario (tablero Unidades de Monday). Lo siembra
@@ -211,6 +230,7 @@ model Unidad {
modelo String @default("")
manzana String @default("")
lote String @default("")
nivel String @default("") // piso (desarrollos verticales); '' si no aplica
supConstruccion Float @default(0)
supExcedente Float @default(0)
supEstacionamiento Float @default(0)
@@ -298,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
@@ -530,3 +553,61 @@ model SolicitudSoporte {
@@index([usuarioId])
@@index([departamento])
}
// ── Módulo Desarrollos: catálogo de desarrollos nuevos ────────────────
// La BD es la fuente de verdad: la ficha del desarrollo, sus modelos y las
// reglas del cotizador (antes constantes fijas en el código de las apps).
model Desarrollo {
id String @id @default(uuid())
clave String @unique // SENDA, DIAGONAL… (forma los códigos de unidad)
nombre String // nombre comercial
estado String @default("ACTIVO") // ACTIVO | PREVENTA | CERRADO
tipo String @default("VERTICAL") // VERTICAL | HORIZONTAL
constructora String?
direccion String?
zona String?
descripcion String?
amenidades String? // texto libre o lista separada por comas
fechaInicioObra DateTime?
fechaEntregaProgramada DateTime?
fechaEntregaReprogramada DateTime?
activoEnCotizador Boolean @default(true) // aparece en el cotizador
activoEnAlta Boolean @default(true) // aparece en altas de lead/afluencia/cliente
prefijoUnidadPdf String? // p. ej. CASA (lo usa el PDF del cotizador)
imagenUrl String?
logoUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
modelos ModeloDesarrollo[]
reglas ReglaCotizadorDesarrollo?
@@index([estado])
}
model ModeloDesarrollo {
id String @id @default(uuid())
desarrolloId String
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
nombre String
recamaras Int?
banos Float?
estacionamientos Int?
supConstruccion Float?
activo Boolean @default(true)
@@unique([desarrolloId, nombre])
@@index([desarrolloId])
}
model ReglaCotizadorDesarrollo {
// `desarrolloId` guarda el uuid de `Desarrollo.id` (es lo que escriben el seed
// y PUT /desarrollos/:id/reglas). El endpoint que consume el cotizador
// (`/desarrollos/reglas-cotizador`) traduce uuid → clave antes de responder.
desarrolloId String @id
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
parametrosJson String // JSON con la forma ParametrosDesarrollo
updatedAt DateTime @updatedAt
updatedPorId String?
}

View File

@@ -0,0 +1,130 @@
/**
* Limpieza de leads/oportunidades/apartados duplicados (septiembre 2026).
*
* Los duplicados se originaron ANTES del fix del dedup normalizado
* (telefonoNorm, 2026-09-26): la búsqueda `telefono contains` no encontraba
* teléfonos guardados con espacios y dejaba pasar capturas repetidas.
*
* Regla aplicada: conservar la cadena MÁS AVANZADA de cada teléfono
* (APARTADO > OPORTUNIDADES > LEADS; dentro del mismo tablero, la etapa más
* avanzada / la de la cadena ligada) y descartar el resto SIN borrarlo:
* - LEADS → etapa 'Descartado', motivo 'Duplicado'
* - OPORTUNIDADES→ etapa 'Perdido', motivo 'Duplicado'
* - APARTADO → etapa 'Cancelacion Apartado', motivo 'Duplicado'
* Cada descarte queda en AuditoriaLog (usuario admin del sistema).
*
* Seguridad: sólo toca la fila si el board y la etapa ACTUALES coinciden con
* lo esperado; si ya avanzó o ya está cerrada, la omite y avisa. Idempotente.
*
* Uso: npx tsx scripts/descartar-duplicados.ts
*/
import dotenv from 'dotenv'
import { PrismaClient } from '@prisma/client'
dotenv.config()
const prisma = new PrismaClient()
const ADMIN_ID = process.env.ADMIN_USUARIO_ID ?? 'c882ed06-7891-439a-9cd9-f33dd36378b3'
const MOTIVO = 'Duplicado'
/** Etapa terminal por board (mismo criterio que CrmLocalService.descartar) */
const ETAPA_TERMINAL: Record<string, string> = {
LEADS: 'Descartado',
OPORTUNIDADES: 'Perdido',
APARTADO: 'Cancelacion Apartado',
}
interface Descarte {
leadId: string
nombre: string
board: string
etapaEsperada: string
}
const LEADS: Descarte[] = [
{ leadId: 'loc_9a9b444a-6d1b-495f-8a6f-a10c4ae260c5', nombre: 'PaoSan', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_c8eac00b-f7ef-4842-81c8-6d0011aa9531', nombre: 'Nayeli Mota Perez (25/09)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_b190d1cd-96aa-4abb-8d44-606840bf3ae5', nombre: 'Xenya Dominguez (25/09)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_758b736f-d186-476f-996f-c95cce0463ea', nombre: 'Gustavo Fernandez de León (jose gregorio)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_80ed24b0-5c7d-4df5-8273-0e1c6b64e2d8', nombre: 'GUADALUPE (25/09)', board: 'LEADS', etapaEsperada: 'Calificado' },
{ leadId: 'loc_9e63629d-988f-485c-8b23-47b38115357a', nombre: 'Blanca Monter (17:49)', board: 'LEADS', etapaEsperada: 'Calificado' },
{ leadId: 'loc_e2e85982-5a3e-469e-9dc7-57c7225671d8', nombre: 'Wilberth Almanza (00:38)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_6a5f2678-8c5f-468c-b9e4-67a6b845e095', nombre: 'Maytte Lyon (00:48)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_d6d996d9-1ed3-48c7-8eb7-bb1e4890c37f', nombre: 'eduardo velarde gutierrez (tel de Daniel)', board: 'LEADS', etapaEsperada: 'NUEVO' },
{ leadId: 'loc_44fe422c-9746-47c8-a8c7-2b3e495630db', nombre: 'Stefano Sandoval (tel compartido con Blanca)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_c26e53c0-5c33-472c-8b66-0d84a7a34b7b', nombre: 'Miguel Ángel Rodríguez (25/09)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_beeec35a-aa09-44af-ab5a-c7edd522e80e', nombre: 'Joel Benitez (00:36)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_347d7a43-b9e9-4f5c-8319-965353e256a2', nombre: 'Abigail Melendez (00:29)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_237f8b3d-23d7-4206-97de-d69ffab5ce00', nombre: 'Mildred (00:25)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_eab9b179-0883-4ecf-a380-7dba8ae2d256', nombre: 'Eva Vargas Arias (00:31)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_5f1b3b9b-cd37-4353-8b32-d8a49cb2992c', nombre: 'Julio (NUEVO 18:58)', board: 'LEADS', etapaEsperada: 'NUEVO' },
{ leadId: 'loc_7c98bdbf-00fb-40ba-be1f-19d78efb8df7', nombre: 'EDGAR MORALES (20:57)', board: 'LEADS', etapaEsperada: 'Contactado' },
{ leadId: 'loc_cfacf7dd-eaa7-43c8-aab7-1f388c8477fe', nombre: 'Ivan (21/09)', board: 'LEADS', etapaEsperada: 'Contactado' },
]
const OPORTUNIDADES: Descarte[] = [
{ leadId: 'loc_07cbd367-9537-4f8f-9a12-6af840dc548f', nombre: 'EMANUEL GARCIA HERNANDEZ (19/09)', board: 'OPORTUNIDADES', etapaEsperada: 'Aparto' },
{ leadId: 'loc_6635cc1e-d160-4a93-bb58-e52a7d704546', nombre: 'GUADALUPE Agendo Cita (25/09)', board: 'OPORTUNIDADES', etapaEsperada: 'Agendo Cita' },
{ leadId: 'loc_661acb75-2601-46dc-a314-e903b2d9ad02', nombre: 'GUADALUPE AVILA Afluencia (24/09)', board: 'OPORTUNIDADES', etapaEsperada: 'Afluencia' },
{ leadId: 'loc_d234e717-b849-4a09-ab06-3cacc8afcd7b', nombre: 'Blanca Monter Agendo Cita (17:50)', board: 'OPORTUNIDADES', etapaEsperada: 'Agendo Cita' },
{ leadId: 'loc_2c18e0ce-c1a7-47e0-b9fc-18562a83b9b7', nombre: 'Rocio Gtz Afluencia (17:50)', board: 'OPORTUNIDADES', etapaEsperada: 'Afluencia' },
{ leadId: 'loc_d5f3ec01-cf7e-47f2-9840-a77aada459e7', nombre: 'Nereyda Lopez Afluencia (17:53)', board: 'OPORTUNIDADES', etapaEsperada: 'Afluencia' },
{ leadId: 'loc_8873f6e0-7440-4061-aa2e-cc31be47f8f0', nombre: 'Guadalupe Avila Jimenez Cotizador', board: 'OPORTUNIDADES', etapaEsperada: 'Cotizador' },
]
const APARTADOS: Descarte[] = [
{ leadId: 'loc_e0751165-95e8-4e1c-b21c-f99e417a6c54', nombre: 'EMANUEL GARCIA HERNANDEZ (03:29)', board: 'APARTADO', etapaEsperada: 'Pago Apartado' },
]
const ETAPAS_CERRADAS = ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado']
async function main() {
const todos = [...LEADS, ...OPORTUNIDADES, ...APARTADOS]
let descartados = 0
let omitidos = 0
for (const d of todos) {
const row = await prisma.leadCaptura.findUnique({ where: { leadId: d.leadId } })
if (!row) {
console.log(`OMITIDO (no existe): ${d.leadId} ${d.nombre}`)
omitidos++
continue
}
if (row.board !== d.board || ETAPAS_CERRADAS.includes(row.etapa)) {
console.log(`OMITIDO (board=${row.board} etapa=${row.etapa}): ${d.nombre}`)
omitidos++
continue
}
if (row.etapa !== d.etapaEsperada) {
console.log(`OMITIDO (etapa actual "${row.etapa}" != esperada "${d.etapaEsperada}"): ${d.nombre}`)
omitidos++
continue
}
await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: d.leadId },
data: { etapa: ETAPA_TERMINAL[d.board], motivo: MOTIVO, submotivo: null, pendienteSync: true },
}),
prisma.auditoriaLog.create({
data: {
usuarioId: ADMIN_ID,
accion: 'LIMPIEZA_DUPLICADO',
entidad: 'LeadCaptura',
entidadId: d.leadId,
detalle: `Descartado como duplicado (${d.board} "${row.nombreLead}" etapa ${row.etapa}). Limpieza post-fix dedup telefonoNorm.`,
},
}),
])
console.log(`DESCARTADO [${d.board} → ${ETAPA_TERMINAL[d.board]}]: ${row.nombreLead} (${d.nombre})`)
descartados++
}
console.log(`\nListo: ${descartados} descartados, ${omitidos} omitidos de ${todos.length}.`)
}
main()
.catch((e) => {
console.error(e)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())

View File

@@ -17,8 +17,10 @@
* - Pagos programados: numPagos mensualidades desde MES INICIO
* (montoPagado=0; el módulo deriva VENCIDO por fecha).
* - Celda de grid con valor > 0 = abono real del mes → AplicacionPago
* (fechaPago = mes de la columna) sobre la mensualidad de ese mes;
* si no hay mensualidad programada en ese mes → Pago standalone pagado.
* (fechaPago = mes de la columna) sobre la mensualidad de ese mes; si no
* hay mensualidad programada en ese mes, o el abono excede lo que falta de
* la mensualidad, el excedente se registra como Pago standalone ya cubierto
* (así ningún abono supera el monto de su pago).
*/
import { PrismaClient } from '@prisma/client'
@@ -57,6 +59,7 @@ function norm(h) {
}
const toStr = (v) => (v == null ? null : String(v).trim() || null)
const round2 = (x) => Math.round(x * 100) / 100
const toFloat = (v) => {
if (v == null || v === '') return null
if (typeof v === 'number') return v
@@ -230,23 +233,27 @@ async function escribir(planesLeidos) {
}
plan.engancheTotal = pagosProg.reduce((a, p) => a + p.monto, 0)
// 2) abonos del grid sobre la mensualidad del mismo mes; sobrantes → standalone
// 2) abonos del grid sobre la mensualidad del mismo mes. Un abono nunca
// puede exceder el monto de su pago: lo que sobra (y los meses sin
// mensualidad programada) se registra como pago nuevo ya cubierto, así
// Σ montoPagado == dinero recibido y ningún pago queda sobrepagado.
const aplicaciones = [] // { idxPago, fechaPago, monto }
const standalone = []
for (const ab of abonosGrid) {
for (const ab of [...abonosGrid].sort((a, b) => a.fecha - b.fecha)) {
// idx del arreglo == numPago (el inicial ocupa numPago 0 en el índice 0)
const idx = pagosProg.findIndex((p) => p.numPago >= 1 && claveMes(p.fechaVencimiento) === claveMes(ab.fecha))
let restante = ab.monto
if (idx !== -1) {
aplicaciones.push({ idx, fechaPago: ab.fecha, monto: ab.monto })
} else {
standalone.push({ fecha: ab.fecha, monto: ab.monto })
const p = pagosProg[idx]
const falta = Math.max(0, round2(p.monto - p.montoPagado))
const aplica = Math.min(restante, falta)
if (aplica > 0) {
aplicaciones.push({ idx, fechaPago: ab.fecha, monto: round2(aplica) })
p.montoPagado = round2(p.montoPagado + aplica)
restante = round2(restante - aplica)
}
}
}
// aplicar abonos al montoPagado (sin pasar del monto programado)
for (const ap of aplicaciones) {
const p = pagosProg[ap.idx]
p.montoPagado = Math.min(p.monto, p.montoPagado + ap.monto)
if (restante > 0) standalone.push({ fecha: ab.fecha, monto: round2(restante) })
}
// Invariante del módulo: Σ montos de TODOS los pagos del plan == engancheTotal

View File

@@ -0,0 +1,157 @@
/**
* Siembra el catálogo `Desarrollo` + `ModeloDesarrollo` + `ReglaCotizadorDesarrollo`
* desde la realidad actual del sistema:
*
* - Desarrollos y modelos: lo que hoy hay en la tabla `Unidad` (espejo del
* inventario), más SOLE y MUTUO, que existen en captura y altas pero todavía
* no tienen inventario local.
* - Reglas del cotizador: copiadas (replicadas, no importadas) de las
* constantes de apps/cotizador/src/data/desarrollos.ts, que es la config con
* la que hoy cotiza producción.
*
* Idempotente: upsert por `clave`. NUNCA pisa lo ya editado: si la ficha o las
* reglas existen, el seed no las toca (el admin manda).
*
* Uso: npm run seed:desarrollos (tsx: la fuente de las reglas es TypeScript)
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
import { PrismaClient } from '@prisma/client'
import { DESARROLLOS as PARAMETROS_COTIZADOR } from '../../apps/cotizador/src/data/desarrollos.ts'
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
const prisma = new PrismaClient()
/**
* Desarrollos sin inventario local que sí existen en el ecosistema (captura y
* altas de lead/afluencia/cliente). Los modelos de SOLE vienen de la lista
* real del alta de clientes (apps/alta-clientes/src/data/constantes.ts);
* MUTUO captura el modelo en texto libre, así que se siembra sin modelos.
*/
const SIN_INVENTARIO = [
{ clave: 'SOLE', nombre: 'Sole', tipo: 'VERTICAL', modelos: ['ALBA', 'HORIZONTE', 'OCASO'] },
{ clave: 'MUTUO', nombre: 'Mutuo', tipo: 'VERTICAL', modelos: [] },
]
const normalizar = (v) => String(v ?? '').trim().toUpperCase()
/** Nombre comercial: el del cotizador si existe; si no, la clave capitalizada */
function nombreComercial(clave) {
const delCotizador = PARAMETROS_COTIZADOR[clave]?.nombre
if (typeof delCotizador === 'string' && delCotizador !== '') return delCotizador
return clave.charAt(0) + clave.slice(1).toLowerCase()
}
async function main() {
const [porDesarrollo, modelosInventario] = await Promise.all([
prisma.unidad.groupBy({ by: ['desarrollo'], _count: { codigo: true } }),
prisma.unidad.groupBy({ by: ['desarrollo', 'modelo'], _count: { codigo: true } }),
])
if (porDesarrollo.length === 0) {
throw new Error('La tabla Unidad está vacía: corre primero `npm run seed:unidades`')
}
const claves = new Set(porDesarrollo.map((d) => normalizar(d.desarrollo)))
for (const extra of SIN_INVENTARIO) claves.add(extra.clave)
const modelosPorClave = new Map()
for (const fila of modelosInventario) {
const clave = normalizar(fila.desarrollo)
const modelo = String(fila.modelo ?? '').trim()
if (modelo === '') continue
if (!modelosPorClave.has(clave)) modelosPorClave.set(clave, new Set())
modelosPorClave.get(clave).add(modelo)
}
for (const extra of SIN_INVENTARIO) {
if (!modelosPorClave.has(extra.clave)) modelosPorClave.set(extra.clave, new Set())
for (const modelo of extra.modelos) modelosPorClave.get(extra.clave).add(modelo)
}
let fichasNuevas = 0
let fichasExistentes = 0
let modelosNuevos = 0
let reglasNuevas = 0
for (const clave of [...claves].sort()) {
const extra = SIN_INVENTARIO.find((e) => e.clave === clave)
const parametros = PARAMETROS_COTIZADOR[clave]
const previa = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
const ficha = await prisma.desarrollo.upsert({
where: { clave },
// No pisa lo ya editado por el admin: si existe, se deja como está
update: {},
create: {
clave,
nombre: extra?.nombre ?? nombreComercial(clave),
// `tipo` no se deduce del inventario actual (los códigos y el prefijo
// del PDF no lo distinguen): queda el default VERTICAL y el admin lo
// ajusta desde el módulo.
tipo: extra?.tipo ?? 'VERTICAL',
prefijoUnidadPdf: typeof parametros?.prefijoUnidadPdf === 'string' ? parametros.prefijoUnidadPdf : null,
},
})
if (previa) fichasExistentes++
else fichasNuevas++
for (const nombre of [...(modelosPorClave.get(clave) ?? [])].sort()) {
const modeloPrevio = await prisma.modeloDesarrollo.findUnique({
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
select: { id: true },
})
await prisma.modeloDesarrollo.upsert({
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
update: {},
create: { desarrolloId: ficha.id, nombre },
})
if (!modeloPrevio) modelosNuevos++
}
if (parametros) {
const reglaPrevia = await prisma.reglaCotizadorDesarrollo.findUnique({
where: { desarrolloId: ficha.id },
select: { desarrolloId: true },
})
if (!reglaPrevia) {
await prisma.reglaCotizadorDesarrollo.create({
data: { desarrolloId: ficha.id, parametrosJson: JSON.stringify(parametros) },
})
reglasNuevas++
}
}
}
const total = await prisma.desarrollo.count()
const conReglas = await prisma.reglaCotizadorDesarrollo.count()
const totalModelos = await prisma.modeloDesarrollo.count()
// eslint-disable-next-line no-console
console.log(
`[seed-desarrollos] fichas: ${fichasNuevas} nueva(s), ${fichasExistentes} ya existían; ` +
`${modelosNuevos} modelo(s) nuevo(s); ${reglasNuevas} regla(s) de cotizador copiada(s)`,
)
// eslint-disable-next-line no-console
console.log(`[seed-desarrollos] total en BD: ${total} desarrollos, ${totalModelos} modelos, ${conReglas} con reglas`)
for (const d of await prisma.desarrollo.findMany({
orderBy: { clave: 'asc' },
include: { modelos: { orderBy: { nombre: 'asc' } }, reglas: { select: { desarrolloId: true } } },
})) {
// eslint-disable-next-line no-console
console.log(
` ${d.clave} (${d.estado}/${d.tipo}) — ${d.modelos.length} modelo(s)` +
`${d.reglas ? '' : ' — SIN reglas de cotizador'}: ${d.modelos.map((m) => m.nombre).join(', ') || '—'}`,
)
}
}
main()
.catch((err) => {
console.error('[seed-desarrollos] error:', err)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { DEPARTAMENTOS, DEPARTAMENTOS_POR_ROL, departamentosDeRol, rolPuedeDepartamento } from './campos-departamento.js'
/**
* Matriz de permisos de Captura (fuente de verdad del backend; el front la
* espeja en apps/landing/src/services/captura.ts para ocultar pestañas).
*/
describe('departamentosDeRol', () => {
it('los roles operativos ven exactamente su departamento', () => {
expect(departamentosDeRol('vendedor')).toEqual(['VENTAS'])
expect(departamentosDeRol('escrituracion')).toEqual(['ESCRITURACION'])
expect(departamentosDeRol('cobranza')).toEqual(['FINANZAS'])
expect(departamentosDeRol('postventa')).toEqual(['POSTVENTA'])
expect(departamentosDeRol('obra')).toEqual(['OBRA'])
})
it('dirección y administración ven todos', () => {
for (const rol of ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta', 'coordinador', 'director']) {
expect(departamentosDeRol(rol)).toEqual([...DEPARTAMENTOS])
}
})
it('tolera mayúsculas y espacios; sin token no restringe', () => {
expect(departamentosDeRol(' Vendedor ')).toEqual(['VENTAS'])
expect(departamentosDeRol(undefined)).toEqual([...DEPARTAMENTOS])
expect(departamentosDeRol('')).toEqual([...DEPARTAMENTOS])
})
it('un rol desconocido cae al fallback seguro (vendedor)', () => {
expect(departamentosDeRol('otro')).toEqual(['VENTAS'])
})
})
describe('rolPuedeDepartamento', () => {
it('cada clave de la matriz ve sus departamentos y ninguno más', () => {
for (const [rol, departamentos] of Object.entries(DEPARTAMENTOS_POR_ROL)) {
for (const departamento of DEPARTAMENTOS) {
expect(rolPuedeDepartamento(rol, departamento)).toBe(
(departamentos as readonly string[]).includes(departamento),
)
}
}
})
it('un departamento inexistente nunca se permite', () => {
expect(rolPuedeDepartamento('admin', 'INVENTADO')).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
export const DEPARTAMENTOS = ['OBRA', 'VENTAS', 'ESCRITURACION', 'FINANZAS', 'POSTVENTA'] as const
export type Departamento = (typeof DEPARTAMENTOS)[number]
export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: string; label: string; options?: string[]; conFecha?: string }[]> = {
OBRA: [
@@ -15,6 +16,15 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' },
// Fechas de obra (las llena el área de obra; rigen el arranque y la entrega)
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
{ campo: 'desviacion', tipo: 'number', label: 'Desviación (días)' },
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
{ campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
],
VENTAS: [
@@ -30,13 +40,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
],
ESCRITURACION: [
{ campo: 'inicio_obra', tipo: 'boolean', label: 'Inicio Obra', conFecha: 'inicio_obra_fecha' },
{ campo: 'inicio_obra_fecha', tipo: 'date', label: 'Fecha Inicio Obra' },
{ campo: 'entrega_programada', tipo: 'boolean', label: 'Entrega Programada', conFecha: 'entrega_programada_fecha' },
{ campo: 'entrega_programada_fecha', tipo: 'date', label: 'Fecha Programada Entrega Vivienda' },
{ campo: 'entrega_reprogramada', tipo: 'boolean', label: 'Entrega Reprogramada', conFecha: 'entrega_reprogramada_fecha' },
{ campo: 'entrega_reprogramada_fecha', tipo: 'date', label: 'Fecha Reprogramada Entrega Vivienda' },
{ campo: 'fecha_autorizacion', tipo: 'date', label: 'Fecha Autorización' },
{ campo: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
{ campo: 'notaria', tipo: 'text', label: 'Notaría' },
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },
@@ -59,3 +62,44 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
}
export const DESARROLLOS = ['SENDA', 'ATELIER', 'ESTANZA', 'DIAGONAL', 'SOLANA', 'SOLE', 'MUTUO'] as const
// ── Permisos de captura por rol ───────────────────────────────────────
/**
* Departamentos que cada rol puede ver y capturar. Fuente de verdad del
* ecosistema: el front espeja esta misma matriz en services/captura.ts para
* ocultar pestañas, pero quien la hace cumplir es el API.
*
* Dirección y administración ven todos (incluye los roles legacy de la matriz
* de módulos: 'director', 'coordinador'); cada rol operativo sólo el suyo.
*/
export const DEPARTAMENTOS_POR_ROL: Record<string, readonly Departamento[]> = {
admin: DEPARTAMENTOS,
manager: DEPARTAMENTOS,
direccion_general: DEPARTAMENTOS,
director_ventas: DEPARTAMENTOS,
administracion_venta: DEPARTAMENTOS,
director: DEPARTAMENTOS,
coordinador: DEPARTAMENTOS,
vendedor: ['VENTAS'],
escrituracion: ['ESCRITURACION'],
cobranza: ['FINANZAS'],
postventa: ['POSTVENTA'],
obra: ['OBRA'],
}
/**
* Departamentos del rol. Sin token (AUTH_ENABLED=false, modo local) no hay
* restricción; un rol desconocido cae a vendedor, igual que modulosDeRol
* (fallback seguro: es el conjunto más chico).
*/
export function departamentosDeRol(rol: string | undefined): readonly Departamento[] {
const clave = (rol ?? '').trim().toLowerCase()
if (clave === '') return DEPARTAMENTOS
return DEPARTAMENTOS_POR_ROL[clave] ?? DEPARTAMENTOS_POR_ROL['vendedor']!
}
/** ¿El rol puede ver/capturar ese departamento? */
export function rolPuedeDepartamento(rol: string | undefined, departamento: string): boolean {
return (departamentosDeRol(rol) as readonly string[]).includes(departamento)
}

View File

@@ -1,6 +1,20 @@
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MODULOS, esRolValido, modulosDeRol } from './roles.js'
// ---------- Prisma mockeado ----------
// modulosDeRol lee RolConfiguracion (BD) y cae a la MATRIZ estática cuando no
// hay fila para el rol. Los permisos reales se editan desde la UI, así que la
// BD viva cambia con el tiempo: estos tests fijan el contrato por defecto
// (sin override en BD), que es lo que recibe una instalación nueva.
const prismaMock = vi.hoisted(() => ({
rolConfiguracion: { findUnique: vi.fn() },
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
beforeEach(() => {
prismaMock.rolConfiguracion.findUnique.mockResolvedValue(null)
})
const ESPERADOS: Record<string, string[]> = {
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
administracion_venta: [

View File

@@ -12,9 +12,11 @@ export const MODULOS = [
'cobranza',
'contactos',
'chat',
'whatsapp',
'clientes-maestro',
'captura',
'soporte',
'desarrollos',
] as const
export type Modulo = (typeof MODULOS)[number]
@@ -38,6 +40,7 @@ const MODULOS_VENDEDOR: readonly Modulo[] = [
'mi-tablero',
'contactos',
'chat',
'whatsapp',
'clientes-maestro',
'captura',
'soporte',
@@ -48,9 +51,10 @@ 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
manager: MODULOS,
// ni dar de alta desarrollos (módulo exclusivo del admin)
manager: MODULOS.filter((m) => m !== 'desarrollos'),
admin: MODULOS,
}

View File

@@ -13,7 +13,7 @@
export const VOCABULARIO = {
// Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del
// API + NUEVO (captura) y DESCARTADO (terminal)
FASES_LEAD: ['NUEVO', 'CONTACTADO', 'Intento de contacto', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
FASES_LEAD: ['NUEVO', 'Intento de contacto', 'CONTACTADO', 'INTERESADO', 'AGENDA CITA', 'CALIFICADO', 'DESCARTADO'],
// Etapas del funnel de Oportunidades (labels REALES de la columna status 'Estado')
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES de la
@@ -60,8 +60,27 @@ export const VOCABULARIO = {
// Labels oficiales de desarrollo (verificados el 2026-09-04; SOLE y MUTUO
// agregados 2026-09 — existen en captura e inventario)
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA', 'SOLE', 'MUTUO'],
// Campos de calificación que hacen "completa" una afluencia: sin ellos la
// oportunidad NO puede avanzar a 'Aparto' (regla de negocio del cliente)
CAMPOS_AFLUENCIA_REQUERIDOS: ['estadoCivil', 'tipoEmpleo', 'rangoSalarial', 'tipoEconomia', 'califica', 'loQuierePara'],
} as const
/** Campos de afluencia faltantes en un datosJson ('[]' = afluencia completa) */
export function camposAfluenciaFaltantes(datosJson: string | null): string[] {
let datos: Record<string, unknown> = {}
if (datosJson) {
try { datos = JSON.parse(datosJson) as Record<string, unknown> } catch { datos = {} }
}
return VOCABULARIO.CAMPOS_AFLUENCIA_REQUERIDOS.filter(
(c) => typeof datos[c] !== 'string' || (datos[c] as string).trim() === '',
)
}
/** ¿La fila tiene la afluencia completa? (gate de 'Aparto' y flag de Mi día) */
export function afluenciaCompleta(datosJson: string | null): boolean {
return camposAfluenciaFaltantes(datosJson).length === 0
}
export type FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number]
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[number]

View File

@@ -114,6 +114,12 @@ export async function obtenerConversacion(req: Request, res: Response): Promise<
include: { mensajes: { orderBy: { createdAt: 'asc' } } },
})
if (!conversacion) throw new NotFoundError('Conversación no encontrada')
// Propiedad: misma regla que al borrar — sólo el dueño o un rol de
// supervisión puede leer la conversación.
const tokenLectura = req.usuarioToken
if (tokenLectura && !esAdminOManager(tokenLectura.rol) && conversacion.usuarioId !== tokenLectura.sub) {
throw new AppError(403, 'Solo puedes ver tus conversaciones')
}
res.json({
id: conversacion.id,
createdAt: conversacion.createdAt,

View File

@@ -22,6 +22,8 @@ const cambiarEstadoSchema = z.object({
motivo: z.string().optional(),
subcategoria: z.string().optional(),
comentario: z.string().optional(),
// En 'Cita Clientes' puede setear la fecha y hora de la cita
fecha: z.string().optional(),
})
/**
@@ -33,9 +35,32 @@ export async function cambiarEstadoApartadoEndpoint(req: Request, res: Response)
const body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId'])
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
fecha: body.fecha,
usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId),
})
// 'Cita Clientes' con fecha: agenda la cita en el calendario del vendedor del
// apartado (ActividadLocal ligada a la captura; reaperturas reagendan: se
// cierran las citas abiertas previas de ese apartado y se crea la nueva).
if (body.estado === 'Cita Clientes' && body.fecha !== undefined && body.fecha !== '') {
const fila = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
const nombreCliente = fila?.nombreLead ?? 'Cliente'
const usuarioDestino = fila?.usuarioId ?? req.usuarioToken?.sub ?? 'desconocido'
await prisma.actividadLocal.updateMany({
where: { leadCapturaId: itemId, tipo: 'CITA', estado: 'ABIERTA' },
data: { estado: 'CERRADA', fechaCierre: new Date() },
})
await prisma.actividadLocal.create({
data: {
titulo: `Cita cliente: ${nombreCliente}`,
descripcion: `Cita de cliente del apartado (${itemId})`,
usuarioId: usuarioDestino,
leadCapturaId: itemId,
fechaVencimiento: new Date(body.fecha),
tipo: 'CITA',
},
})
}
// Las cancelaciones (apartado y escrituración) guardan el motivo en la fila
// para los reportes de cancelación (Cliente/Motivo).
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo'

View File

@@ -68,7 +68,7 @@ describe('listarAuditoriaEndpoint (GET /api/auditoria)', () => {
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
createdAt: expect.objectContaining({ gte: expect.any(Date), lte: expect.any(Date) }),
createdAt: expect.objectContaining({ gte: expect.any(Date), lt: expect.any(Date) }),
}),
}),
)

View File

@@ -26,7 +26,7 @@ describe('buscarEndpoint (GET /api/buscar)', () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({ q: 'María' }), r)
expect(buscarMock).toHaveBeenCalledWith('María', 20)
expect(buscarMock).toHaveBeenCalledWith('María', 20, undefined)
expect(r.json).toHaveBeenCalledWith([])
})
@@ -34,25 +34,47 @@ describe('buscarEndpoint (GET /api/buscar)', () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({}), r)
expect(buscarMock).toHaveBeenCalledWith('', 20)
expect(buscarMock).toHaveBeenCalledWith('', 20, undefined)
})
it('limite válido del query string', async () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({ q: 'x', limite: '10' }), r)
expect(buscarMock).toHaveBeenCalledWith('x', 10)
expect(buscarMock).toHaveBeenCalledWith('x', 10, undefined)
})
it('limite inválido o > 50 se clampa a 20', async () => {
buscarMock.mockResolvedValue([])
const r = res()
await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r)
expect(buscarMock).toHaveBeenCalledWith('x', 20)
expect(buscarMock).toHaveBeenCalledWith('x', 20, undefined)
const r2 = res()
await buscarEndpoint(req({ q: 'x', limite: '100' }), r2)
expect(buscarMock).toHaveBeenCalledWith('x', 50)
expect(buscarMock).toHaveBeenCalledWith('x', 50, undefined)
})
it('vendedor: pasa alcance soloPropio (sólo ve sus propios registros)', async () => {
buscarMock.mockResolvedValue([])
const r = res()
const reqVendedor = {
query: { q: 'Ana' },
usuarioToken: { sub: 'u-9', username: 'v', rol: 'vendedor' },
} as unknown as Request
await buscarEndpoint(reqVendedor, r)
expect(buscarMock).toHaveBeenCalledWith('Ana', 20, { usuarioId: 'u-9', soloPropio: true })
})
it('admin: pasa alcance sin filtro (sigue viendo todo el universo)', async () => {
buscarMock.mockResolvedValue([])
const r = res()
const reqAdmin = {
query: { q: 'Ana' },
usuarioToken: { sub: 'u-1', username: 'a', rol: 'admin' },
} as unknown as Request
await buscarEndpoint(reqAdmin, r)
expect(buscarMock).toHaveBeenCalledWith('Ana', 20, { usuarioId: 'u-1', soloPropio: false })
})
it('devuelve los resultados del service como JSON', async () => {

View File

@@ -8,6 +8,13 @@ import { buscar } from '../services/buscar.service.js'
export async function buscarEndpoint(req: Request, res: Response): Promise<void> {
const q = typeof req.query.q === 'string' ? req.query.q : ''
const limite = typeof req.query.limite === 'string' ? Math.min(parseInt(req.query.limite, 10) || 20, 50) : 20
const resultados = await buscar(q, limite)
// Privacidad: el vendedor sólo encuentra sus propios leads, contactos y
// cotizaciones; los roles de supervisión siguen viendo todo el universo.
const soloPropio = req.usuarioToken?.rol === 'vendedor'
const resultados = await buscar(
q,
limite,
req.usuarioToken ? { usuarioId: req.usuarioToken.sub, soloPropio } : undefined,
)
res.json(resultados)
}

View File

@@ -194,6 +194,111 @@ describe('bulkEndpoint (POST /api/captura/bulk)', () => {
})
})
describe('permisos por rol (DEPARTAMENTOS_POR_ROL)', () => {
const bulk = (departamento: string, rol: string, campo: string) =>
reqFake({
body: { desarrollo: 'SENDA', departamento, registros: [{ unidad: 'U-101', campo, valor: null }] },
token: { sub: 'u1', rol },
})
it('vendedor: 403 al guardar fuera de VENTAS (POST /captura)', async () => {
await expect(
guardarEndpoint(
reqFake({
body: { desarrollo: 'SENDA', unidadCodigo: 'U-101', departamento: 'OBRA', campo: 'habitabilidad', valor: '2026-09-01' },
token: { sub: 'u1', rol: 'vendedor' },
}),
resFake().res,
),
).rejects.toMatchObject({ status: 403 })
expect(capturaService.guardarCampo).not.toHaveBeenCalled()
})
it('vendedor: 403 en FINANZAS y 204 en VENTAS (POST /captura/bulk)', async () => {
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
await expect(bulkEndpoint(bulk('FINANZAS', 'vendedor', 'bancario'), resFake().res)).rejects.toMatchObject({ status: 403 })
expect(capturaService.guardarBulk).not.toHaveBeenCalled()
const { res, status } = resFake()
await bulkEndpoint(bulk('VENTAS', 'vendedor', 'estatus'), res)
expect(status).toHaveBeenCalledWith(204)
expect(capturaService.guardarBulk).toHaveBeenCalledWith('SENDA', 'VENTAS', [{ unidad: 'U-101', campo: 'estatus', valor: null }], 'u1', 'vendedor')
})
it('cada rol operativo conserva su departamento y sólo ése', async () => {
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
const casos: [string, string, string][] = [
['vendedor', 'VENTAS', 'estatus'],
['escrituracion', 'ESCRITURACION', 'notaria'],
['cobranza', 'FINANZAS', 'bancario'],
['postventa', 'POSTVENTA', 'entrega_sac'],
['obra', 'OBRA', 'comentarios_obra'],
]
for (const [rol, departamento, campo] of casos) {
const { res, status } = resFake()
await bulkEndpoint(bulk(departamento, rol, campo), res)
expect(status).toHaveBeenCalledWith(204)
}
// El mismo rol no puede escribir en el departamento de otro
const { res } = resFake()
await expect(bulkEndpoint(bulk('VENTAS', 'obra', 'estatus'), res)).rejects.toMatchObject({ status: 403 })
})
it('admin, manager, dirección y administración de venta pueden con todos los departamentos', async () => {
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
for (const rol of ['admin', 'manager', 'direccion_general', 'director_ventas', 'administracion_venta', 'coordinador']) {
await bulkEndpoint(bulk('OBRA', rol, 'comentarios_obra'), resFake().res)
}
expect(capturaService.guardarBulk).toHaveBeenCalledTimes(6)
})
it('sin token (AUTH_ENABLED=false, modo local) no se restringe', async () => {
vi.mocked(capturaService.guardarBulk).mockResolvedValue(undefined)
const { res, status } = resFake()
await bulkEndpoint(
reqFake({ body: { desarrollo: 'SENDA', departamento: 'OBRA', registros: [{ unidad: 'U-101', campo: 'comentarios_obra', valor: 'ok' }] } }),
res,
)
expect(status).toHaveBeenCalledWith(204)
})
it('GET: 403 si el rol pide un departamento que no le toca', async () => {
await expect(
listarEndpoint(reqFake({ query: { desarrollo: 'SENDA', departamento: 'OBRA' }, token: { sub: 'u1', rol: 'vendedor' } }), resFake().res),
).rejects.toMatchObject({ status: 403 })
expect(capturaService.obtenerCapturas).not.toHaveBeenCalled()
})
it('GET: filtra los campos de los demás departamentos', async () => {
vi.mocked(capturaService.obtenerCapturas).mockResolvedValue({
'U-101': { estatus: 'VENDIDO', comentarios_obra: 'en proceso' },
'U-102': { comentarios_obra: 'sin ventas' },
})
const { res, json } = resFake()
await listarEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'vendedor' } }), res)
expect(json).toHaveBeenCalledWith({ 'U-101': { estatus: 'VENDIDO' } })
})
it('GET /resumen: sólo devuelve los departamentos del rol', async () => {
vi.mocked(capturaService.resumenAvance).mockResolvedValue([
{ departamento: 'OBRA', camposTotales: 14, camposLlenos: 2, porcentaje: 14 },
{ departamento: 'VENTAS', camposTotales: 10, camposLlenos: 5, porcentaje: 50 },
])
const { res, json } = resFake()
await resumenEndpoint(reqFake({ query: { desarrollo: 'SENDA' }, token: { sub: 'u1', rol: 'vendedor' } }), res)
expect(json).toHaveBeenCalledWith([{ departamento: 'VENTAS', camposTotales: 10, camposLlenos: 5, porcentaje: 50 }])
})
})
// ── importarEndpoint ────────────────────────────────────────────────
describe('importarEndpoint (POST /api/captura/importar)', () => {

View File

@@ -1,10 +1,52 @@
import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod'
import * as capturaService from '../services/captura-departamento.service.js'
import { CAMPOS_POR_DEPARTAMENTO, DEPARTAMENTOS } from '../config/campos-departamento.js'
import {
CAMPOS_POR_DEPARTAMENTO,
DEPARTAMENTOS,
departamentosDeRol,
rolPuedeDepartamento,
} from '../config/campos-departamento.js'
import { AppError, BadRequestError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js'
// ── Permisos por rol ─────────────────────────────────────────────────
/**
* El rol debe tener permitido el departamento (matriz DEPARTAMENTOS_POR_ROL):
* un vendedor sólo captura VENTAS aunque el front la oculte; sin esta guarda
* un token de vendedor podía escribir en OBRA o FINANZAS.
*/
function requireDepartamentoPermitido(rol: string | undefined, departamento: string): void {
if (!rolPuedeDepartamento(rol, departamento)) {
throw new AppError(403, `Tu rol no tiene acceso al departamento ${departamento}`)
}
}
/** Campos visibles para el rol (unión de los campos de sus departamentos) */
function camposPermitidos(rol: string | undefined): Set<string> {
const campos = departamentosDeRol(rol).flatMap((d) => (CAMPOS_POR_DEPARTAMENTO[d] ?? []).map((c) => c.campo))
return new Set(campos)
}
/**
* La lectura también respeta la matriz: con un token de rol limitado sólo se
* devuelven los campos de sus departamentos (el front ya filtra las pestañas,
* esto evita que los datos lleguen al cliente).
*/
function filtrarPorRol(
datos: Record<string, Record<string, string | null>>,
rol: string | undefined,
): Record<string, Record<string, string | null>> {
const permitidos = camposPermitidos(rol)
const salida: Record<string, Record<string, string | null>> = {}
for (const [unidad, campos] of Object.entries(datos)) {
const visibles = Object.fromEntries(Object.entries(campos).filter(([campo]) => permitidos.has(campo)))
if (Object.keys(visibles).length > 0) salida[unidad] = visibles
}
return salida
}
// ── Validación de campos por departamento ────────────────────────────
/**
@@ -44,8 +86,15 @@ const listarSchema = z.object({
export async function listarEndpoint(req: Request, res: Response): Promise<void> {
const query = listarSchema.parse(req.query)
const rol = req.usuarioToken?.rol
if (query.departamento !== undefined) {
if (!DEPARTAMENTOS.includes(query.departamento as (typeof DEPARTAMENTOS)[number])) {
throw new BadRequestError(`Departamento inválido: ${query.departamento}`)
}
requireDepartamentoPermitido(rol, query.departamento)
}
const capturas = await capturaService.obtenerCapturas(query.desarrollo, query.departamento)
res.json(capturas)
res.json(filtrarPorRol(capturas, rol))
}
// ── GET /api/captura/unidades?desarrollo=X ───────────────────────────
@@ -69,7 +118,8 @@ const resumenSchema = z.object({
export async function resumenEndpoint(req: Request, res: Response): Promise<void> {
const query = resumenSchema.parse(req.query)
const resumen = await capturaService.resumenAvance(query.desarrollo)
res.json(resumen)
const rol = req.usuarioToken?.rol
res.json(resumen.filter((r) => rolPuedeDepartamento(rol, r.departamento)))
}
// ── POST /api/captura — guardar campo individual ─────────────────────
@@ -89,6 +139,7 @@ export async function guardarEndpoint(req: Request, res: Response): Promise<void
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
}
requireDepartamentoPermitido(req.usuarioToken?.rol, body.departamento)
validarCamposDelDepartamento(body.departamento, [{ unidad: body.unidadCodigo, campo: body.campo, valor: body.valor ?? null }])
await capturaService.guardarCampo(
@@ -124,6 +175,7 @@ export async function bulkEndpoint(req: Request, res: Response): Promise<void> {
if (!DEPARTAMENTOS.includes(body.departamento as typeof DEPARTAMENTOS[number])) {
throw new BadRequestError(`Departamento inválido: ${body.departamento}`)
}
requireDepartamentoPermitido(req.usuarioToken?.rol, body.departamento)
validarCamposDelDepartamento(
body.departamento,
body.registros.map((r) => ({ unidad: r.unidad, campo: r.campo, valor: r.valor ?? null })),

View File

@@ -6,6 +6,7 @@ const servicioMock = vi.hoisted(() => ({
listar: vi.fn(),
obtener: vi.fn(),
contar: vi.fn(),
catalogos: vi.fn(),
exportar: vi.fn(),
importarDesdeExcel: vi.fn(),
}))
@@ -14,6 +15,7 @@ vi.mock('../services/cliente-maestro.service.js', () => servicioMock)
import {
listarEndpoint,
catalogosEndpoint,
exportarEndpoint,
obtenerEndpoint,
importarEndpoint,
@@ -77,6 +79,23 @@ describe('listarEndpoint', () => {
})
})
// ── GET /api/clientes-maestro/catalogos ──────────────────────────────
describe('catalogosEndpoint', () => {
it('200 con desarrollos y estatus reales', async () => {
servicioMock.catalogos.mockResolvedValue({
desarrollos: ['MUTUO - EDIFICACION', 'SOLE'],
estatuses: ['ENTREGA'],
})
const { res, json } = resFake()
await catalogosEndpoint(reqFake(), res)
expect(json).toHaveBeenCalledWith({
desarrollos: ['MUTUO - EDIFICACION', 'SOLE'],
estatuses: ['ENTREGA'],
})
})
})
// ── GET /api/clientes-maestro/exportar ───────────────────────────────
describe('exportarEndpoint', () => {

View File

@@ -22,6 +22,12 @@ export async function listarEndpoint(req: Request, res: Response): Promise<void>
res.json(resultado)
}
// ── GET /api/clientes-maestro/catalogos ──────────────────────────────
export async function catalogosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await clienteMaestroService.catalogos())
}
// ── GET /api/clientes-maestro/exportar ───────────────────────────────
export async function exportarEndpoint(req: Request, res: Response): Promise<void> {

View File

@@ -82,7 +82,7 @@ describe('formaPagoLabel (tipos de crédito del alta)', () => {
describe('crearClienteSchema', () => {
const base = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
viviendaCredito: { proyecto: 'SENDA' },
}
@@ -102,7 +102,7 @@ describe('crearClienteSchema', () => {
// ---------- Alta local (POST /api/clientes) ----------
const altaBase = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' },
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
viviendaCredito: { proyecto: 'SENDA' },
}
@@ -124,7 +124,15 @@ const oportunidadLocal = {
email: null,
desarrollo: 'SENDA',
modelo: 'SOHO',
datosJson: null,
// Afluencia completa: el gate de negocio exige el perfil para apartar
datosJson: JSON.stringify({
estadoCivil: 'Casado(a)',
tipoEmpleo: 'Asalariado',
rangoSalarial: '20,000-40,000',
tipoEconomia: 'Economía mexicana',
califica: 'Sí',
loQuierePara: 'Para vivir',
}),
}
describe('crearCliente (POST /api/clientes) — alta local', () => {
@@ -352,7 +360,7 @@ describe('alta progresiva — local', () => {
mondayApartadoId: null,
})
const altaCompleta = {
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0' },
datosGenerales: { nombreCompleto: 'Ana Demo Progreso', curp: 'AEDA900101MBCXXX00', rfc: 'AEDA900101XX0', celular: '6649990003' },
viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
leadMondayId: 'loc_op_9',
}

View File

@@ -3,6 +3,7 @@ import path from 'node:path'
import fs from 'node:fs'
import { randomUUID } from 'node:crypto'
import multer from 'multer'
import * as XLSX from 'xlsx'
import { z } from 'zod'
import { config } from '../config/index.js'
import { prisma } from '../services/prisma.js'
@@ -20,7 +21,8 @@ export const crearClienteSchema = z
curp: z.string().min(1),
rfc: z.string().min(1),
email: z.string().optional(),
celular: z.string().optional(),
// Teléfono obligatorio (regla de negocio: sin celular no hay alta)
celular: z.string().trim().min(8, 'El celular es obligatorio'),
direccion: z.string().optional(),
// Perfil demográfico (opcionales; viajan en el payload del alta)
fechaNacimiento: z.string().optional(),
@@ -525,6 +527,22 @@ async function prefillApartadoLocal(itemId: string) {
})
}
// Datos del alta (payloadJson) que el prefill del front consume con claves
// legacy de Monday: dirección, estado civil y fecha de nacimiento
const contactoExtra: Record<string, string> = {}
if (cliente?.payloadJson) {
try {
const payload = JSON.parse(cliente.payloadJson) as { datosGenerales?: Record<string, unknown> }
const g = payload.datosGenerales ?? {}
const str = (v: unknown): string => (typeof v === 'string' && v.trim() !== '' ? v : '')
if (str(g['direccion']) !== '') contactoExtra['Dirección'] = str(g['direccion'])
if (str(g['estadoCivil']) !== '') contactoExtra['Estado Civil'] = str(g['estadoCivil'])
if (str(g['fechaNacimiento']) !== '') contactoExtra['Fecha Nacimiento'] = str(g['fechaNacimiento'])
} catch {
// payload inválido: el prefill sale parcial, como antes
}
}
return {
apartado: {
id: captura.leadId,
@@ -545,6 +563,7 @@ async function prefillApartadoLocal(itemId: string) {
Email: cliente.email ?? '',
CURP: cliente.curp,
RFC: cliente.rfc,
...contactoExtra,
}
: null,
oportunidad: oportunidadLocal
@@ -558,3 +577,132 @@ async function prefillApartadoLocal(itemId: string) {
cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null,
}
}
// ---------- Importación de información desde Enkontrol ----------
/** Upload en memoria del documento de Enkontrol (Excel o CSV, máx 10 MB) */
export const uploadImportacionEnkontrol = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
})
/** Normaliza un encabezado para compararlo: minúsculas, sin acentos, espacios→' ' */
function normalizarEncabezado(h: string): string {
return h
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
}
/** Encabezados aceptados como ID de Enkontrol (el campo determinante del documento) */
const COLUMNAS_ID_ENKONTROL = new Set([
'id enkontrol',
'cliente enkontrol',
'usuario enkontrol',
'enkontrol id',
'id de enkontrol',
'cliente enkontrol id',
])
/** Encabezado normalizado → columna del Cliente que actualiza */
const COLUMNAS_CLIENTE: Record<string, 'nombreCompleto' | 'curp' | 'rfc' | 'email' | 'celular' | 'proyecto' | 'unidadCodigo'> = {
'nombre': 'nombreCompleto',
'nombre completo': 'nombreCompleto',
'cliente': 'nombreCompleto',
'curp': 'curp',
'rfc': 'rfc',
'email': 'email',
'correo': 'email',
'correo electronico': 'email',
'celular': 'celular',
'telefono': 'celular',
'telefono celular': 'celular',
'proyecto': 'proyecto',
'desarrollo': 'proyecto',
'unidad': 'unidadCodigo',
'ubicacion': 'unidadCodigo',
'unidad codigo': 'unidadCodigo',
}
/**
* POST /api/clientes/importar-enkontrol — importa un documento (Excel/CSV) de
* Enkontrol usando el ID de Enkontrol como campo determinante de cada cliente:
* cada fila se empareja con la ficha local cuyo alta tenga ese usuarioEnkontrol
* y su información completa se guarda en el payload de la ficha. Las columnas
* conocidas (nombre, CURP, RFC, email, celular, proyecto, unidad) actualizan
* también los campos directos del Cliente.
*/
export async function importarEnkontrolEndpoint(req: Request, res: Response): Promise<void> {
const archivo = (req as Request & { file?: Express.Multer.File }).file
if (!archivo) throw new BadRequestError('Adjunta el archivo Excel o CSV de Enkontrol')
let filas: Record<string, unknown>[]
try {
const libro = XLSX.read(archivo.buffer, { type: 'buffer' })
const hoja = libro.Sheets[libro.SheetNames[0] ?? '']
if (!hoja) throw new Error('sin hojas')
filas = XLSX.utils.sheet_to_json<Record<string, unknown>>(hoja, { defval: '' })
} catch {
throw new BadRequestError('No se pudo leer el documento: usa un Excel (.xlsx) o CSV válido')
}
if (filas.length === 0) throw new BadRequestError('El documento no contiene filas de datos')
const encabezados = Object.keys(filas[0]!)
const columnaId = encabezados.find((h) => COLUMNAS_ID_ENKONTROL.has(normalizarEncabezado(h)))
if (!columnaId) {
throw new BadRequestError(
`No encontré la columna del ID de Enkontrol (encabezados: ${encabezados.slice(0, 8).join(', ')})`,
)
}
// Índice de clientes locales por ID de Enkontrol (payloadJson.datosGenerales.usuarioEnkontrol)
const clientes = await prisma.cliente.findMany({ select: { id: true, nombreCompleto: true, payloadJson: true } })
const porEnkontrol = new Map<string, { id: string; nombreCompleto: string; payloadJson: string }>()
for (const c of clientes) {
try {
const payload = JSON.parse(c.payloadJson) as { datosGenerales?: { usuarioEnkontrol?: unknown } }
const idE = payload.datosGenerales?.usuarioEnkontrol
if (typeof idE === 'string' && idE.trim() !== '') {
porEnkontrol.set(idE.trim().toUpperCase(), c)
}
} catch { /* payload ilegible: se omite del índice */ }
}
let actualizados = 0
const noEncontrados: string[] = []
const ahora = new Date().toISOString()
for (const fila of filas) {
const idE = String(fila[columnaId] ?? '').trim()
if (idE === '') continue
const cliente = porEnkontrol.get(idE.toUpperCase())
if (!cliente) {
noEncontrados.push(idE)
continue
}
let payload: Record<string, unknown> = {}
try { payload = JSON.parse(cliente.payloadJson) as Record<string, unknown> } catch { payload = {} }
const data: Record<string, unknown> = {
payloadJson: JSON.stringify({ ...payload, importacionEnkontrol: { fecha: ahora, datos: fila } }),
}
for (const [encabezado, valor] of Object.entries(fila)) {
const columna = COLUMNAS_CLIENTE[normalizarEncabezado(encabezado)]
const texto = String(valor ?? '').trim()
if (columna !== undefined && texto !== '') data[columna] = texto
}
await prisma.cliente.update({ where: { id: cliente.id }, data })
actualizados++
}
res.json({ totalFilas: filas.length, actualizados, noEncontrados })
auditoria.registrar(
req.usuarioToken?.sub ?? 'sistema',
'IMPORTAR_ENKONTROL',
'cliente',
archivo.originalname,
`${actualizados} actualizados, ${noEncontrados.length} sin ficha local`,
)
}

View File

@@ -7,6 +7,9 @@ const prismaMock = vi.hoisted(() => ({
unidad: {
findUnique: vi.fn(),
},
capturaDepartamento: {
findMany: vi.fn(),
},
leadCaptura: {
findFirst: vi.fn(),
findUnique: vi.fn(),
@@ -30,12 +33,17 @@ const prismaMock = vi.hoisted(() => ({
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
// Reglas comerciales configurables por el admin (vacías = constantes)
reglaCotizadorDesarrollo: {
findMany: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
import { DESARROLLOS, calcularNumMensualidades } from '../services/cotizacion.service.js'
/**
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
@@ -61,7 +69,28 @@ const bodyBase = {
acabados: false,
clienteNombre: 'Cliente Demo',
clienteTelefono: '4771234567',
tipoPropuesta: 'BANCARIO' as const,
}
/**
* Esquema único FRASA 2026 esperado para filaUnidad (precio $1,000,000) con
* los defaults del body: apartado $15,000, N=14, escrituración a la firma.
* Caso dorado del cliente: hoy $15,000 · firma $65,000 · 14 × $5,357.14.
*/
const esquemaDorado = {
esquema: 'FRASA_2026',
apartado: 15000,
saldoFirma: 10000,
engancheDiferible: 75000,
escrituracion: 55000,
escrituracionDiferida: false,
tipoPropuesta: 'FRASA_2026',
descuento: 0,
precioLista: 1_000_000,
precioFinal: 1_000_000,
enganche: 75000,
numMensualidades: 14,
mensualidad: 75000 / 14,
pagoFinal: 65000,
}
function reqFake(body: unknown): Request {
@@ -77,6 +106,7 @@ function resFake() {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad)
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
@@ -87,6 +117,7 @@ beforeEach(() => {
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
@@ -100,10 +131,54 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
expect(status).toHaveBeenCalledWith(201)
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
expect(respuesta['propuesta']).toBeDefined()
expect(respuesta['esquema']).toBeDefined()
expect(respuesta['folio']).toBeDefined()
})
it('caso dorado: recalcula el esquema FRASA 2026 server-side y persiste columnas nuevas y legacy', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ data: expect.objectContaining(esquemaDorado) })
const respuesta = json.mock.calls[0]?.[0] as {
esquema: { saldoFirma: number; pagoUnicoFirma: number; mensualidad: number; baseDiferible: number }
}
expect(respuesta.esquema.saldoFirma).toBe(10000)
expect(respuesta.esquema.pagoUnicoFirma).toBe(65000)
expect(respuesta.esquema.baseDiferible).toBe(75000)
expect(respuesta.esquema.mensualidad).toBeCloseTo(75000 / 14, 2)
})
it('escrituración diferida: entra al diferible y baja el pago a la firma', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, escrituracionDiferida: true }), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({
escrituracionDiferida: true,
pagoFinal: 10000,
mensualidad: 130000 / 14,
}),
})
const respuesta = json.mock.calls[0]?.[0] as { esquema: { baseDiferible: number } }
expect(respuesta.esquema.baseDiferible).toBe(130000)
})
it('apartado mayor al 2.5%: el excedente se acredita al enganche (no se cobra dos veces)', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, apartado: 30000 }), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ apartado: 30000, saldoFirma: 0, enganche: 70000, pagoFinal: 55000 }),
})
const respuesta = json.mock.calls[0]?.[0] as { esquema: { excedenteApartado: number; totalPagado: number } }
expect(respuesta.esquema.excedenteApartado).toBe(5000)
expect(respuesta.esquema.totalPagado).toBeCloseTo(155000, 2)
})
it('la unidad no existe en el espejo → 404 sin crear cotización', async () => {
prismaMock.unidad.findUnique.mockResolvedValue(null)
const { res } = resFake()
@@ -184,6 +259,210 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
})
it('sin numMensualidades usa el default (14), acotado a los meses que faltan para la entrega', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
])
const { res, json } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
const esperadas = Math.min(14, calcularNumMensualidades('2027-10-08'))
const respuesta = json.mock.calls[0]?.[0] as {
numMensualidades: number
mensualidad: number
esquema: { numMensualidades: number; mensualidad: number; baseDiferible: number }
}
expect(respuesta.numMensualidades).toBe(esperadas)
expect(respuesta.numMensualidades).toBe(respuesta.esquema.numMensualidades)
expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / esperadas, 6)
})
it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res)
// Enganche diferible $75,000 en 12 pagos
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }),
})
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } }
expect(respuesta.numMensualidades).toBe(12)
expect(respuesta.esquema.numMensualidades).toBe(12)
})
it('rechaza mensualidades fuera de rango (1..36) sin persistir', async () => {
const { res } = resFake()
await expect(crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 48 }), res)).rejects.toBeDefined()
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('acota las mensualidades a los meses que faltan para la entrega estimada de la unidad', async () => {
// La captura de obra marca la entrega de U-101 a ~6 meses (fija vs hoy)
const entrega = new Date(Date.now() + 6 * 30.4 * 86400000).toISOString().slice(0, 10)
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: entrega },
])
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 20 }), res)
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } }
expect(respuesta.numMensualidades).toBe(6)
expect(respuesta.esquema.numMensualidades).toBe(6)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ numMensualidades: 6 }),
})
})
it('persiste y devuelve las opciones de crédito que manda el cotizador', async () => {
const opcionesCredito = JSON.stringify({
elegida: 'CONTADO',
ajustes: {
CREDITO_BANCARIO: { tasaAnual: 0.105, plazoAnos: 20 },
CREDITO_INFONAVIT_COFINAVIT: { tasaAnual: 0.105, plazoAnos: 25 },
SEMI_CONTADO: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 },
CONTADO: { tasaAnual: 0, plazoAnos: 1 },
},
})
const { res, status, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, opcionesCredito }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ opcionesCredito }),
})
// La respuesta del POST incluye el campo (los GET devuelven la fila completa)
expect(json.mock.calls[0]?.[0]).toMatchObject({ opcionesCredito })
})
it('sin opciones de crédito persiste null', async () => {
const { res } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ opcionesCredito: null }),
})
})
it('rechaza opciones de crédito que exceden 4000 caracteres', async () => {
const { res } = resFake()
await expect(
crearCotizacion(reqFake({ ...bodyBase, opcionesCredito: 'x'.repeat(4001) }), res),
).rejects.toBeDefined()
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => {
prismaMock.unidad.findUnique.mockResolvedValue({ ...filaUnidad, codigo: 'ATELIER-10', status: 'VENDIDO' })
const { res } = resFake()
await expect(crearCotizacion(reqFake({ ...bodyBase, unidadCodigo: 'ATELIER-10' }), res)).rejects.toMatchObject({
status: 409,
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
})
describe('crearCotizacion — reglas comerciales del admin', () => {
/** Reglas guardadas del desarrollo, con los ajustes del test */
function reglasGuardadas(
desarrollo: string,
ajustes: { propuestaInactiva?: string; activoEnCotizador?: boolean; descuentoContado?: number } = {},
) {
const base = DESARROLLOS[desarrollo]!
return [
{
desarrolloId: desarrollo,
parametrosJson: JSON.stringify({
...base,
...(ajustes.activoEnCotizador !== undefined
? { activoEnCotizador: ajustes.activoEnCotizador }
: {}),
propuestas: base.propuestas.map((p) => ({
...p,
activa: p.tipo !== ajustes.propuestaInactiva,
...(p.tipo === 'CONTADO' && ajustes.descuentoContado !== undefined
? { descuentoDefault: ajustes.descuentoContado }
: {}),
})),
}),
},
]
}
it('sin reglas guardadas cotiza con el esquema único (respaldo en constantes)', async () => {
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0, precioFinal: 1_000_000 }),
})
})
it('los descuentos que configure el admin YA NO aplican: el esquema único no los usa', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
)
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0, precioFinal: 1_000_000 }),
})
})
it('propuesta desactivada por el admin ya NO bloquea: el esquema único no usa propuestas', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
)
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalled()
})
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { activoEnCotizador: false }),
)
const { res } = resFake()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
status: 409,
message: expect.stringContaining('no está disponible en el cotizador'),
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('JSON corrupto en la BD: cotiza con las constantes (un warning, sin fallar)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: '{corrupto' },
])
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0, precioLista: 1_000_000 }),
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
warn.mockRestore()
})
})
describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => {
@@ -237,3 +516,27 @@ describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario)
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled()
})
})
describe('crearCotizacion — enganche adicional', () => {
it('persiste el enganche adicional y recalcula el saldo a financiar', async () => {
const { res, json } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, engancheAdicional: 400000 }), res)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ engancheAdicional: 400000 }),
})
const respuesta = json.mock.calls[0]?.[0] as { esquema: { engancheAdicional: number; saldoAFinanciar: number } }
expect(respuesta.esquema.engancheAdicional).toBe(400000)
expect(respuesta.esquema.saldoAFinanciar).toBeCloseTo(445000, 2)
})
it('rechaza con 400 un adicional que pase del 90% de enganche total', async () => {
const { res } = resFake()
await expect(
crearCotizacion(reqFake({ ...bodyBase, engancheAdicional: 900000 }), res),
).rejects.toBeDefined()
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
})

View File

@@ -1,7 +1,17 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { getUnidadLocal } from '../services/unidades-local.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
import { calcularNumMensualidades, obtenerPrecioLista } from '../services/cotizacion.service.js'
import {
calcularEsquemaFrasa,
MENSUALIDADES_DEFAULT,
MENSUALIDADES_MAX,
MENSUALIDADES_MIN,
PCT_ENGANCHE_FIRMA,
PCT_ESCRITURACION,
type EsquemaFrasaResultado,
} from '../services/cotizacion-esquema.service.js'
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
import {
procesarAlertasInventario,
type ResultadoAlertas,
@@ -9,7 +19,7 @@ import {
import { prisma } from '../services/prisma.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import { actividadLocalService } from '../services/actividad-local.service.js'
import { NotFoundError } from '../utils/errors.js'
import { NotFoundError, AppError } from '../utils/errors.js'
import * as auditoria from '../services/auditoria.service.js'
import { folioCotizacion, vigenciaCotizacion } from '../utils/folio.js'
@@ -21,16 +31,42 @@ const crearCotizacionSchema = z.object({
clienteNombre: z.string().min(1),
clienteEmail: z.string().email().optional(),
clienteTelefono: z.string().min(7).optional(),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']),
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%)
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a
// esquemas con crédito (bancario/Infonavit)
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(),
// Esquema único FRASA 2026: mensualidades del diferimiento (elección del
// vendedor, 1..36; sin ellas va el default de 14) y si la escrituración
// (5.5%) se difiere con el enganche o se paga a la firma
numMensualidades: z
.number()
.int('Las mensualidades deben ser un número entero')
.min(MENSUALIDADES_MIN, `Mínimo ${MENSUALIDADES_MIN} mensualidad`)
.max(MENSUALIDADES_MAX, `Máximo ${MENSUALIDADES_MAX} mensualidades`)
.optional(),
escrituracionDiferida: z.boolean().optional(),
// Enganche adicional (anticipo extra): el total de anticipos (esquema +
// adicional) no puede superar el 90% del precio; el espejo lo valida
engancheAdicional: z.number().min(0, 'El enganche adicional no puede ser negativo').optional(),
// Opciones de credito del saldo (84.5%): JSON serializado {elegida, ajustes}
// que manda el cotizador; se persiste crudo y se devuelve en los GET
opcionesCredito: z.string().max(4000).optional(),
// Oportunidad a la que queda vinculada la cotización (botón "Cotizar" de Mi
// día manda el id; sin él se busca por teléfono)
oportunidadId: z.string().trim().min(1).optional(),
})
/** Apartado por defecto del esquema único (el cotizador arranca con el mismo valor) */
const APARTADO_DEFAULT = 15000
/** Unidad cotizable: solo inmuebles DISPONIBLES (apartar/vender no se puede cotizar dos veces) */
function validarUnidadDisponible(unidad: { codigo: string; status: string }): void {
const status = unidad.status.trim().toUpperCase()
if (status !== 'DISPONIBLE') {
throw new AppError(
409,
`La unidad ${unidad.codigo} no está disponible (estatus: ${unidad.status.trim() || 'desconocido'}). ` +
'Elige otra unidad del inventario disponible.',
)
}
}
/**
* Oportunidad local que tenga ese teléfono (match de cotizaciones). Mismo
* criterio que el funnel: primero el mapping histórico (lead → su
@@ -57,10 +93,48 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
// Inventario: SIEMPRE el espejo local (tabla Unidad); 404 si no existe
const unidad = await getUnidadLocal(body.unidadCodigo)
if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`)
// Solo se cotiza inventario disponible: evita cotizar (y apartar) dos veces
// el mismo inmueble ya apartado/vendido
validarUnidadDisponible(unidad)
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento, body.enganchePct)
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
// respaldo en las constantes históricas. Un desarrollo desactivado se
// rechaza con el mensaje que muestra el cotizador. El esquema único
// FRASA 2026 ya no depende de propuestas (las 4 propuestas desaparecieron).
const reglas = await getReglasDesarrollo(unidad.desarrollo)
if (reglas.activoEnCotizador === false) {
throw new AppError(
409,
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
)
}
// El API RECALCULA el esquema server-side con el precio de lista de la
// unidad: no se confía en los montos que manda el cliente.
const apartado = body.apartado ?? APARTADO_DEFAULT
// El diferimiento no puede rebasar la entrega estimada de la unidad (captura
// de obra): se acota a los meses que faltan (36 sigue siendo el tope global)
const topeMensualidades = unidad.fechaEntrega
? Math.min(MENSUALIDADES_MAX, Math.max(MENSUALIDADES_MIN, calcularNumMensualidades(unidad.fechaEntrega)))
: MENSUALIDADES_MAX
const numMensualidades = Math.min(body.numMensualidades ?? MENSUALIDADES_DEFAULT, topeMensualidades)
const escrituracionDiferida = body.escrituracionDiferida ?? false
const precioLista = obtenerPrecioLista(unidad, body.acabados)
// Enganche adicional: el total de anticipos no puede pasar del 90% del
// precio (espejo valida; se traduce a 400 con el mensaje del negocio)
const esquema: EsquemaFrasaResultado = (() => {
try {
return calcularEsquemaFrasa({
precio: precioLista,
apartado,
numMensualidades,
escrituracionDiferida,
...(body.engancheAdicional !== undefined ? { engancheAdicional: body.engancheAdicional } : {}),
})
} catch (err) {
throw new AppError(400, err instanceof Error ? err.message : 'Datos del esquema inválidos')
}
})()
const cotizacion = await prisma.cotizacion.create({
data: {
@@ -70,16 +144,30 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
clienteNombre: body.clienteNombre,
clienteEmail: body.clienteEmail ?? null,
clienteTelefono: body.clienteTelefono ?? null,
tipoPropuesta: body.tipoPropuesta,
acabados: body.acabados,
apartado,
descuento: propuesta.descuento,
precioLista,
precioFinal: propuesta.precioFinal,
enganche: propuesta.enganche,
numMensualidades: propuesta.numMensualidades ?? 0, // 0 => plazo desconocido (sin fecha de entrega)
mensualidad: propuesta.mensualidad,
pagoFinal: propuesta.pagoFinal,
// Columnas nuevas del esquema único
esquema: 'FRASA_2026',
saldoFirma: esquema.saldoFirma,
pctEngancheFirma: PCT_ENGANCHE_FIRMA,
engancheDiferible: esquema.enganche,
pctEscrituracion: PCT_ESCRITURACION,
escrituracionDiferida,
escrituracion: esquema.escrituracion,
engancheAdicional: esquema.engancheAdicional,
// Opciones de credito elegidas por el vendedor (JSON crudo)
opcionesCredito: body.opcionesCredito ?? null,
// Columnas legacy, rellenadas para compatibilidad de reportes:
// sin descuentos en el esquema único, el precio final es el de lista y
// el "pago final" es todo lo que se liquida a la firma.
tipoPropuesta: 'FRASA_2026',
descuento: 0,
precioFinal: precioLista,
enganche: esquema.enganche,
numMensualidades,
mensualidad: esquema.mensualidad,
pagoFinal: esquema.pagoUnicoFirma,
},
})
@@ -96,8 +184,12 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
mondayItemId = opExistente
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
try {
// sombra:false: sólo se marca la etapa si el id corresponde a una
// oportunidad real. Antes (sombra para ids no locales) elegir un cliente
// del maestro en el buscador del cotizador creaba un LeadCaptura
// fantasma ("Registro previo (CRM anterior)") con pendienteSync.
await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', {
sombra: !esIdLocal(opExistente),
sombra: false,
usuarioId: req.usuarioToken?.sub,
})
} catch (err) {
@@ -106,7 +198,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
}
}
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, propuesta })
res.status(201).json({ ...cotizacion, folio: folioCotizacion(cotizacion.id), mondayItemId, esquema })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_COTIZACION', 'cotizacion', cotizacion.id)
}
@@ -162,9 +254,18 @@ export async function obtenerCotizacionPublica(req: Request, res: Response): Pro
folio: folioCotizacion(cotizacion.id),
desarrollo: cotizacion.desarrollo,
unidadCodigo: cotizacion.unidadCodigo,
// Esquema único FRASA 2026 (filas nuevas) + legacy (filas BANCARIO, …)
esquema: cotizacion.esquema,
tipoPropuesta: cotizacion.tipoPropuesta,
precioLista: cotizacion.precioLista,
apartado: cotizacion.apartado,
saldoFirma: cotizacion.saldoFirma,
engancheDiferible: cotizacion.engancheDiferible,
escrituracion: cotizacion.escrituracion,
escrituracionDiferida: cotizacion.escrituracionDiferida,
precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades,
mensualidad: cotizacion.mensualidad,
pagoFinal: cotizacion.pagoFinal,
vigencia: vigenciaCotizacion(cotizacion.createdAt).toISOString(),

View File

@@ -0,0 +1,524 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Prisma mockeado (el servicio corre de verdad) ----------
const prismaMock = vi.hoisted(() => ({
desarrollo: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
modeloDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
reglaCotizadorDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
upsert: vi.fn(),
count: vi.fn(),
},
unidad: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
cotizacion: { count: vi.fn(), findMany: vi.fn() },
leadCaptura: { count: vi.fn(), findMany: vi.fn() },
cliente: { count: vi.fn(), findMany: vi.fn() },
planPago: { count: vi.fn(), findMany: vi.fn() },
auditoriaLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { requireAdminEstricto } from './config.controller.js'
import {
actualizarDesarrolloEndpoint,
agregarModeloEndpoint,
crearDesarrolloEndpoint,
crearUnidadesEndpoint,
eliminarDesarrolloEndpoint,
eliminarUnidadEndpoint,
guardarReglasEndpoint,
listarDesarrollosEndpoint,
listarDesarrollosPublicosEndpoint,
obtenerDesarrolloPublicoEndpoint,
} from './desarrollos.controller.js'
function reqFake(opts: { body?: unknown; params?: Record<string, string>; query?: Record<string, string>; token?: { sub: string; rol: string } }): Request {
return {
body: opts.body ?? {},
params: opts.params ?? {},
query: opts.query ?? {},
usuarioToken: opts.token,
} as unknown as Request
}
function resFake() {
const json = vi.fn()
const end = vi.fn()
const statusFn = vi.fn().mockReturnValue({ json, end })
return { res: { json, end, status: statusFn } as unknown as Response, status: statusFn, json, end }
}
const ADMIN = { sub: 'u-admin', rol: 'admin' }
function filaDesarrollo(over: Record<string, unknown> = {}) {
return {
id: 'd-1',
clave: 'SENDA',
nombre: 'Senda',
estado: 'ACTIVO',
tipo: 'VERTICAL',
constructora: null,
direccion: null,
zona: 'Zapopan',
descripcion: null,
amenidades: null,
fechaInicioObra: null,
fechaEntregaProgramada: null,
fechaEntregaReprogramada: null,
activoEnCotizador: true,
activoEnAlta: true,
prefijoUnidadPdf: 'CASA',
imagenUrl: null,
logoUrl: null,
createdAt: new Date('2026-09-21T00:00:00.000Z'),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
modelos: [],
reglas: null,
...over,
}
}
function filaUnidad(over: Record<string, unknown> = {}) {
return {
id: 'u-1',
codigo: 'SENDA-1',
desarrollo: 'SENDA',
modelo: 'HIMALAYA',
manzana: '',
lote: '',
nivel: '',
supConstruccion: 120,
supExcedente: 0,
supEstacionamiento: 1,
precioLista: 3000000,
precioAcabados: 3200000,
status: 'DISPONIBLE',
etapa: '',
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...over,
}
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.count.mockResolvedValue(0)
prismaMock.cotizacion.count.mockResolvedValue(0)
prismaMock.leadCaptura.count.mockResolvedValue(0)
prismaMock.cliente.count.mockResolvedValue(0)
prismaMock.planPago.count.mockResolvedValue(0)
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: `id-${String(data['codigo'])}`,
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...data,
}))
})
// ── Permisos ──────────────────────────────────────────────────────────
describe('requireAdminEstricto', () => {
it('deja pasar al admin', () => {
const next = vi.fn()
requireAdminEstricto(reqFake({ token: ADMIN }), {} as Response, next)
expect(next).toHaveBeenCalledOnce()
})
it('403 para manager y para vendedor', () => {
for (const rol of ['manager', 'vendedor', 'coordinador', 'corporativo']) {
expect(() => requireAdminEstricto(reqFake({ token: { sub: 'u-1', rol } }), {} as Response, vi.fn())).toThrow(
'Solo un administrador puede hacer esta operación',
)
}
})
it('403 sin token', () => {
expect(() => requireAdminEstricto(reqFake({}), {} as Response, vi.fn())).toThrow()
})
it('los endpoints de escritura quedan detrás del guard (403 con rol manager)', async () => {
// El guard se monta en las rutas; aquí se comprueba que corta antes del servicio
const req = reqFake({ token: { sub: 'u-1', rol: 'manager' }, body: { clave: 'NUEVO', nombre: 'Nuevo' } })
expect(() => requireAdminEstricto(req, {} as Response, vi.fn())).toThrow()
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
})
})
// ── Fichas ────────────────────────────────────────────────────────────
describe('POST /api/desarrollos', () => {
it('201 con la ficha cuando la clave es nueva', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(filaDesarrollo())
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
const { res, status, json } = resFake()
await crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ accion: 'CREAR_DESARROLLO', entidad: 'desarrollo', usuarioId: 'u-admin' }),
})
})
it('409 con clave duplicada y sin crear', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
const { res } = resFake()
await expect(crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)).rejects.toMatchObject({
status: 409,
})
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
})
it('400 si la clave no es mayúsculas sin espacios', async () => {
const { res } = resFake()
await expect(
crearDesarrolloEndpoint(reqFake({ body: { clave: 'senda nueva', nombre: 'Senda' }, token: ADMIN }), res),
).rejects.toThrow()
})
})
describe('GET /api/desarrollos y detalle', () => {
it('devuelve el contrato del listado', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
reglas: { desarrolloId: 'd-1', parametrosJson: '{}', updatedAt: new Date(), updatedPorId: null },
}),
])
prismaMock.unidad.findMany.mockResolvedValue([
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
{ desarrollo: 'SENDA', status: 'APARTADO' },
])
const { res, json } = resFake()
await listarDesarrollosEndpoint(reqFake({ token: ADMIN }), res)
expect(json).toHaveBeenCalledWith({
desarrollos: [
expect.objectContaining({
clave: 'SENDA',
unidadesTotales: 2,
unidadesDisponibles: 1,
tieneReglas: true,
modelos: [
{ id: 'm-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
],
}),
],
})
})
})
describe('PATCH /api/desarrollos/:id', () => {
it('404 si no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(actualizarDesarrolloEndpoint(reqFake({ params: { id: 'nope' }, body: { zona: 'X' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
status: 404,
})
})
it('actualiza solo los campos enviados', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' }).mockResolvedValueOnce(filaDesarrollo({ zona: 'Tlajomulco' }))
prismaMock.desarrollo.update.mockResolvedValue(filaDesarrollo())
const { res, json } = resFake()
await actualizarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, body: { zona: 'Tlajomulco' }, token: ADMIN }), res)
expect(prismaMock.desarrollo.update).toHaveBeenCalledWith({ where: { id: 'd-1' }, data: { zona: 'Tlajomulco' } })
expect(json).toHaveBeenCalledWith(expect.objectContaining({ zona: 'Tlajomulco' }))
})
})
describe('DELETE /api/desarrollos/:id', () => {
it('409 con inventario y operación; 204 sin dependencias', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
prismaMock.cotizacion.count.mockResolvedValue(3)
await expect(eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
status: 409,
message: 'El desarrollo tiene 2 unidades y 3 registros de operación; desactívalo en vez de borrarlo',
})
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
})
it('204 y auditoría cuando el desarrollo está limpio', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const { res, status } = resFake()
await eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(204)
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ accion: 'ELIMINAR_DESARROLLO', entidadId: 'd-1' }),
})
})
})
// ── Modelos y reglas ──────────────────────────────────────────────────
describe('POST /api/desarrollos/:id/modelos', () => {
it('201 con el modelo creado', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue(null)
prismaMock.modeloDesarrollo.create.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'PANORAMA' })
const { res, status } = resFake()
await agregarModeloEndpoint(reqFake({ params: { id: 'd-1' }, body: { nombre: 'PANORAMA', recamaras: 3 }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(201)
})
})
describe('PUT /api/desarrollos/:id/reglas', () => {
const reglas = {
precioUnico: false,
fuenteDeN: 'fechaEntrega',
nDefault: 13,
cuotaMantenimiento: 1500,
prefijoUnidadPdf: 'CASA',
propuestas: [
{
tipo: 'CREDITO_BANCARIO',
claveDescuento: 'creditoBancario',
titulo: 'Crédito Bancario',
descuentoDefault: 0,
pctEnganche: 0.025,
engancheBase: 'lista',
pctPagoFinal: 0.9,
pagoFinalBase: 'final',
modoMensualidades: 'remanente',
},
],
}
it('guarda y devuelve las reglas parseadas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
desarrolloId: 'd-1',
parametrosJson: JSON.stringify(reglas),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
updatedPorId: 'u-admin',
})
const { res, json } = resFake()
await guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: reglas, token: ADMIN }), res)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ desarrolloId: 'd-1', parametros: expect.objectContaining({ nDefault: 13 }) }))
})
it('400 si un porcentaje se sale de 0..1', async () => {
const malas = { ...reglas, propuestas: [{ ...reglas.propuestas[0], pctPagoFinal: 1.2 }] }
await expect(guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: malas, token: ADMIN }), resFake().res)).rejects.toThrow()
expect(prismaMock.reglaCotizadorDesarrollo.upsert).not.toHaveBeenCalled()
})
})
// ── Inventario ────────────────────────────────────────────────────────
describe('POST /api/desarrollos/:id/unidades', () => {
it('genera el rango con {clave} y {n}: 201 con las creadas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const { res, status, json } = resFake()
await crearUnidadesEndpoint(
reqFake({
params: { id: 'd-1' },
body: { generar: { patron: 'SENDA-401-{n}', desde: 1, hasta: 3, precioLista: 2000000 } },
token: ADMIN,
}),
res,
)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({
creadas: 3,
unidades: expect.arrayContaining([expect.objectContaining({ codigo: 'SENDA-401-1', status: 'DISPONIBLE' })]),
})
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ accion: 'CREAR_UNIDADES', entidad: 'unidad' }),
})
})
it('{clave} se sustituye por la clave del desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const { json } = resFake()
await crearUnidadesEndpoint(
reqFake({ params: { id: 'd-1' }, body: { generar: { patron: '{clave}-{n}', desde: 7, hasta: 8, precioLista: 1 } }, token: ADMIN }),
resFake().res,
)
void json
const creadas = prismaMock.unidad.create.mock.calls.map((c) => (c[0] as { data: { codigo: string } }).data.codigo)
expect(creadas).toEqual(['SENDA-7', 'SENDA-8'])
})
it('409 sin crear nada si algún código ya existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([{ codigo: 'SENDA-5' }])
const { res } = resFake()
await expect(
crearUnidadesEndpoint(
reqFake({
params: { id: 'd-1' },
body: {
unidades: [
{ codigo: 'SENDA-5', precioLista: 1 },
{ codigo: 'SENDA-6', precioLista: 1 },
],
},
token: ADMIN,
}),
res,
),
).rejects.toMatchObject({ status: 409 })
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
})
it('409 si el mismo código se repite en la alta', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValue([])
await expect(
crearUnidadesEndpoint(
reqFake({
params: { id: 'd-1' },
body: { unidades: [{ codigo: 'SENDA-1', precioLista: 1 }, { codigo: 'SENDA-1', precioLista: 2 }] },
token: ADMIN,
}),
resFake().res,
),
).rejects.toMatchObject({ status: 409 })
})
it('404 si el desarrollo no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(
crearUnidadesEndpoint(reqFake({ params: { id: 'nope' }, body: { generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }, token: ADMIN }), resFake().res),
).rejects.toMatchObject({ status: 404 })
})
})
describe('DELETE /api/desarrollos/:id/unidades/:codigo', () => {
it('409 si la unidad tiene cotizaciones o ventas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
prismaMock.planPago.count.mockResolvedValue(1)
await expect(
eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), resFake().res),
).rejects.toMatchObject({ status: 409 })
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
})
it('204 si la unidad está libre', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
const { res, status } = resFake()
await eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(204)
})
})
// ── Público (sin auth) ────────────────────────────────────────────────
describe('GET /api/publico/desarrollos', () => {
it('publica solo el desarrollo activo con inventario disponible y sin datos personales', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
}),
])
prismaMock.unidad.findMany.mockResolvedValue([
filaUnidad({ codigo: 'SENDA-1', nivel: '3' }),
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 5000000 }),
])
const { res, json } = resFake()
await listarDesarrollosPublicosEndpoint(reqFake({}), res)
const payload = (json.mock.calls[0] as [ { desarrollos: Record<string, unknown>[] } ])[0]
const desarrollo = payload.desarrollos[0] as Record<string, unknown>
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
)
expect(Object.keys(desarrollo).sort()).toEqual(
[
'amenidades',
'clave',
'descripcion',
'direccion',
'estado',
'fechaEntregaProgramada',
'imagenUrl',
'logoUrl',
'modelos',
'nombre',
'tipo',
'unidadesDisponibles',
'zona',
].sort(),
)
expect(desarrollo['unidadesDisponibles']).toEqual([
{ codigo: 'SENDA-1', modelo: 'HIMALAYA', nivel: '3', precioLista: 3000000, precioAcabados: 3200000, supConstruccion: 120 },
])
// El camino público nunca consulta clientes, cotizaciones ni planes
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
expect(prismaMock.cliente.findMany).not.toHaveBeenCalled()
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
expect(prismaMock.planPago.count).not.toHaveBeenCalled()
expect(prismaMock.leadCaptura.count).not.toHaveBeenCalled()
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado', 'rfc']) {
expect(JSON.stringify(payload).toLowerCase()).not.toContain(prohibido)
}
})
})
describe('GET /api/publico/desarrollos/:clave', () => {
it('404 si no existe o no es público', async () => {
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
await expect(obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'NOEXISTE' } }), resFake().res)).rejects.toMatchObject({
status: 404,
})
})
it('resuelve la clave sin importar mayúsculas', async () => {
prismaMock.desarrollo.findFirst.mockResolvedValue(filaDesarrollo())
const { res, json } = resFake()
await obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'senda' } }), res)
expect(prismaMock.desarrollo.findFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'SENDA', activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
})
})

View File

@@ -0,0 +1,151 @@
/**
* Endpoints del módulo Desarrollos.
*
* Todo el CRUD y el inventario son solo admin (`requireAdminEstricto`, montado
* en las rutas); el micrositio público lee /api/publico/desarrollos sin auth,
* con información exclusivamente comercial (nunca clientes ni cotizaciones).
*/
import type { Request, Response } from 'express'
import * as service from '../services/desarrollos.service.js'
import * as auditoria from '../services/auditoria.service.js'
import { AppError } from '../utils/errors.js'
/** Usuario del token para la bitácora (modo local sin auth: 'desconocido') */
function usuarioDe(req: Request): string {
return req.usuarioToken?.sub ?? 'desconocido'
}
function idDe(req: Request): string {
const id = String(req.params['id'] ?? '')
if (id === '') throw new AppError(400, 'Falta el parámetro :id')
return id
}
// ── Fichas ────────────────────────────────────────────────────────────
/** GET /api/desarrollos */
export async function listarDesarrollosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await service.listarDesarrollos())
}
/** GET /api/desarrollos/:id */
export async function obtenerDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
res.json(await service.obtenerDesarrollo(idDe(req)))
}
/** POST /api/desarrollos */
export async function crearDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
const body = service.crearDesarrolloSchema.parse(req.body)
const ficha = await service.crearDesarrollo(body)
res.status(201).json(ficha)
auditoria.registrar(usuarioDe(req), 'CREAR_DESARROLLO', 'desarrollo', ficha.id, ficha.clave)
}
/** PATCH /api/desarrollos/:id */
export async function actualizarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
const body = service.actualizarDesarrolloSchema.parse(req.body)
const ficha = await service.actualizarDesarrollo(idDe(req), body)
res.json(ficha)
auditoria.registrar(
usuarioDe(req),
'ACTUALIZAR_DESARROLLO',
'desarrollo',
ficha.id,
Object.keys(req.body as Record<string, unknown>).join(', '),
)
}
/** DELETE /api/desarrollos/:id — 409 si tiene inventario u operación */
export async function eliminarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
const id = idDe(req)
await service.eliminarDesarrollo(id)
res.status(204).end()
auditoria.registrar(usuarioDe(req), 'ELIMINAR_DESARROLLO', 'desarrollo', id)
}
// ── Modelos ───────────────────────────────────────────────────────────
/** POST /api/desarrollos/:id/modelos */
export async function agregarModeloEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const body = service.crearModeloSchema.parse(req.body)
const modelo = await service.agregarModelo(desarrolloId, body)
res.status(201).json(modelo)
auditoria.registrar(usuarioDe(req), 'AGREGAR_MODELO', 'desarrollo', desarrolloId, modelo.nombre)
}
/** PATCH /api/desarrollos/modelos/:modeloId */
export async function actualizarModeloEndpoint(req: Request, res: Response): Promise<void> {
const modeloId = String(req.params['modeloId'])
const body = service.actualizarModeloSchema.parse(req.body)
const modelo = await service.actualizarModelo(modeloId, body)
res.json(modelo)
auditoria.registrar(usuarioDe(req), 'ACTUALIZAR_MODELO', 'desarrollo', modelo.desarrolloId, modelo.nombre)
}
/** DELETE /api/desarrollos/modelos/:modeloId — 409 si el modelo tiene unidades */
export async function eliminarModeloEndpoint(req: Request, res: Response): Promise<void> {
const modeloId = String(req.params['modeloId'])
await service.eliminarModelo(modeloId)
res.status(204).end()
auditoria.registrar(usuarioDe(req), 'ELIMINAR_MODELO', 'desarrollo', modeloId)
}
// ── Reglas del cotizador ──────────────────────────────────────────────
/** PUT /api/desarrollos/:id/reglas */
export async function guardarReglasEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const parametros = service.parametrosDesarrolloSchema.parse(req.body)
const reglas = await service.guardarReglas(desarrolloId, parametros, req.usuarioToken?.sub ?? null)
res.json(reglas)
auditoria.registrar(usuarioDe(req), 'GUARDAR_REGLAS_COTIZADOR', 'desarrollo', desarrolloId)
}
// ── Inventario ────────────────────────────────────────────────────────
/** POST /api/desarrollos/:id/unidades — alta manual o por generador */
export async function crearUnidadesEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const body = service.crearUnidadesSchema.parse(req.body)
const resultado = await service.crearUnidades(desarrolloId, body)
res.status(201).json(resultado)
auditoria.registrar(
usuarioDe(req),
'CREAR_UNIDADES',
'unidad',
desarrolloId,
`${resultado.creadas} unidad(es): ${resultado.unidades.map((u) => u.codigo).join(', ')}`,
)
}
/** DELETE /api/desarrollos/:id/unidades/:codigo — 409 si la unidad tiene operación */
export async function eliminarUnidadEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const codigo = String(req.params['codigo'])
await service.eliminarUnidad(desarrolloId, codigo)
res.status(204).end()
auditoria.registrar(usuarioDe(req), 'ELIMINAR_UNIDAD', 'unidad', codigo)
}
/** PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m², modelo y estatus */
export async function actualizarUnidadEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const codigo = String(req.params['codigo'])
const body = service.actualizarUnidadSchema.parse(req.body)
const unidad = await service.actualizarUnidad(desarrolloId, codigo, body)
res.json(unidad)
auditoria.registrar(usuarioDe(req), 'ACTUALIZAR_UNIDAD', 'unidad', codigo)
}
// ── Público (micrositio) ──────────────────────────────────────────────
/** GET /api/publico/desarrollos */
export async function listarDesarrollosPublicosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await service.listarDesarrollosPublicos())
}
/** GET /api/publico/desarrollos/:clave */
export async function obtenerDesarrolloPublicoEndpoint(req: Request, res: Response): Promise<void> {
res.json(await service.obtenerDesarrolloPublico(String(req.params['clave'])))
}

View File

@@ -34,6 +34,7 @@ const prismaMock = vi.hoisted(() => ({
findMany: vi.fn(),
},
automatizacionState: {
findFirst: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
@@ -83,6 +84,7 @@ beforeEach(() => {
prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.findFirst.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
@@ -212,6 +214,7 @@ describe('crearAfluenciaEndpoint (POST /api/afluencias) — la afluencia ES la o
reqCon({
body: {
nombre: 'Lead Prueba',
telefono: '4771234567',
oportunidadId: 'loc_op_3',
desarrollo: 'SENDA',
modelo: 'SOHO',
@@ -448,17 +451,30 @@ describe('admin CRUD de leads — local', () => {
expect(json).toHaveBeenCalledWith({ eliminado: true, itemId: 'loc_1' })
})
it('asignarLeadEndpoint reasigna el lead localmente y responde con el nombre', async () => {
it('asignarLeadEndpoint reasigna la cadena completa y responde con el nombre', async () => {
prismaMock.usuario.findUnique.mockResolvedValue({ nombre: 'Martin Zavala' })
prismaMock.leadCaptura.findUnique.mockResolvedValue({ leadId: 'loc_1', board: 'LEADS', oportunidadId: null })
prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 1 })
const { res, json } = resFake()
await asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { usuarioId: 'usr-9' } }), res)
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_1' },
where: { leadId: { in: ['loc_1'] } },
data: { usuarioId: 'usr-9' },
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', usuarioId: 'usr-9', nombre: 'Martin Zavala' })
expect(json).toHaveBeenCalledWith({ itemId: 'loc_1', usuarioId: 'usr-9', nombre: 'Martin Zavala', movidas: 1 })
})
it('asignarLeadEndpoint con lead inexistente → 404 (ya no es exito silencioso)', async () => {
prismaMock.usuario.findUnique.mockResolvedValue({ nombre: 'Martin Zavala' })
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
const { res } = resFake()
await expect(
asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_fantasma' }, body: { usuarioId: 'usr-9' } }), res),
).rejects.toThrow('Lead no encontrado')
expect(prismaMock.leadCaptura.updateMany).not.toHaveBeenCalled()
})
it('asignarLeadEndpoint con usuario inexistente → 404', async () => {

View File

@@ -1,6 +1,6 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { VOCABULARIO } from '../config/vocabulario.js'
import { VOCABULARIO, afluenciaCompleta } from '../config/vocabulario.js'
import { prisma } from '../services/prisma.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.service.js'
@@ -60,7 +60,8 @@ const crearLeadSchema = z.object({
/** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
const crearAfluenciaSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
telefono: z.string().trim().min(1).optional(),
// Teléfono obligatorio (regla de negocio: sin teléfono no hay afluencia)
telefono: z.string().trim().min(7, 'El teléfono es obligatorio'),
email: z.string().trim().email('Email inválido').optional(),
origen: z.string().trim().min(1).optional(),
desarrollo: z.string().trim().min(1).optional(),
@@ -226,6 +227,26 @@ export async function crearAfluenciaEndpoint(req: Request, res: Response): Promi
auditoria.registrar(usuarioSub ?? 'sistema', 'CREAR_AFLUENCIA', 'afluencia', resultado.id)
}
/**
* GET /api/afluencias/:leadId — perfil de afluencia capturado en el datosJson
* de la fila (lead/oportunidad/apartado). Solo valores de texto no vacíos.
* Alimenta la pestaña "Afluencia" de la bitácora del cliente en Mi día.
*/
export async function obtenerAfluenciaEndpoint(req: Request, res: Response): Promise<void> {
const leadId = String(req.params['leadId'])
const fila = await prisma.leadCaptura.findUnique({ where: { leadId } })
if (!fila) throw new NotFoundError('No existe el registro solicitado')
let datos: Record<string, unknown> = {}
if (fila.datosJson) {
try { datos = JSON.parse(fila.datosJson) as Record<string, unknown> } catch { datos = {} }
}
const perfil: Record<string, string> = {}
for (const [clave, valor] of Object.entries(datos)) {
if (typeof valor === 'string' && valor.trim() !== '') perfil[clave] = valor
}
res.json({ leadId, nombre: fila.nombreLead, perfil, afluenciaCompleta: afluenciaCompleta(fila.datosJson) })
}
/**
* Cambio de estado en modo local: guarda la etapa (label del board) en la fila
* local. Con id heredado sin fila local (items viejos del CRM anterior)
@@ -319,7 +340,9 @@ const asignarLeadSchema = z.object({
usuarioId: z.string().trim().min(1, 'usuarioId es obligatorio'),
})
/** PATCH /api/leads/:itemId/asignar — reasignar lead a otro vendedor (solo admin) */
/** PATCH /api/leads/:itemId/asignar — reasignar lead a otro vendedor (solo admin).
* Reasigna TODA la cadena del cliente (lead → oportunidad → apartado) y sus
* actividades pendientes, para no dejar el registro partido entre vendedores. */
export async function asignarLeadEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId'])
const { usuarioId } = asignarLeadSchema.parse(req.body)
@@ -328,13 +351,9 @@ export async function asignarLeadEndpoint(req: Request, res: Response): Promise<
select: { nombre: true },
})
if (!usuario) throw new NotFoundError('Usuario no encontrado')
// La asignación local es la verdad: se actualiza la fila del lead
await prisma.leadCaptura.updateMany({
where: { leadId: itemId },
data: { usuarioId },
})
res.json({ itemId, usuarioId, nombre: usuario.nombre })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId)
const movidas = await crmLocal.reasignarCadena(itemId, usuarioId)
res.json({ itemId, usuarioId, nombre: usuario.nombre, movidas })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, `${usuario.nombre} (${movidas} fila(s))`)
}
const moverLeadSchema = z.object({
@@ -363,5 +382,13 @@ export async function moverLeadEndpoint(req: Request, res: Response): Promise<vo
export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres')
res.json(await crmLocal.buscarLocales(q))
// Privacidad: el vendedor sólo encuentra sus propios leads/contactos; los
// roles de supervisión siguen viendo todo el universo.
const soloPropio = req.usuarioToken?.rol === 'vendedor'
res.json(
await crmLocal.buscarLocales(
q,
req.usuarioToken ? { usuarioId: req.usuarioToken.sub, soloPropio } : undefined,
),
)
}

View File

@@ -4,6 +4,7 @@ import {
cambiarEtapaOportunidadEndpoint,
ETAPAS_OPORTUNIDAD_PERMITIDAS,
perderOportunidadEndpoint,
STATUS_NO_BLOQUEABLES,
} from './oportunidades.controller.js'
// ---------- Prisma mockeado (los tests no tocan la BD) ----------
@@ -17,6 +18,9 @@ const prismaMock = vi.hoisted(() => ({
unidad: {
updateMany: vi.fn(),
},
cotizacion: {
findFirst: vi.fn(),
},
automatizacionState: {
findUnique: vi.fn(),
upsert: vi.fn(),
@@ -53,6 +57,7 @@ beforeEach(() => {
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => ({ ...data }))
prismaMock.unidad.updateMany.mockResolvedValue({ count: 1 })
prismaMock.cotizacion.findFirst.mockResolvedValue(null)
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
@@ -149,7 +154,61 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
expect(json).toHaveBeenCalledWith({ itemId: 'loc_2', etapa: 'Agendo Cita', fecha: '2026-09-10' })
})
it("'Aparto': crea el apartado local (crmLocal.apartar) y bloquea la unidad del inventario", async () => {
it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson y bloquea la unidad del inventario", async () => {
// La oportunidad YA tiene afluencia completa (gate de negocio: sin
// afluencia no se puede apartar)
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op',
usuarioId: 'u-1',
nombreLead: 'Luis Demo',
telefono: '6641112233',
email: null,
desarrollo: 'ATELIER',
modelo: 'SOHO',
datosJson: JSON.stringify({
estadoCivil: 'Casado',
tipoEmpleo: 'Formal',
rangoSalarial: 'B. 20,000-40,000',
tipoEconomia: 'Formal',
califica: 'Si',
loQuierePara: 'Para vivir',
unidadCodigo: 'U-101',
}),
})
const { res, json } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
// Oportunidad en Aparto + apartado local en Pago Apartado con la unidad + cola
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_op' },
data: { etapa: 'Aparto', pendienteSync: true },
})
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({
board: 'APARTADO',
etapa: 'Pago Apartado',
nombreLead: 'Luis Demo',
datosJson: JSON.stringify({
estadoCivil: 'Casado',
tipoEmpleo: 'Formal',
rangoSalarial: 'B. 20,000-40,000',
tipoEconomia: 'Formal',
califica: 'Si',
loQuierePara: 'Para vivir',
unidadCodigo: 'U-101',
}),
}),
})
// La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-101', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
data: { status: 'APARTADO' },
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
})
it("'Aparto' SIN afluencia completa: se rechaza con 400 antes de crear nada", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op',
usuarioId: 'u-1',
@@ -160,24 +219,60 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
modelo: 'SOHO',
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
})
const { res, json } = resFake()
const { res } = resFake()
await expect(
cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res),
).rejects.toThrow(/afluencia/i)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
expect(prismaMock.unidad.updateMany).not.toHaveBeenCalled()
})
it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op',
usuarioId: 'u-1',
nombreLead: 'Luis Demo',
telefono: null,
email: null,
desarrollo: 'SENDA',
modelo: null,
datosJson: JSON.stringify({
estadoCivil: 'Casado',
tipoEmpleo: 'Formal',
rangoSalarial: 'B. 20,000-40,000',
tipoEconomia: 'Formal',
califica: 'Si',
loQuierePara: 'Para vivir',
}),
})
prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' })
const { res } = resFake()
await cambiarEtapaOportunidadEndpoint(reqCon({ params: { itemId: 'loc_op' }, body: { etapa: 'Aparto' } }), res)
// Oportunidad en Aparto + apartado local en Pago Apartado + cola
expect(prismaMock.leadCaptura.update).toHaveBeenCalledWith({
where: { leadId: 'loc_op' },
data: { etapa: 'Aparto', pendienteSync: true },
expect(prismaMock.cotizacion.findFirst).toHaveBeenCalledWith({
where: { mondayItemId: 'loc_op' },
orderBy: { createdAt: 'desc' },
select: { unidadCodigo: true },
})
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'APARTADO', etapa: 'Pago Apartado', nombreLead: 'Luis Demo' }),
data: expect.objectContaining({
datosJson: JSON.stringify({
estadoCivil: 'Casado',
tipoEmpleo: 'Formal',
rangoSalarial: 'B. 20,000-40,000',
tipoEconomia: 'Formal',
califica: 'Si',
loQuierePara: 'Para vivir',
unidadCodigo: 'SENDA-411-1-7',
}),
}),
})
// La unidad asignada queda APARTADA en el espejo local
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'U-101' },
data: { status: 'APARTADA' },
where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
data: { status: 'APARTADO' },
})
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' })
})
it("'Aparto' sin unidad asignada no toca el inventario", async () => {
@@ -189,7 +284,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
email: null,
desarrollo: null,
modelo: null,
datosJson: null,
datosJson: JSON.stringify({
estadoCivil: 'Casado',
tipoEmpleo: 'Formal',
rangoSalarial: 'B. 20,000-40,000',
tipoEconomia: 'Formal',
califica: 'Si',
loQuierePara: 'Para vivir',
}),
})
const { res } = resFake()

View File

@@ -52,9 +52,40 @@ const cambiarEtapaSchema = z.object({
fecha: z.string().optional(),
})
/**
* Estatus de inventario que un apartado nunca debe pisar: la unidad ya se
* vendió, se escrituró o no está a la venta (BLOQUEADO).
*/
export const STATUS_NO_BLOQUEABLES = ['VENDIDO', 'ESCRITURADO', 'BLOQUEADO']
/**
* Unidad asignada a la oportunidad, leída de su `datosJson` (la deja el alta de
* clientes / la afluencia) o, si no está ahí, de la cotización más reciente
* ligada a ella (`Cotizacion.mondayItemId`): el vendedor cotiza y después
* aparta, así que la unidad cotizada ES la que se está apartando.
*/
async function unidadDeOportunidad(itemId: string, datosJson: string | null): Promise<string | null> {
if (datosJson) {
try {
const datos = JSON.parse(datosJson) as Record<string, unknown>
if (typeof datos['unidadCodigo'] === 'string' && datos['unidadCodigo'].trim() !== '') {
return datos['unidadCodigo'].trim()
}
} catch { /* datosJson inválido: se ignora */ }
}
const cotizacion = await prisma.cotizacion.findFirst({
where: { mondayItemId: itemId },
orderBy: { createdAt: 'desc' },
select: { unidadCodigo: true },
})
const codigo = cotizacion?.unidadCodigo.trim()
return codigo !== undefined && codigo !== '' ? codigo : null
}
/**
* Cambio de etapa local: guarda la etapa en la fila local y encola
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar).
* CAMBIO_ETAPA. En 'Aparto' crea además el apartado local (crmLocal.apartar),
* dejando la unidad apartada en su `datosJson`.
* Devuelve el unidadCodigo de la oportunidad si existe (para bloquear la unidad).
*/
async function cambiarEtapaLocal(
@@ -64,17 +95,12 @@ async function cambiarEtapaLocal(
usuarioId: string | undefined,
): Promise<string | null> {
if (etapa === 'Aparto') {
await crmLocal.apartar(itemId, usuarioId)
// Leer la oportunidad local para obtener el unidadCodigo y bloquear la unidad
const captura = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
let unidadCodigo = ''
if (captura?.datosJson) {
try {
const datos = JSON.parse(captura.datosJson) as Record<string, unknown>
if (typeof datos['unidadCodigo'] === 'string') unidadCodigo = datos['unidadCodigo']
} catch { /* datosJson inválido: se ignora */ }
}
return unidadCodigo.trim() !== '' ? unidadCodigo.trim() : null
const unidadCodigo = await unidadDeOportunidad(itemId, captura?.datosJson ?? null)
await crmLocal.apartar(itemId, usuarioId, {
...(unidadCodigo !== null ? { unidadCodigo } : {}),
})
return unidadCodigo
}
await crmLocal.cambiarEstado(itemId, etapa, 'OPORTUNIDADES', {
fecha,
@@ -89,10 +115,15 @@ export async function cambiarEtapaOportunidadEndpoint(req: Request, res: Respons
const body = cambiarEtapaSchema.parse(req.body)
const itemId = String(req.params['itemId'])
const unidadCodigo = await cambiarEtapaLocal(itemId, body.etapa, body.fecha, req.usuarioToken?.sub)
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada
// Bloquear la unidad del inventario local si la oportunidad tiene una asignada.
// Nunca se degrada una unidad ya VENDIDA/ESCRITURADA/BLOQUEADA: el apartado
// solo bloquea inventario vendible.
if (body.etapa === 'Aparto' && unidadCodigo !== null) {
try {
await prisma.unidad.updateMany({ where: { codigo: unidadCodigo }, data: { status: 'APARTADA' } })
await prisma.unidad.updateMany({
where: { codigo: unidadCodigo, status: { notIn: STATUS_NO_BLOQUEABLES } },
data: { status: 'APARTADO' },
})
} catch (err) {
// eslint-disable-next-line no-console
console.error(`No se pudo bloquear la unidad ${unidadCodigo} de la oportunidad ${itemId}:`, err)

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { resolverFiltroMios } from './planes.controller.js'
import { describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
import { ROLES_EDITAN_PLAN, requireEditaPlan, resolverFiltroMios } from './planes.controller.js'
describe('resolverFiltroMios (?mios=1)', () => {
it('sin ?mios=1 no filtra nada', () => {
@@ -20,3 +21,35 @@ describe('resolverFiltroMios (?mios=1)', () => {
expect(resolverFiltroMios('1', undefined)).toBeUndefined()
})
})
const reqCon = (rol?: string): Request =>
({
body: {},
params: { id: 'plan-1' },
...(rol ? { usuarioToken: { sub: 'u-1', username: 'x', rol } } : {}),
}) as unknown as Request
describe('requireEditaPlan (PATCH /planes/:id)', () => {
it('admin, manager, dirección y sus roles legacy pasan', () => {
for (const rol of ROLES_EDITAN_PLAN) {
const next = vi.fn()
requireEditaPlan(reqCon(rol), {} as Response, next)
expect(next).toHaveBeenCalled()
}
expect(ROLES_EDITAN_PLAN).toEqual(
expect.arrayContaining(['admin', 'manager', 'direccion_general', 'director_ventas']),
)
})
it('los roles de cobranza que no son dirección quedan fuera', () => {
for (const rol of ['vendedor', 'administracion_venta', 'escrituracion']) {
expect(() => requireEditaPlan(reqCon(rol), {} as Response, vi.fn())).toThrow(
'Solo admin, manager o dirección pueden modificar el plan',
)
}
})
it('sin token también rechaza (403)', () => {
expect(() => requireEditaPlan(reqCon(), {} as Response, vi.fn())).toThrow()
})
})

View File

@@ -1,4 +1,4 @@
import type { Request, Response } from 'express'
import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod'
import { prisma } from '../services/prisma.js'
import {
@@ -18,7 +18,7 @@ import {
validarAbono,
type PlanPago as PlanPagoDominio,
} from '../services/cobranza.service.js'
import { BadRequestError, NotFoundError } from '../utils/errors.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client'
// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ----------
@@ -319,3 +319,48 @@ export async function cancelarPlan(req: Request, res: Response): Promise<void> {
const actualizado = await cargarPlan(plan.id)
res.json(conCalculados(aDominio(actualizado)))
}
// ── Edición de parámetros del plan (tasa de mora) ────────────────────
/** Roles que pueden editar el plan: admin, manager y dirección (y sus legacy) */
export const ROLES_EDITAN_PLAN: readonly string[] = [
'admin',
'manager',
'direccion_general',
'director_ventas',
// Legacy: la matriz de roles los homologa a dirección de ventas
'coordinador',
'corporativo',
]
/** Middleware: sin rol autorizado no se edita el plan (403) */
export function requireEditaPlan(req: Request, _res: Response, next: NextFunction): void {
const rol = req.usuarioToken?.rol
if (!rol || !ROLES_EDITAN_PLAN.includes(rol)) {
throw new AppError(403, 'Solo admin, manager o dirección pueden modificar el plan')
}
next()
}
const actualizarPlanSchema = z.object({
// Tasa mensual en % sobre el restante de cada pago vencido (0 = sin mora)
tasaMoraMensual: z
.number()
.min(0, 'La tasa de mora no puede ser negativa')
.max(100, 'La tasa de mora mensual no puede exceder 100%'),
})
/**
* PATCH /api/planes/:id — actualiza la tasa de mora mensual del plan.
* (El calendario de pagos no se edita: cambiarlo requiere recrear el plan.)
*/
export async function actualizarPlan(req: Request, res: Response): Promise<void> {
const body = actualizarPlanSchema.parse(req.body)
const plan = await cargarPlan(String(req.params['id']))
const actualizado = await prisma.planPago.update({
where: { id: plan.id },
data: { tasaMoraMensual: body.tasaMoraMensual },
include: includeCompleto,
})
res.json(conCalculados(aDominio(actualizado)))
}

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const svcMock = vi.hoisted(() => ({
crear: vi.fn(),
listar: vi.fn(),
listarPorCliente: vi.fn(),
completar: vi.fn(),
eliminar: vi.fn(),
proximos: vi.fn(),
@@ -72,6 +73,33 @@ describe('recordatorio-vendedor.controller', () => {
expect(res.json).toHaveBeenCalledWith([])
})
it('bitácora: con leadId devuelve las notas de TODOS los usuarios (listarPorCliente)', async () => {
svcMock.listarPorCliente.mockResolvedValue([{ id: 'n1', titulo: 'Nota de otro asesor' }])
const res = resJson()
await controller.listar(
reqConToken('u-1', { query: { leadId: 'loc_9' } }),
res as never,
)
expect(svcMock.listarPorCliente).toHaveBeenCalledWith({ leadId: 'loc_9', clienteClave: null })
expect(svcMock.listar).not.toHaveBeenCalled()
expect(res.json).toHaveBeenCalledWith([{ id: 'n1', titulo: 'Nota de otro asesor' }])
})
it('bitácora: con clienteClave (y sin leadId) también usa listarPorCliente', async () => {
svcMock.listarPorCliente.mockResolvedValue([])
const res = resJson()
await controller.listar(
reqConToken('u-1', { query: { clienteClave: 'ana prueba|4771234567' } }),
res as never,
)
expect(svcMock.listarPorCliente).toHaveBeenCalledWith({ leadId: null, clienteClave: 'ana prueba|4771234567' })
expect(svcMock.listar).not.toHaveBeenCalled()
})
it('completar: llama al service con el id', async () => {
svcMock.completar.mockResolvedValue({ id: 'r1', completado: true })
const res = resJson()

View File

@@ -39,6 +39,17 @@ export async function listarRecordatoriosEndpoint(req: Request, res: Response):
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida')
// Bitácora: cuando piden las notas de un lead/cliente concreto, se devuelven
// TODAS las notas de ese cliente (de todos los usuarios). Así cualquier
// usuario que abra la bitácora ve el seguimiento completo, no sólo las
// notas que él mismo escribió.
const leadId = (req.query['leadId'] as string) ?? null
const clienteClave = (req.query['clienteClave'] as string) ?? null
if (leadId !== null || clienteClave !== null) {
res.json(await recordatorioVendedorService.listarPorCliente({ leadId, clienteClave }))
return
}
const completadosStr = req.query['completados'] as string | undefined
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null; clienteClave?: string | null } = {
desde: (req.query['desde'] as string) ?? null,
@@ -89,6 +100,14 @@ export function crearController(svc: RecordatorioVendedorService) {
listar: async (req: Request, res: Response): Promise<void> => {
const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida')
// Bitácora: con leadId/clienteClave devuelve las notas de TODOS los
// usuarios (el seguimiento del cliente es compartido)
const leadId = (req.query['leadId'] as string) ?? null
const clienteClave = (req.query['clienteClave'] as string) ?? null
if (leadId !== null || clienteClave !== null) {
res.json(await svc.listarPorCliente({ leadId, clienteClave }))
return
}
const completadosStr = req.query['completados'] as string | undefined
const opts: {
completados?: boolean

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { exigirRolCobranza } from './recordatorios.controller.js'
describe('exigirRolCobranza (motor de recordatorios)', () => {
it('permite los roles con el módulo de cobranza', () => {
for (const rol of [
'admin',
'manager',
'administracion_venta',
'direccion_general',
'director_ventas',
'escrituracion',
'coordinador', // legacy → director_ventas
'corporativo', // legacy → director_ventas
]) {
expect(() => exigirRolCobranza(rol)).not.toThrow()
}
})
it('rechaza al vendedor (no tiene el módulo) y a "cobranza", que es módulo y no rol', () => {
expect(() => exigirRolCobranza('vendedor')).toThrow('Tu rol no tiene acceso a la cobranza')
expect(() => exigirRolCobranza('cobranza')).toThrow('Tu rol no tiene acceso a la cobranza')
})
it('sin token (AUTH_ENABLED=false) no bloquea el modo sin autenticación', () => {
expect(() => exigirRolCobranza(undefined)).not.toThrow()
})
})

View File

@@ -3,11 +3,35 @@ import { prisma } from '../services/prisma.js'
import { recordatoriosCobranzaService } from '../services/recordatorios-cobranza.service.js'
import { AppError } from '../utils/errors.js'
// Roles que pueden disparar una pasada manual del motor de recordatorios
const ROLES_RUN: readonly string[] = ['admin', 'cobranza']
// Roles del ecosistema cuyo perfil incluye el módulo de cobranza (matriz de
// config/roles.ts): los que pueden ver y disparar el motor de recordatorios.
// 'cobranza' es un MÓDULO, no un rol: no existe como rol de usuario.
const ROLES_COBRANZA: readonly string[] = [
'admin',
'manager',
'administracion_venta',
'direccion_general',
'director_ventas',
'escrituracion',
// Roles legacy (se homologan a director_ventas en la matriz)
'coordinador',
'corporativo',
]
/**
* Exige rol con cobranza. Sin token (AUTH_ENABLED=false) se permite, igual que
* el resto de la API en ese modo.
*/
export function exigirRolCobranza(rol: string | undefined): void {
if (!rol) return
if (!ROLES_COBRANZA.includes(rol)) {
throw new AppError(403, 'Tu rol no tiene acceso a la cobranza')
}
}
/** GET /api/cobranza/recordatorios — historial del log (más recientes primero) */
export async function listarRecordatorios(req: Request, res: Response): Promise<void> {
exigirRolCobranza(req.usuarioToken?.rol)
const limite = Math.min(Number(req.query['limite'] ?? 200) || 200, 500)
const registros = await prisma.recordatorioLog.findMany({
orderBy: { createdAt: 'desc' },
@@ -17,15 +41,12 @@ export async function listarRecordatorios(req: Request, res: Response): Promise<
}
/**
* POST /api/cobranza/recordatorios/run — pasada manual del motor (admin/cobranza).
* Con ?dry=1 solo lista lo que enviaría, sin mandar nada ni escribir en el log.
* POST /api/cobranza/recordatorios/run — pasada manual del motor (roles con
* cobranza). Con ?dry=1 solo lista lo que enviaría, sin mandar nada ni escribir
* en el log.
*/
export async function correrRecordatorios(req: Request, res: Response): Promise<void> {
const token = req.usuarioToken
// Sin token (AUTH_ENABLED=false) se permite; con token se exige rol admin/cobranza
if (token && !ROLES_RUN.includes(token.rol)) {
throw new AppError(403, 'Solo admin o cobranza pueden ejecutar el motor de recordatorios')
}
exigirRolCobranza(req.usuarioToken?.rol)
const dryRun = req.query['dry'] === '1'
res.json(await recordatoriosCobranzaService.ejecutarPasada({ dryRun }))
}

View File

@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
const prismaMock = vi.hoisted(() => ({
reglaCotizadorDesarrollo: { findMany: vi.fn() },
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { reglasCotizadorEndpoint } from './reglas-cotizador.controller.js'
import { DESARROLLOS } from '../services/cotizacion.service.js'
function resFake() {
const json = vi.fn()
return { res: { json } as unknown as Response, json }
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
})
describe('GET /api/desarrollos/reglas-cotizador', () => {
it('devuelve el mapa reglasPorDesarrollo con las reglas efectivas', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
{
desarrolloId: 'SENDA',
parametrosJson: JSON.stringify(DESARROLLOS['SENDA']),
activoEnCotizador: false,
},
])
const { res, json } = resFake()
await reglasCotizadorEndpoint({} as Request, res)
const cuerpo = json.mock.calls[0]?.[0] as {
reglasPorDesarrollo: Record<string, { activoEnCotizador?: boolean; propuestas: unknown[] }>
}
expect(Object.keys(cuerpo.reglasPorDesarrollo).sort()).toEqual(Object.keys(DESARROLLOS).sort())
expect(cuerpo.reglasPorDesarrollo['SENDA']?.activoEnCotizador).toBe(false)
expect(cuerpo.reglasPorDesarrollo['SENDA']?.propuestas).toHaveLength(4)
// Los desarrollos sin reglas guardadas salen con sus constantes
expect(cuerpo.reglasPorDesarrollo['DIAGONAL']).toEqual(DESARROLLOS['DIAGONAL'])
})
})

View File

@@ -0,0 +1,14 @@
import type { Request, Response } from 'express'
import { getReglasEfectivas } from '../services/reglas-desarrollo.service.js'
/**
* GET /api/desarrollos/reglas-cotizador — reglas comerciales EFECTIVAS por
* desarrollo (las configuradas por el admin en la BD, con respaldo en las
* constantes históricas para los desarrollos sin reglas guardadas).
*
* Autenticado y disponible para cualquier rol que cotice (el middleware de auth
* cubre todo /api salvo las rutas públicas). Devuelve el mapa `reglasPorDesarrollo`.
*/
export async function reglasCotizadorEndpoint(_req: Request, res: Response): Promise<void> {
res.json({ reglasPorDesarrollo: await getReglasEfectivas() })
}

View File

@@ -0,0 +1,478 @@
import { Readable } from 'node:stream'
import fs from 'node:fs'
import path from 'node:path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// UPLOADS_DIR propio del test: se fija antes de que config lo lea (dotenv no
// pisa una variable ya definida), así la carpeta queda aislada del repo.
const uploadsDir = vi.hoisted(() => {
const dir = `${process.env['TMPDIR'] ?? '/tmp'}/frasa-soporte-test-${Date.now()}`
process.env['UPLOADS_DIR'] = dir
return dir
})
const prismaMock = vi.hoisted(() => ({
solicitudSoporte: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
update: vi.fn(),
},
}))
const auditoriaMock = vi.hoisted(() => ({ registrar: vi.fn() }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
vi.mock('../services/auditoria.service.js', () => auditoriaMock)
import { config } from '../config/index.js'
import {
cambiarEstadoSolicitudEndpoint,
crearSolicitudSoporteEndpoint,
descargarArchivoSoporteEndpoint,
listarSolicitudesSoporteEndpoint,
requireAdminOManager,
uploadArchivosSoporte,
} from './soporte.controller.js'
// ── Helpers ──────────────────────────────────────────────────────────
type ReqConId = Request & { solicitudSoporteId?: string }
function reqFake(overrides: Partial<ReqConId> = {}): ReqConId {
return {
body: {},
params: {},
query: {},
usuarioToken: { sub: 'u-1', username: 'vendedor', rol: 'vendedor' },
...overrides,
} as ReqConId
}
function resFake() {
const json = vi.fn()
const status = vi.fn().mockReturnValue({ json })
const setHeader = vi.fn()
const sendFile = vi.fn()
return {
res: { json, status, setHeader, sendFile } as unknown as Response,
status,
json,
setHeader,
sendFile,
}
}
function filaFake(overrides: Record<string, unknown> = {}) {
return {
id: 'sol-1',
usuarioId: 'u-1',
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
responsable: null,
fechaSolicitud: new Date('2026-09-21T00:00:00.000Z'),
fechaLimite: null,
notas: null,
archivos: null,
solicitante: 'Vendedor',
solicitanteEmail: null,
estado: 'ABIERTA',
createdAt: new Date('2026-09-21T00:00:00.000Z'),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...overrides,
}
}
/** Request multipart real (stream) para ejercitar el middleware de multer */
function reqMultipart(partes: Array<{ name: string; filename?: string; tipo: string; contenido: string }>): ReqConId {
const boundary = '----frasaTestBoundary'
const trozos = partes.map((p) => {
const disposicion = p.filename
? `Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n`
: `Content-Disposition: form-data; name="${p.name}"\r\n`
return Buffer.from(
`--${boundary}\r\n${disposicion}Content-Type: ${p.tipo}\r\n\r\n${p.contenido}\r\n`,
'binary',
)
})
const cuerpo = Buffer.concat([...trozos, Buffer.from(`--${boundary}--\r\n`, 'binary')])
const req = Readable.from([cuerpo]) as unknown as ReqConId
// type-is (usado por multer) exige content-type + content-length para
// considerar la petición "multipart con body"
req.headers = {
'content-type': `multipart/form-data; boundary=${boundary}`,
'content-length': String(cuerpo.length),
} as ReqConId['headers']
return req
}
const bodyValido = {
titulo: 'Impresora',
descripcion: 'No imprime',
departamento: 'Sistemas',
prioridad: 'Media',
fechaSolicitud: '2026-09-21',
email: 'vendedor@frasa.mx',
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.solicitudSoporte.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) =>
filaFake(data),
)
prismaMock.solicitudSoporte.update.mockImplementation(async ({ data }: { data: Record<string, unknown> }) =>
filaFake(data),
)
})
afterAll(() => {
fs.rmSync(uploadsDir, { recursive: true, force: true })
})
// ── Subida real de adjuntos (multer + carpeta por solicitud) ──────────
describe('uploadArchivosSoporte', () => {
function subir(req: ReqConId): Promise<Error | null> {
return new Promise((resolve) => {
uploadArchivosSoporte(req, {} as Response, (err?: unknown) => resolve((err as Error) ?? null))
})
}
it('guarda el archivo en uploads/soporte/<idSolicitud>/ conservando el nombre', async () => {
const req = reqMultipart([
{ name: 'archivos', filename: 'carta poder.pdf', tipo: 'application/pdf', contenido: '%PDF-1.4 hola' },
])
req.solicitudSoporteId = 'sol-abc'
expect(await subir(req)).toBeNull()
const archivos = req.files as Express.Multer.File[]
expect(archivos).toHaveLength(1)
const relativa = path.relative(config.uploadsDir, archivos[0]!.path)
expect(relativa.startsWith(`soporte${path.sep}sol-abc${path.sep}`)).toBe(true)
expect(relativa.endsWith('-carta poder.pdf')).toBe(true)
expect(fs.readFileSync(archivos[0]!.path, 'utf8')).toBe('%PDF-1.4 hola')
})
it('rechaza tipos no permitidos (texto plano)', async () => {
const req = reqMultipart([
{ name: 'archivos', filename: 'notas.txt', tipo: 'text/plain', contenido: 'hola' },
])
req.solicitudSoporteId = 'sol-txt'
const err = await subir(req)
expect(err).toBeInstanceOf(Error)
expect(err?.message).toContain('Tipo de archivo no permitido')
})
it('si multer corta (demasiados archivos) borra la carpeta de la solicitud', async () => {
const partes = Array.from({ length: 11 }, (_, i) => ({
name: 'archivos',
filename: `doc-${i}.pdf`,
tipo: 'application/pdf',
contenido: 'pdf',
}))
const req = reqMultipart(partes)
req.solicitudSoporteId = 'sol-limite'
const err = await subir(req)
expect(err).toBeInstanceOf(Error)
expect(err?.message).toContain('Too many files')
expect(fs.existsSync(path.join(config.uploadsDir, 'soporte', 'sol-limite'))).toBe(false)
})
it('sanea el nombre original (no permite rutas)', async () => {
const req = reqMultipart([
{ name: 'archivos', filename: '../../evil.png', tipo: 'image/png', contenido: 'PNG' },
])
req.solicitudSoporteId = 'sol-evil'
expect(await subir(req)).toBeNull()
const archivos = req.files as Express.Multer.File[]
const relativa = path.relative(config.uploadsDir, archivos[0]!.path)
expect(relativa).not.toContain('..')
expect(relativa.startsWith(`soporte${path.sep}sol-evil${path.sep}`)).toBe(true)
})
})
// ── POST /api/soporte ────────────────────────────────────────────────
describe('crearSolicitudSoporteEndpoint', () => {
it('201 guardando las rutas relativas de los adjuntos subidos', async () => {
const solicitado = 'a1b2c3d4-factura.pdf'
const rutaAbsoluta = path.join(config.uploadsDir, 'soporte', 'sol-1', solicitado)
fs.mkdirSync(path.dirname(rutaAbsoluta), { recursive: true })
fs.writeFileSync(rutaAbsoluta, 'pdf')
const req = reqFake({
body: bodyValido,
solicitudSoporteId: 'sol-1',
files: [
{
path: rutaAbsoluta,
originalname: 'factura.pdf',
mimetype: 'application/pdf',
size: 3,
} as Express.Multer.File,
],
})
const { res, status, json } = resFake()
await crearSolicitudSoporteEndpoint(req, res)
expect(status).toHaveBeenCalledWith(201)
const data = prismaMock.solicitudSoporte.create.mock.calls[0]![0] as { data: Record<string, unknown> }
expect(data.data['id']).toBe('sol-1')
expect(JSON.parse(String(data.data['archivos']))).toEqual([
path.join('soporte', 'sol-1', solicitado),
])
expect(data.data['solicitanteEmail']).toBe('vendedor@frasa.mx')
expect(json).toHaveBeenCalled()
expect(auditoriaMock.registrar).toHaveBeenCalled()
})
it('si el body no valida borra la carpeta de adjuntos y propaga el error', async () => {
const carpeta = path.join(config.uploadsDir, 'soporte', 'sol-mala')
fs.mkdirSync(carpeta, { recursive: true })
fs.writeFileSync(path.join(carpeta, 'x.pdf'), 'pdf')
const req = reqFake({ body: { ...bodyValido, fechaSolicitud: '' }, solicitudSoporteId: 'sol-mala' })
await expect(crearSolicitudSoporteEndpoint(req, resFake().res)).rejects.toThrow()
expect(fs.existsSync(carpeta)).toBe(false)
expect(prismaMock.solicitudSoporte.create).not.toHaveBeenCalled()
})
it('acepta JSON sin adjuntos (compatibilidad) y guarda archivos: null', async () => {
const req = reqFake({ body: bodyValido, solicitudSoporteId: 'sol-json' })
const { res, status } = resFake()
await crearSolicitudSoporteEndpoint(req, res)
expect(status).toHaveBeenCalledWith(201)
const data = prismaMock.solicitudSoporte.create.mock.calls[0]![0] as { data: Record<string, unknown> }
expect(data.data['archivos']).toBeNull()
})
})
// ── GET /api/soporte (visibilidad + filtros) ─────────────────────────
describe('listarSolicitudesSoporteEndpoint', () => {
it('el manager ve TODAS las solicitudes (sin filtro por usuario)', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
const { res } = resFake()
await listarSolicitudesSoporteEndpoint(
reqFake({ usuarioToken: { sub: 'm-1', username: 'manager', rol: 'manager' } }),
res,
)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({
where: {},
orderBy: { createdAt: 'desc' },
})
})
it('el admin ve TODAS las solicitudes', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
await listarSolicitudesSoporteEndpoint(
reqFake({ usuarioToken: { sub: 'a-1', username: 'admin', rol: 'admin' } }),
resFake().res,
)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: {} }),
)
})
it('el vendedor solo ve las suyas', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
await listarSolicitudesSoporteEndpoint(reqFake({ usuarioToken: { sub: 'v-9', username: 'v', rol: 'vendedor' } }), resFake().res)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({
where: { usuarioId: 'v-9' },
orderBy: { createdAt: 'desc' },
})
})
it('aplica filtros de estado y departamento', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
await listarSolicitudesSoporteEndpoint(
reqFake({
query: { estado: 'EN_PROCESO', departamento: 'Cobranza' },
usuarioToken: { sub: 'm-1', username: 'manager', rol: 'manager' },
}),
resFake().res,
)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith({
where: { estado: 'EN_PROCESO', departamento: 'Cobranza' },
orderBy: { createdAt: 'desc' },
})
})
it('ignora filtros vacíos y rechaza estados inválidos', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([])
await listarSolicitudesSoporteEndpoint(
reqFake({ query: { estado: '', departamento: '' } }),
resFake().res,
)
expect(prismaMock.solicitudSoporte.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { usuarioId: 'u-1' } }),
)
await expect(
listarSolicitudesSoporteEndpoint(reqFake({ query: { estado: 'LOQUESEA' } }), resFake().res),
).rejects.toThrow()
})
it('serializa archivos JSON a array', async () => {
prismaMock.solicitudSoporte.findMany.mockResolvedValue([
filaFake({ archivos: JSON.stringify(['soporte/sol-1/a.pdf']) }),
])
const { res, json } = resFake()
await listarSolicitudesSoporteEndpoint(reqFake(), res)
expect((json.mock.calls[0]![0] as Array<{ archivos: string[] }>)[0]!.archivos).toEqual(['soporte/sol-1/a.pdf'])
})
})
// ── GET /api/soporte/:id/archivos/:indice ────────────────────────────
describe('descargarArchivoSoporteEndpoint', () => {
function crearArchivo(relativa: string, contenido = 'contenido'): string {
const absoluta = path.join(config.uploadsDir, relativa)
fs.mkdirSync(path.dirname(absoluta), { recursive: true })
fs.writeFileSync(absoluta, contenido)
return absoluta
}
it('el dueño descarga su adjunto', async () => {
const absoluta = crearArchivo(path.join('soporte', 'sol-1', 'a1-doc.pdf'))
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ usuarioId: 'u-1', archivos: JSON.stringify([path.relative(config.uploadsDir, absoluta)]) }),
)
const { res, setHeader, sendFile } = resFake()
await descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), res)
expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf')
expect(sendFile).toHaveBeenCalledWith(absoluta)
})
it('el manager descarga el adjunto de otro usuario', async () => {
const absoluta = crearArchivo(path.join('soporte', 'sol-1', 'a1-doc.png'))
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ usuarioId: 'otro', archivos: JSON.stringify([path.relative(config.uploadsDir, absoluta)]) }),
)
const { res, sendFile } = resFake()
await descargarArchivoSoporteEndpoint(
reqFake({
params: { id: 'sol-1', indice: '0' },
usuarioToken: { sub: 'm-1', username: 'm', rol: 'manager' },
}),
res,
)
expect(sendFile).toHaveBeenCalledWith(absoluta)
})
it('403 si la solicitud es de otro usuario y no es admin/manager', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ usuarioId: 'otro', archivos: JSON.stringify(['soporte/otro/x.pdf']) }),
)
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), resFake().res),
).rejects.toThrow('Sin permiso')
})
it('404 si la solicitud o el índice no existen', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(null)
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'nope', indice: '0' } }), resFake().res),
).rejects.toThrow('Solicitud no encontrada')
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ archivos: JSON.stringify(['soporte/sol-1/a.pdf']) }),
)
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '5' } }), resFake().res),
).rejects.toThrow('Archivo no encontrado')
})
it('400 si la ruta guardada apunta fuera de uploads (path traversal)', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(
filaFake({ archivos: JSON.stringify(['../../etc/passwd']) }),
)
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: '0' } }), resFake().res),
).rejects.toThrow('Ruta de archivo inválida')
})
it('400 si el índice no es un entero válido', async () => {
await expect(
descargarArchivoSoporteEndpoint(reqFake({ params: { id: 'sol-1', indice: 'x' } }), resFake().res),
).rejects.toThrow('Índice de archivo inválido')
})
})
// ── PATCH /api/soporte/:id ───────────────────────────────────────────
describe('cambiarEstadoSolicitudEndpoint', () => {
it('cambia el estado y deja auditoría', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(filaFake({ estado: 'ABIERTA' }))
const { res, json } = resFake()
await cambiarEstadoSolicitudEndpoint(
reqFake({
params: { id: 'sol-1' },
body: { estado: 'EN_PROCESO' },
usuarioToken: { sub: 'm-1', username: 'm', rol: 'manager' },
}),
res,
)
expect(prismaMock.solicitudSoporte.update).toHaveBeenCalledWith({
where: { id: 'sol-1' },
data: { estado: 'EN_PROCESO' },
})
expect(json).toHaveBeenCalledWith(expect.objectContaining({ estado: 'EN_PROCESO' }))
expect(auditoriaMock.registrar).toHaveBeenCalledWith(
'm-1',
'CAMBIAR_ESTADO_SOPORTE',
'soporte',
'sol-1',
'ABIERTA→EN_PROCESO',
)
})
it('404 si la solicitud no existe', async () => {
prismaMock.solicitudSoporte.findUnique.mockResolvedValue(null)
await expect(
cambiarEstadoSolicitudEndpoint(reqFake({ params: { id: 'nope' }, body: { estado: 'CERRADA' } }), resFake().res),
).rejects.toThrow('Solicitud no encontrada')
})
it('400 si el estado no es válido', async () => {
await expect(
cambiarEstadoSolicitudEndpoint(reqFake({ params: { id: 'sol-1' }, body: { estado: 'VOLANDO' } }), resFake().res),
).rejects.toThrow()
})
})
describe('requireAdminOManager', () => {
it('deja pasar a admin y manager', () => {
for (const rol of ['admin', 'manager']) {
const next = vi.fn()
requireAdminOManager(reqFake({ usuarioToken: { sub: 'x', username: 'x', rol } }), resFake().res, next)
expect(next).toHaveBeenCalled()
}
})
it('rechaza a un vendedor', () => {
expect(() =>
requireAdminOManager(reqFake(), resFake().res, vi.fn()),
).toThrow('Se requiere rol admin o manager')
})
})

View File

@@ -1,21 +1,44 @@
import type { Request, Response } from 'express'
import type { NextFunction, Request, Response } from 'express'
import fs from 'node:fs'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
import multer from 'multer'
import { z } from 'zod'
import { config } from '../config/index.js'
import { prisma } from '../services/prisma.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js'
import * as auditoria from '../services/auditoria.service.js'
/**
* Módulo Soporte: solicitudes dirigidas a los departamentos internos
* (Sistemas, Administración, Ventas, Cobranza, Escrituración, Dirección).
*
* - POST /api/soporte — crear solicitud (cualquier usuario autenticado).
* - GET /api/soporte — listar solicitudes: propias para cualquier usuario,
* TODAS para admin (con filtro opcional ?estado=ABIERTA|EN_PROCESO|CERRADA).
* - POST /api/soporte — crear solicitud (cualquier usuario autenticado). Acepta
* multipart con los adjuntos reales en el campo `archivos` (PDF/JPG/PNG,
* máx 10 MB c/u, hasta 10) o JSON sin adjuntos.
* - GET /api/soporte — listar solicitudes: propias para un vendedor, TODAS
* para admin y manager, con filtros opcionales ?estado= y ?departamento=.
* - GET /api/soporte/:id/archivos/:indice — descargar un adjunto (dueño,
* admin o manager).
* - PATCH /api/soporte/:id — cambiar el estado (solo admin/manager).
*/
const DEPARTAMENTOS_SOPORTE = ['Sistemas', 'Administración', 'Ventas', 'Cobranza', 'Escrituración', 'Dirección'] as const
const PRIORIDADES_SOPORTE = ['Baja', 'Media', 'Alta', 'Urgente'] as const
const ESTADOS_SOPORTE = ['ABIERTA', 'EN_PROCESO', 'CERRADA'] as const
const MIME_PERMITIDOS_SOPORTE = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'])
const EXT_POR_MIME_SOPORTE: Record<string, string> = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
}
const MAX_ARCHIVOS_SOPORTE = 10
const MAX_BYTES_SOPORTE = 10 * 1024 * 1024
const crearSolicitudSchema = z.object({
titulo: z.string().trim().min(1, 'El título es obligatorio'),
descripcion: z.string().trim().min(1, 'La descripción es obligatoria'),
@@ -29,16 +52,104 @@ const crearSolicitudSchema = z.object({
// username del token si viene vacío
solicitante: z.string().trim().min(1).optional(),
email: z.string().trim().email('Email inválido').optional(),
// Rutas/nombres de archivos adjuntos (sin endpoint de upload: el front manda
// nombres; se guardan como JSON array)
// Rutas relativas de adjuntos YA subidos; con multipart se ignoran y se toman
// los archivos reales que entregó multer (nadie declara rutas a mano)
archivos: z.array(z.string().trim().min(1)).optional(),
})
const listarSolicitudSchema = z.object({
estado: z.enum(ESTADOS_SOPORTE).optional(),
// '' = sin filtro: el front a veces manda el select vacío
estado: z.enum(ESTADOS_SOPORTE).or(z.literal('')).optional(),
departamento: z.enum(DEPARTAMENTOS_SOPORTE).or(z.literal('')).optional(),
})
/** Serializa la fila para el front: archivos JSON → array (tolerante a basura) */
const cambiarEstadoSchema = z.object({
estado: z.enum(ESTADOS_SOPORTE),
})
/** Request con el id de la solicitud ya reservado para la carpeta de adjuntos */
type RequestConIdSoporte = Request & { solicitudSoporteId?: string }
/**
* Id de la solicitud en curso. Se reserva ANTES de multer para que los adjuntos
* aterricen en uploads/soporte/<id>/ con el mismo id que tendrá la fila.
*/
function idSolicitudDe(req: Request): string {
const conId = req as RequestConIdSoporte
conId.solicitudSoporteId ??= randomUUID()
return conId.solicitudSoporteId
}
/** Carpeta de adjuntos de una solicitud: uploads/soporte/<id>/ */
function carpetaSoporte(id: string): string {
return path.join(config.uploadsDir, 'soporte', id)
}
/** Nombre seguro y único: uuid corto + nombre original saneado (sin rutas) */
function nombreSeguro(original: string): string {
const base = path.basename(original).replace(/[^\w.\- ]+/g, '_').slice(-80).trim()
return base.length > 0 ? base : 'archivo'
}
const storageSoporte = multer.diskStorage({
destination: (req, _file, cb) => {
const dir = carpetaSoporte(idSolicitudDe(req))
fs.mkdirSync(dir, { recursive: true })
cb(null, dir)
},
filename: (_req, file, cb) => {
const ext = EXT_POR_MIME_SOPORTE[file.mimetype] ?? 'bin'
const base = nombreSeguro(file.originalname)
cb(null, `${randomUUID().slice(0, 8)}-${base.endsWith(`.${ext}`) ? base : `${base}.${ext}`}`)
},
})
const multerArchivosSoporte = multer({
storage: storageSoporte,
limits: { fileSize: MAX_BYTES_SOPORTE, files: MAX_ARCHIVOS_SOPORTE },
fileFilter: (_req, file, cb) => {
if (!MIME_PERMITIDOS_SOPORTE.has(file.mimetype)) {
cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype} (solo PDF, JPG o PNG)`))
return
}
cb(null, true)
},
}).array('archivos', MAX_ARCHIVOS_SOPORTE)
/**
* Middleware de subida: recibe hasta 10 adjuntos en el campo `archivos`. Si
* multer corta a mitad (tipo no permitido, archivo > 10 MB, demasiados
* archivos) borra lo que haya quedado en la carpeta de la solicitud.
*/
export function uploadArchivosSoporte(req: Request, res: Response, next: NextFunction): void {
multerArchivosSoporte(req, res, (err?: unknown) => {
if (err) {
borrarCarpetaSoporte(idSolicitudDe(req))
next(err)
return
}
next()
})
}
/** Borra la carpeta de adjuntos de una solicitud que no llegó a crearse */
function borrarCarpetaSoporte(id: string): void {
fs.rmSync(carpetaSoporte(id), { recursive: true, force: true })
}
/** archivos JSON → array de rutas relativas (tolerante a basura) */
function parsearArchivos(archivos: string | null): string[] {
if (!archivos) return []
try {
const parseado: unknown = JSON.parse(archivos)
if (Array.isArray(parseado)) return parseado.filter((a): a is string => typeof a === 'string')
return []
} catch {
return []
}
}
/** Serializa la fila para el front: archivos JSON → array de rutas */
function aPublico(fila: {
id: string
usuarioId: string
@@ -57,24 +168,40 @@ function aPublico(fila: {
createdAt: Date
updatedAt: Date
}) {
let archivos: string[] = []
if (fila.archivos) {
try {
const parseado: unknown = JSON.parse(fila.archivos)
if (Array.isArray(parseado)) archivos = parseado.filter((a): a is string => typeof a === 'string')
} catch {
archivos = []
}
}
return { ...fila, archivos }
return { ...fila, archivos: parsearArchivos(fila.archivos) }
}
/** POST /api/soporte — crear solicitud de soporte */
/** MIME por extensión para servir el adjunto descargado */
function mimeDeArchivo(ruta: string): string {
const ext = path.extname(ruta).toLowerCase()
if (ext === '.pdf') return 'application/pdf'
if (ext === '.png') return 'image/png'
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
return 'application/octet-stream'
}
/** POST /api/soporte — crear solicitud de soporte (JSON o multipart) */
export async function crearSolicitudSoporteEndpoint(req: Request, res: Response): Promise<void> {
const body = crearSolicitudSchema.parse(req.body)
const id = idSolicitudDe(req)
let body: z.infer<typeof crearSolicitudSchema>
try {
body = crearSolicitudSchema.parse(req.body)
} catch (err) {
// Los adjuntos ya están en disco: sin solicitud que los referencie, sobran
borrarCarpetaSoporte(id)
throw err
}
const subidos = (req.files ?? []) as Express.Multer.File[]
const archivos =
subidos.length > 0
? subidos.map((f) => path.relative(config.uploadsDir, f.path))
: (body.archivos ?? [])
const usuarioId = req.usuarioToken?.sub ?? 'desconocido'
const fila = await prisma.solicitudSoporte.create({
data: {
id,
usuarioId,
titulo: body.titulo,
descripcion: body.descripcion,
@@ -84,7 +211,7 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response)
fechaSolicitud: body.fechaSolicitud,
fechaLimite: body.fechaLimite ?? null,
notas: body.notas ?? null,
archivos: body.archivos !== undefined ? JSON.stringify(body.archivos) : null,
archivos: archivos.length > 0 ? JSON.stringify(archivos) : null,
solicitante: body.solicitante ?? req.usuarioToken?.username ?? '',
solicitanteEmail: body.email ?? null,
},
@@ -93,15 +220,18 @@ export async function crearSolicitudSoporteEndpoint(req: Request, res: Response)
auditoria.registrar(usuarioId, 'CREAR_SOLICITUD_SOPORTE', 'soporte', fila.id, body.departamento)
}
/** GET /api/soporte — listar solicitudes (propias; admin ve todas) */
/**
* GET /api/soporte — listar solicitudes. Admin y manager ven TODAS (la UI les
* dice "Todas las solicitudes"); un vendedor solo ve las suyas.
*/
export async function listarSolicitudesSoporteEndpoint(req: Request, res: Response): Promise<void> {
const { estado } = listarSolicitudSchema.parse(req.query)
const { estado, departamento } = listarSolicitudSchema.parse(req.query)
const where: Record<string, unknown> = {}
// Admin ve todas; el resto solo las suyas
if (req.usuarioToken?.rol !== 'admin') {
if (!esAdminOManager(req.usuarioToken?.rol)) {
where['usuarioId'] = req.usuarioToken?.sub ?? 'desconocido'
}
if (estado !== undefined) where['estado'] = estado
if (estado) where['estado'] = estado
if (departamento) where['departamento'] = departamento
const filas = await prisma.solicitudSoporte.findMany({
where,
@@ -109,3 +239,68 @@ export async function listarSolicitudesSoporteEndpoint(req: Request, res: Respon
})
res.json(filas.map(aPublico))
}
/** Middleware: solo admin/manager pueden mover solicitudes de estado */
export function requireAdminOManager(req: Request, _res: Response, next: NextFunction): void {
if (!esAdminOManager(req.usuarioToken?.rol)) {
throw new AppError(403, 'Se requiere rol admin o manager')
}
next()
}
/** PATCH /api/soporte/:id — cambiar estado (admin/manager) */
export async function cambiarEstadoSolicitudEndpoint(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const { estado } = cambiarEstadoSchema.parse(req.body)
const existente = await prisma.solicitudSoporte.findUnique({ where: { id } })
if (!existente) throw new NotFoundError('Solicitud no encontrada')
const fila = await prisma.solicitudSoporte.update({ where: { id }, data: { estado } })
res.json(aPublico(fila))
auditoria.registrar(
req.usuarioToken?.sub ?? 'desconocido',
'CAMBIAR_ESTADO_SOPORTE',
'soporte',
fila.id,
`${existente.estado}→${estado}`,
)
}
/**
* GET /api/soporte/:id/archivos/:indice — descarga un adjunto de la solicitud.
* Solo el dueño, un admin o un manager. El índice es la posición en
* `archivos`, así el cliente nunca propone una ruta.
*/
export async function descargarArchivoSoporteEndpoint(req: Request, res: Response): Promise<void> {
const id = String(req.params['id'])
const indice = Number(req.params['indice'])
if (!Number.isInteger(indice) || indice < 0) {
throw new BadRequestError('Índice de archivo inválido')
}
const fila = await prisma.solicitudSoporte.findUnique({ where: { id } })
if (!fila) throw new NotFoundError('Solicitud no encontrada')
const propio = fila.usuarioId === (req.usuarioToken?.sub ?? 'desconocido')
if (!propio && !esAdminOManager(req.usuarioToken?.rol)) {
throw new AppError(403, 'Sin permiso para ver los adjuntos de esta solicitud')
}
const archivo = parsearArchivos(fila.archivos)[indice]
if (archivo === undefined) throw new NotFoundError('Archivo no encontrado')
// Guarda anti path-traversal: la ruta guardada debe vivir dentro de uploadsDir
const rutaAbsoluta = path.resolve(config.uploadsDir, archivo)
if (!rutaAbsoluta.startsWith(config.uploadsDir + path.sep)) {
throw new BadRequestError('Ruta de archivo inválida')
}
if (!fs.existsSync(rutaAbsoluta)) throw new NotFoundError('Archivo no encontrado en disco')
res.setHeader('Content-Type', mimeDeArchivo(rutaAbsoluta))
res.setHeader(
'Content-Disposition',
`attachment; filename="${path.basename(rutaAbsoluta).replace(/[^\w.\- ]+/g, '_')}"`,
)
res.sendFile(rutaAbsoluta)
}

View File

@@ -9,6 +9,9 @@ const prismaMock = vi.hoisted(() => ({
findMany: vi.fn(),
findUnique: vi.fn(),
},
capturaDepartamento: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -37,6 +40,7 @@ beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([filaLocal])
prismaMock.unidad.findUnique.mockResolvedValue(filaLocal)
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
})
describe('unidades.controller (local)', () => {
@@ -58,6 +62,18 @@ describe('unidades.controller (local)', () => {
})
})
it('la fecha de entrega sale de la captura del departamento (ESCRITURACION/OBRA)', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
])
const { res, json } = resFake()
await listarUnidades({ query: {} } as unknown as Request, res)
const lista = json.mock.calls[0]?.[0] as Record<string, unknown>[]
expect(lista[0]).toMatchObject({ fechaEntrega: '2027-10-08' })
})
it('los filtros ?disponibles y ?desarrollo se aplican igual', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
filaLocal,

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

@@ -124,6 +124,34 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
}
})
it('deja pasar los endpoints públicos de desarrollos (micrositio sin sesión)', () => {
const casos: Partial<Request>[] = [
{ method: 'GET', path: '/publico/desarrollos' },
{ method: 'GET', path: '/publico/desarrollos/SENDA' },
]
for (const c of casos) {
vi.clearAllMocks()
authMiddleware(reqFake(c), resFake().res, next)
expect(next).toHaveBeenCalledOnce()
}
})
it('el patrón público de desarrollos no abre el CRUD de admin', () => {
const casos: Partial<Request>[] = [
{ method: 'POST', path: '/publico/desarrollos' },
{ method: 'DELETE', path: '/publico/desarrollos/SENDA' },
{ method: 'GET', path: '/publico/desarrollos/SENDA/unidades' },
{ method: 'GET', path: '/desarrollos' },
{ method: 'GET', path: '/desarrollos/d-1' },
{ method: 'POST', path: '/desarrollos/d-1/unidades' },
]
for (const c of casos) {
const { res, status } = resFake()
authMiddleware(reqFake(c), res, next)
expect(status).toHaveBeenCalledWith(401)
}
})
it('el token interno abre solo POST /cotizaciones/alertas-inventario', () => {
authMiddleware(
reqFake({

View File

@@ -20,6 +20,10 @@ const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [
{ metodo: 'GET', patron: /^\/clientes\/publico\/[^/]+$/ },
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/firma$/ },
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/documentos$/ },
// Micrositio: fichas públicas de desarrollos y su inventario disponible
// (solo información comercial, jamás datos de clientes ni cotizaciones).
{ metodo: 'GET', patron: /^\/publico\/desarrollos$/ },
{ metodo: 'GET', patron: /^\/publico\/desarrollos\/[^/]+$/ },
]
declare module 'express-serve-static-core' {

View File

@@ -2,6 +2,7 @@ import { AppError } from '../utils/errors.js'
import { Router } from 'express'
import { health } from '../controllers/health.controller.js'
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
import { reglasCotizadorEndpoint } from '../controllers/reglas-cotizador.controller.js'
import {
alertasInventarioEndpoint,
crearCotizacion,
@@ -14,12 +15,14 @@ import {
actualizarProgreso,
completarProgreso,
crearCliente,
importarEnkontrolEndpoint,
iniciarProgreso,
listarDocumentos,
obtenerCliente,
prefillApartadoEndpoint,
subirDocumentos,
uploadDocumentos,
uploadImportacionEnkontrol,
} from '../controllers/clientes.controller.js'
import {
eliminarBorrador,
@@ -36,6 +39,7 @@ import {
uploadDocumentosPublico,
} from '../controllers/liga.controller.js'
import {
actualizarPlan,
agregarNota,
cancelarPlan,
cobranzaHoy,
@@ -43,6 +47,7 @@ import {
listarPlanes,
obtenerPlan,
registrarAbonoEndpoint,
requireEditaPlan,
timelinePlan,
} from '../controllers/planes.controller.js'
import {
@@ -54,6 +59,7 @@ import { correrRecordatorios, listarRecordatorios } from '../controllers/recorda
import {
cambiarEstadoLeadEndpoint,
crearAfluenciaEndpoint,
obtenerAfluenciaEndpoint,
crearLeadEndpoint,
buscarLeadsEndpoint,
convertirLeadEndpoint,
@@ -90,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,
@@ -148,14 +162,35 @@ import {
import {
listarEndpoint as listarMaestroEndpoint,
exportarEndpoint as exportarMaestroEndpoint,
catalogosEndpoint as catalogosMaestroEndpoint,
obtenerEndpoint as obtenerMaestroEndpoint,
importarEndpoint as importarMaestroEndpoint,
requireAdmin as requireAdminMaestro,
} from '../controllers/cliente-maestro.controller.js'
import {
cambiarEstadoSolicitudEndpoint,
crearSolicitudSoporteEndpoint,
descargarArchivoSoporteEndpoint,
listarSolicitudesSoporteEndpoint,
requireAdminOManager,
uploadArchivosSoporte,
} from '../controllers/soporte.controller.js'
import {
actualizarDesarrolloEndpoint,
actualizarModeloEndpoint,
actualizarUnidadEndpoint,
agregarModeloEndpoint,
crearDesarrolloEndpoint,
crearUnidadesEndpoint,
eliminarDesarrolloEndpoint,
eliminarModeloEndpoint,
eliminarUnidadEndpoint,
guardarReglasEndpoint,
listarDesarrollosEndpoint,
listarDesarrollosPublicosEndpoint,
obtenerDesarrolloEndpoint,
obtenerDesarrolloPublicoEndpoint,
} from '../controllers/desarrollos.controller.js'
export const apiRouter = Router()
@@ -169,6 +204,11 @@ apiRouter.post('/auth/cambiar-password', cambiarPasswordEndpoint)
apiRouter.get('/unidades', listarUnidades)
apiRouter.get('/unidades/:codigo', obtenerUnidad)
// Reglas comerciales del cotizador (BD del admin con respaldo en constantes).
// Ruta fija: debe ir ANTES de cualquier '/desarrollos/:id' que agregue el
// módulo de administración para no quedar tapada por el parámetro.
apiRouter.get('/desarrollos/reglas-cotizador', reglasCotizadorEndpoint)
apiRouter.post('/cotizaciones', crearCotizacion)
apiRouter.get('/cotizaciones', listarCotizaciones)
apiRouter.get('/cotizaciones/mias', listarMisCotizaciones)
@@ -178,6 +218,14 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
apiRouter.post('/clientes', crearCliente)
// Importación de información desde un documento de Enkontrol (ID Enkontrol =
// campo determinante). Solo administración.
apiRouter.post(
'/clientes/importar-enkontrol',
requireAdminOManager,
uploadImportacionEnkontrol.single('archivo'),
importarEnkontrolEndpoint,
)
// Prefill del alta desde un apartado existente (botón "Alta Enkontrol"
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)
@@ -229,6 +277,8 @@ apiRouter.get('/planes/:id/timeline', requireModuloCobranza, timelinePlan)
apiRouter.post('/planes/:id/abonos', requireModuloCobranza, registrarAbonoEndpoint)
apiRouter.post('/planes/:id/notas', requireModuloCobranza, agregarNota)
apiRouter.post('/planes/:id/cancelar', requireModuloCobranza, cancelarPlan)
// Editar la tasa de mora del plan: solo admin/manager/dirección
apiRouter.patch('/planes/:id', requireModuloCobranza, requireEditaPlan, actualizarPlan)
apiRouter.get('/cobranza/recordatorios', listarRecordatorios)
apiRouter.post('/cobranza/recordatorios/run', correrRecordatorios)
@@ -244,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)
@@ -275,6 +334,8 @@ apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint)
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
apiRouter.post('/afluencias', crearAfluenciaEndpoint)
// Perfil de afluencia capturado de un lead/oportunidad/apartado (bitácora de Mi día)
apiRouter.get('/afluencias/:leadId', obtenerAfluenciaEndpoint)
apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
@@ -309,9 +370,11 @@ apiRouter.post('/ai/chat/stream', chatIAStream)
apiRouter.get('/ai/conversations', listarConversaciones)
apiRouter.get('/ai/conversations/:id', obtenerConversacion)
apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
apiRouter.post('/ai/documents', subirDocumento)
apiRouter.get('/ai/documents', listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', eliminarDocumento)
// Mantenimiento de la base de conocimiento: SOLO admin (admin/manager no
// basta: borrar o inyectar documentos afecta a todos los usuarios del chat).
apiRouter.post('/ai/documents', requireAdminEstricto, subirDocumento)
apiRouter.get('/ai/documents', requireAdminEstricto, listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', requireAdminEstricto, eliminarDocumento)
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
@@ -345,11 +408,34 @@ apiRouter.post('/push/desuscribir', desuscribirEndpoint)
// ── Clientes Maestro ──────────────────────────────────────────────
apiRouter.get('/clientes-maestro', listarMaestroEndpoint)
// Exportar va ANTES de /:numCliente para no colisionar con el parámetro
// Rutas fijas ANTES de /:numCliente para no colisionar con el parámetro
apiRouter.get('/clientes-maestro/catalogos', catalogosMaestroEndpoint)
apiRouter.get('/clientes-maestro/exportar', exportarMaestroEndpoint)
apiRouter.get('/clientes-maestro/:numCliente', obtenerMaestroEndpoint)
apiRouter.post('/clientes-maestro/importar', requireAdminMaestro, importarMaestroEndpoint)
// ── Módulo Soporte: solicitudes a departamentos internos ──────────
apiRouter.post('/soporte', crearSolicitudSoporteEndpoint)
apiRouter.post('/soporte', uploadArchivosSoporte, crearSolicitudSoporteEndpoint)
apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint)
apiRouter.get('/soporte/:id/archivos/:indice', descargarArchivoSoporteEndpoint)
apiRouter.patch('/soporte/:id', requireAdminOManager, cambiarEstadoSolicitudEndpoint)
// ── Módulo Desarrollos: catálogo en BD (solo admin) ─────────────────
// Público primero (sin auth, lo consume el micrositio): ver RUTAS_PUBLICAS/
// PATRONES_PUBLICOS en middleware/auth.middleware.ts
apiRouter.get('/publico/desarrollos', listarDesarrollosPublicosEndpoint)
apiRouter.get('/publico/desarrollos/:clave', obtenerDesarrolloPublicoEndpoint)
apiRouter.get('/desarrollos', requireAdminEstricto, listarDesarrollosEndpoint)
apiRouter.post('/desarrollos', requireAdminEstricto, crearDesarrolloEndpoint)
// Rutas fijas de modelos ANTES de /desarrollos/:id para no colisionar con el parámetro
apiRouter.patch('/desarrollos/modelos/:modeloId', requireAdminEstricto, actualizarModeloEndpoint)
apiRouter.delete('/desarrollos/modelos/:modeloId', requireAdminEstricto, eliminarModeloEndpoint)
apiRouter.get('/desarrollos/:id', requireAdminEstricto, obtenerDesarrolloEndpoint)
apiRouter.patch('/desarrollos/:id', requireAdminEstricto, actualizarDesarrolloEndpoint)
apiRouter.delete('/desarrollos/:id', requireAdminEstricto, eliminarDesarrolloEndpoint)
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
apiRouter.patch('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, actualizarUnidadEndpoint)
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)

View File

@@ -2,6 +2,7 @@ import crypto from 'node:crypto'
import { prisma } from './prisma.js'
import { config } from '../config/index.js'
import { AppError, NotFoundError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js'
import { getUnidadesLocal } from './unidades-local.service.js'
import {
APARTADO_DEFAULT,
@@ -1020,10 +1021,12 @@ export function crearEjecutoresHerramientas(
})
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
const hoy = new Date()
// Medianoche de hoy: lo que vence HOY aún no está vencido
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
return planes.map((p) => {
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < hoy && x.montoPagado < x.monto)
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < inicioHoy && x.montoPagado < x.monto)
const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
const abonos = p.pagos
@@ -1054,7 +1057,8 @@ export function crearEjecutoresHerramientas(
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
prisma.pago.findMany({
where: { fechaVencimiento: { lt: new Date() }, plan: alcance },
// Sólo planes activos: el adeudo de planes cancelados no cuenta
where: { fechaVencimiento: { lt: new Date() }, plan: { ...alcance, status: 'ACTIVO' } },
select: {
monto: true,
montoPagado: true,
@@ -1505,6 +1509,18 @@ export async function chat(
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
: null
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
// Propiedad: sólo el dueño (o un rol de supervisión) puede reanudar una
// conversación existente. Sin esto, cualquiera con el uuid continuaba el
// chat ajeno con todo su historial.
if (
conversacion &&
opciones.usuarioId &&
conversacion.usuarioId &&
conversacion.usuarioId !== opciones.usuarioId &&
!esAdminOManager(opciones.contexto?.rol)
) {
throw new AppError(403, 'No puedes continuar la conversación de otro usuario')
}
conversacion ??= await prisma.aIConversacion.create({
data: { usuarioId: opciones.usuarioId ?? null },
})

View File

@@ -94,7 +94,8 @@ describe('buscar', () => {
prismaMock.usuario.findMany.mockResolvedValue([])
await buscar({ desde, hasta })
expect(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith({
where: { createdAt: { gte: desde, lte: hasta } },
// "hasta" incluye todo el día: se excluye con lt al día siguiente
where: { createdAt: { gte: desde, lt: new Date(hasta.getTime() + 24 * 60 * 60 * 1000) } },
orderBy: { createdAt: 'desc' },
take: 100,
})

View File

@@ -43,7 +43,9 @@ export async function buscar(filtros: FiltrosAuditoria, limite = 100) {
if (filtros.desde !== undefined || filtros.hasta !== undefined) {
where['createdAt'] = {
...(filtros.desde !== undefined ? { gte: filtros.desde } : {}),
...(filtros.hasta !== undefined ? { lte: filtros.hasta } : {}),
// "hasta" es inclusive de todo el día: se excluye con lt al día siguiente
// (antes con lte a las 00:00 del día se perdía casi todo el día elegido)
...(filtros.hasta !== undefined ? { lt: new Date(filtros.hasta.getTime() + 24 * 60 * 60 * 1000) } : {}),
}
}
const logs = await prisma.auditoriaLog.findMany({

View File

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

View File

@@ -13,43 +13,59 @@ export interface BuscadoItem {
* (oportunidades con cotización), y Unidad (inventario).
* Case-insensitive sobre los campos indicados; limita a `limite` resultados.
*/
export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[]> {
export async function buscar(
termino: string,
limite = 20,
alcance?: { usuarioId: string; soloPropio: boolean },
): Promise<BuscadoItem[]> {
const q = termino.trim()
if (q === '') return []
const pat = `%${q}%`
// Privacidad por vendedor: cada asesor sólo encuentra SUS leads, contactos
// y cotizaciones. Los roles de supervisión llegan sin filtro (soloPropio).
const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {}
const propioCliente = alcance?.soloPropio ? { capturadoPorId: alcance.usuarioId } : {}
const propiaCotizacion = alcance?.soloPropio ? { vendedorId: alcance.usuarioId } : {}
const [leads, clientes, cotizaciones, unidades] = await Promise.all([
prisma.leadCaptura.findMany({
where: {
OR: [
{ nombreLead: { contains: q } },
{ telefono: { contains: q } },
{ email: { contains: q } },
],
AND: [propioLead, {
OR: [
{ nombreLead: { contains: q } },
{ telefono: { contains: q } },
{ email: { contains: q } },
],
}],
},
orderBy: { updatedAt: 'desc' },
take: limite,
}),
prisma.cliente.findMany({
where: {
OR: [
{ nombreCompleto: { contains: q } },
{ curp: { contains: q } },
{ rfc: { contains: q } },
{ email: { contains: q } },
{ celular: { contains: q } },
],
AND: [propioCliente, {
OR: [
{ nombreCompleto: { contains: q } },
{ curp: { contains: q } },
{ rfc: { contains: q } },
{ email: { contains: q } },
{ celular: { contains: q } },
],
}],
},
orderBy: { createdAt: 'desc' },
take: limite,
}),
prisma.cotizacion.findMany({
where: {
OR: [
{ clienteNombre: { contains: q } },
{ clienteEmail: { contains: q } },
{ unidadCodigo: { contains: q } },
],
AND: [propiaCotizacion, {
OR: [
{ clienteNombre: { contains: q } },
{ clienteEmail: { contains: q } },
{ unidadCodigo: { contains: q } },
],
}],
},
orderBy: { createdAt: 'desc' },
take: limite,
@@ -72,14 +88,14 @@ export async function buscar(termino: string, limite = 20): Promise<BuscadoItem[
id: l.leadId,
nombre: l.nombreLead,
detalle: [l.telefono, l.email].filter(Boolean).join(' · ') || l.origen || l.board,
url: `/app/#leads`,
url: `/hoy/`,
})),
...clientes.map((c) => ({
tipo: 'contacto' as const,
id: c.id,
nombre: c.nombreCompleto,
detalle: [c.email, c.celular, c.proyecto].filter(Boolean).join(' · ') || '',
url: `/app/#clientes`,
url: `/`,
})),
...cotizaciones.map((ct) => ({
tipo: 'oportunidad' as const,

View File

@@ -193,7 +193,7 @@ describe('fechas de Obra bloqueadas', () => {
expect(prismaMock.$transaction).not.toHaveBeenCalled()
})
it('ESCRITURACION: no-admin no puede modificar una fecha ya establecida', async () => {
it('OBRA: no-admin no puede modificar la fecha de entrega programada ya establecida', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' },
])
@@ -201,11 +201,11 @@ describe('fechas de Obra bloqueadas', () => {
guardarCampo(
'SENDA',
'U-101',
'ESCRITURACION',
'OBRA',
'entrega_programada_fecha',
'2026-10-15',
'user-1',
'escrituracion',
'obra',
),
).rejects.toThrow('solo puede modificarlas un administrador')
expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled()

View File

@@ -18,6 +18,7 @@ import {
listar,
obtener,
contar,
catalogos,
exportar,
importarDesdeExcel,
} from './cliente-maestro.service.js'
@@ -49,23 +50,31 @@ describe('listar', () => {
await listar({ busqueda: 'Juan', pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: expect.arrayContaining([
{ nombre: { contains: 'Juan' } },
]),
}),
where: {
AND: [
expect.objectContaining({
OR: expect.arrayContaining([{ nombre: { contains: 'Juan' } }]),
}),
],
},
}),
)
})
it('aplica filtro de desarrollo', async () => {
it('aplica filtro de desarrollo (valor exacto o con sufijo " - ...")', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
prismaMock.clienteMaestro.count.mockResolvedValue(0)
await listar({ desarrollo: 'SENDA', pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ desarrollo: 'SENDA' }),
where: {
AND: [
{
OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }],
},
],
},
}),
)
})
@@ -77,10 +86,48 @@ describe('listar', () => {
await listar({ estatus: 'VENDIDO', pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ estatus: 'VENDIDO' }),
where: { AND: [{ estatus: 'VENDIDO' }] },
}),
)
})
it('combina búsqueda, desarrollo y estatus en la misma consulta', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
prismaMock.clienteMaestro.count.mockResolvedValue(0)
await listar({ busqueda: 'Ana', desarrollo: 'SOLE', estatus: 'ENTREGA', pagina: 1, limite: 50 })
const llamada = prismaMock.clienteMaestro.findMany.mock.calls[0]![0] as { where: { AND: unknown[] } }
expect(llamada.where.AND).toHaveLength(3)
})
it('sin filtros no arma condiciones', async () => {
prismaMock.clienteMaestro.findMany.mockResolvedValue([])
prismaMock.clienteMaestro.count.mockResolvedValue(0)
await listar({ pagina: 1, limite: 50 })
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: {} }),
)
})
})
// ── catalogos ────────────────────────────────────────────────────────
describe('catalogos', () => {
it('devuelve los desarrollos y estatus reales, sin vacíos ni nulos', async () => {
prismaMock.clienteMaestro.findMany
.mockResolvedValueOnce([{ desarrollo: 'MUTUO - EDIFICACION' }, { desarrollo: 'SOLE' }, { desarrollo: null }, { desarrollo: ' ' }])
.mockResolvedValueOnce([{ estatus: 'ENTREGA' }, { estatus: 'VENDIDO' }, { estatus: null }])
const result = await catalogos()
expect(result.desarrollos).toEqual(['MUTUO - EDIFICACION', 'SOLE'])
expect(result.estatuses).toEqual(['ENTREGA', 'VENDIDO'])
expect(prismaMock.clienteMaestro.findMany).toHaveBeenCalledWith({
distinct: ['desarrollo'],
select: { desarrollo: true },
orderBy: { desarrollo: 'asc' },
})
})
})
// ── obtener ──────────────────────────────────────────────────────────
@@ -113,7 +160,11 @@ describe('contar', () => {
prismaMock.clienteMaestro.count.mockResolvedValue(5)
expect(await contar({ desarrollo: 'SENDA' })).toBe(5)
expect(prismaMock.clienteMaestro.count).toHaveBeenCalledWith(
expect.objectContaining({ where: { desarrollo: 'SENDA' } }),
{
where: {
AND: [{ OR: [{ desarrollo: 'SENDA' }, { desarrollo: { startsWith: 'SENDA -' } }] }],
},
},
)
})
})

View File

@@ -44,26 +44,37 @@ export type ClienteMaestroDTO = {
// ── Helpers ─────────────────────────────────────────────────────────
function construirWhere(filtros: FiltrosClientesMaestro) {
const where: Record<string, unknown> = {}
const condiciones: Record<string, unknown>[] = []
if (filtros.desarrollo) {
where.desarrollo = filtros.desarrollo
const desarrollo = filtros.desarrollo?.trim()
if (desarrollo) {
// El mismo desarrollo convive con sufijos en la base ("ATELIER" y
// "ATELIER - EDIFICACION"): se acepta el valor exacto o su prefijo, así el
// filtro funciona tanto con el valor crudo como con el agrupado.
condiciones.push({
OR: [{ desarrollo }, { desarrollo: { startsWith: `${desarrollo} -` } }],
})
}
if (filtros.estatus) {
where.estatus = filtros.estatus
const estatus = filtros.estatus?.trim()
if (estatus) {
condiciones.push({ estatus })
}
if (filtros.busqueda) {
const term = filtros.busqueda.trim()
where.OR = [
{ nombre: { contains: term } },
{ apellidoPaterno: { contains: term } },
{ apellidoMaterno: { contains: term } },
{ telefono: { contains: term } },
{ numCliente: { contains: term } },
]
condiciones.push({
OR: [
{ nombre: { contains: term } },
{ apellidoPaterno: { contains: term } },
{ apellidoMaterno: { contains: term } },
{ telefono: { contains: term } },
{ numCliente: { contains: term } },
],
})
}
return where
return condiciones.length > 0 ? { AND: condiciones } : {}
}
// ── Service ─────────────────────────────────────────────────────────
@@ -102,6 +113,33 @@ export async function obtener(numCliente: string): Promise<ClienteMaestroDTO | n
return prisma.clienteMaestro.findUnique({ where: { numCliente } })
}
/**
* Catálogos reales de los filtros: los desarrollos y estatus que existen hoy en
* la base (no una lista fija). El front arma los selectores con esto.
*/
export async function catalogos(): Promise<{ desarrollos: string[]; estatuses: string[] }> {
const [desarrollos, estatuses] = await Promise.all([
prisma.clienteMaestro.findMany({
distinct: ['desarrollo'],
select: { desarrollo: true },
orderBy: { desarrollo: 'asc' },
}),
prisma.clienteMaestro.findMany({
distinct: ['estatus'],
select: { estatus: true },
orderBy: { estatus: 'asc' },
}),
])
const limpiar = (valores: Array<string | null>): string[] =>
valores.filter((v): v is string => v !== null && v.trim() !== '')
return {
desarrollos: limpiar(desarrollos.map((d) => d.desarrollo)),
estatuses: limpiar(estatuses.map((e) => e.estatus)),
}
}
/**
* Cuenta clientes con filtros.
*/

View File

@@ -8,6 +8,7 @@ import {
generarCalendario,
moraDePago,
pagadoTotal,
pagoAtrasado,
proximoVencimiento,
round2,
saldoPlan,
@@ -203,6 +204,23 @@ describe('saldo, avance y status del plan', () => {
expect(statusPlan(plan)).toBe('CANCELADO')
})
it('sin calendario capturado → SIN_PLAN, no LIQUIDADO', () => {
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 }
expect(statusPlan(sinPagos)).toBe('SIN_PLAN')
// con pagos pero todos en cero (captura incompleta): tampoco es liquidado
const enCeros: PlanPago = {
...planDePrueba(),
engancheTotal: 0,
pagos: planDePrueba().pagos.map((p) => ({ ...p, monto: 0 })),
}
expect(statusPlan(enCeros)).toBe('SIN_PLAN')
})
it('un plan SIN_PLAN no entra en la cobranza del día', () => {
const sinPagos: PlanPago = { ...planDePrueba(), pagos: [], engancheTotal: 0 }
expect(cobranzaDelDia([sinPagos], new Date('2026-02-01T00:00:00'))).toHaveLength(0)
})
it('alCorriente es falso cuando hay pagos vencidos', () => {
expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false)
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
@@ -233,10 +251,13 @@ describe('proximoVencimiento y diasAtraso', () => {
expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0)
})
it('un pago PARCIAL vencido no cuenta como atraso', () => {
it('un pago PARCIAL vencido SÍ cuenta como atraso (corrección: antes se ignoraba)', () => {
let plan = planDePrueba()
plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
// pago 1 vence 10-ene y sigue sin cubrir (abono de 100) → 22 días al 1-feb
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(22)
expect(alCorriente(plan, new Date('2026-02-01T00:00:00'))).toBe(false)
expect(pagoAtrasado(plan.pagos[0]!, new Date('2026-02-01T00:00:00'))).toBe(true)
})
})

View File

@@ -7,7 +7,8 @@ import { randomUUID } from 'node:crypto'
*/
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
/** SIN_PLAN = no hay calendario capturado (no debe confundirse con LIQUIDADO) */
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' | 'SIN_PLAN'
export interface AplicacionPago {
id: string
@@ -137,7 +138,8 @@ export function generarCalendario(
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`.
* PARCIAL (tiene abono aplicado); el atraso se señala con `pagoAtrasado`,
* `diasAtraso` y `alCorriente`, que sí lo cuentan como vencido.
*/
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
@@ -184,10 +186,10 @@ export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date(
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
}
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin cubrir) */
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
const vencidos = plan.pagos
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
.filter((p) => pagoAtrasado(p, hoy))
.sort(
(a, b) =>
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
@@ -197,15 +199,39 @@ export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
}
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
/**
* ¿El pago está atrasado? Un pago sin cubrir cuya fecha de vencimiento ya
* pasó: incluye el PARCIAL vencido (tiene abono pero no cubre el monto), que
* es un atraso real aunque `statusPago` lo siga etiquetando PARCIAL.
*/
export function pagoAtrasado(pago: Pago, hoy: Date = new Date()): boolean {
if (estaCubierto(pago)) return false
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0
}
/**
* Status derivado del plan:
* - CANCELADO: solo manual
* - SIN_PLAN: no hay calendario capturado (0 pagos o montos en 0). Distinto de
* LIQUIDADO: el adeudo real es desconocido, no saldado; queda fuera de la
* cobranza del día hasta que se capture el plan.
* - LIQUIDADO: saldo == 0
* - ACTIVO: tiene saldo pendiente
*/
export function statusPlan(plan: PlanPago): StatusPlan {
if (plan.status === 'CANCELADO') return 'CANCELADO'
if (!tieneCalendario(plan)) return 'SIN_PLAN'
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
}
/** Al corriente = sin pagos VENCIDOS */
/** ¿El plan tiene calendario capturado? (al menos un pago con monto > 0) */
export function tieneCalendario(plan: PlanPago): boolean {
return plan.pagos.some((p) => p.monto > EPS)
}
/** Al corriente = sin pagos atrasados (un PARCIAL vencido también es atraso) */
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
return !plan.pagos.some((p) => pagoAtrasado(p, hoy))
}
/**
@@ -236,10 +262,11 @@ export interface CobranzaItem {
}
/**
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual
* atraso, el vencimiento más próximo primero.
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS
* (los SIN_PLAN quedan fuera: sin calendario capturado no hay nada que
* cobrar): pagos vencidos (incluye PARCIAL vencido) más los que vencen en los
* próximos `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a
* igual atraso, el vencimiento más próximo primero.
*/
export function cobranzaDelDia(
planes: PlanPago[],

View File

@@ -48,6 +48,56 @@ export function claveTelefono(telefono: string): string {
return digitos.length >= 10 ? digitos.slice(-10) : ''
}
/**
* Clave de nombre para deduplicar/relacionar contactos que no traen teléfono ni
* email (las altas fallidas dejan un registro hueco con el mismo nombre).
* Sin acentos, sin mayúsculas y con los espacios colapsados.
*/
export function claveNombre(nombre: string): string {
return nombre
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ')
}
/**
* Aplana el JSON de perfil a un mapa campo→valor no vacío: sirve tanto para el
* `payloadJson` del alta de cliente (anidado por secciones) como para el
* `datosJson` del perfilamiento de una captura de afluencia.
*
* Mismo criterio que el reporte de clientes: gana el primer valor no vacío y se
* ignoran las secciones que repiten los mismos campos vacíos (coacreditado) o
* que no son de perfil. Así una sección posterior no pisa un dato real.
*/
export function aplanarPerfil(json: string | null | undefined): Record<string, string> {
if (!json) return {}
try {
const datos = JSON.parse(json) as Record<string, unknown>
const plano: Record<string, string> = {}
const SECCIONES_IGNORADAS = new Set(['coacreditado', 'documentos', 'firma', 'foto', 'referencias'])
const recorrer = (obj: Record<string, unknown>, raiz: boolean): void => {
for (const [k, v] of Object.entries(obj)) {
if (v === null || v === undefined) continue
if (typeof v === 'object' && !Array.isArray(v)) {
if (raiz && SECCIONES_IGNORADAS.has(k)) continue
recorrer(v as Record<string, unknown>, false)
continue
}
if (typeof v === 'object') continue
const texto = String(v)
if (texto.trim() === '') continue
if (plano[k] === undefined) plano[k] = texto
}
}
recorrer(datos, true)
return plano
} catch {
return {}
}
}
// ── Perfil Completo del Contacto (modo local) ─────────────────────────
export interface PerfilCompletoContacto {

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
cliente: { findMany: vi.fn() },
leadCaptura: { findMany: vi.fn() },
usuario: { findMany: vi.fn() },
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -13,12 +14,42 @@ beforeEach(() => {
vi.clearAllMocks()
prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', username: 'alfonso castillo' }])
})
describe('exportarCSV', () => {
it('genera CSV con encabezados cuando no hay contactos', async () => {
const csv = await exportarCSV()
expect(csv).toContain('Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad')
expect(csv).toContain('Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad,Asesor')
})
it('incluye el nombre del asesor que dio de alta el contacto', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
{
nombreCompleto: 'Juan Pérez',
celular: '4771234567',
email: null,
proyecto: 'SENDA',
createdAt: new Date('2026-09-01'),
capturadoPorId: 'u-1',
},
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
nombreLead: 'Ana López',
telefono: '6649998877',
email: null,
desarrollo: 'DIAGONAL',
etapa: 'NUEVO',
updatedAt: new Date('2026-09-05'),
usuarioId: 'u-1',
},
])
const csv = await exportarCSV()
const lineas = csv.split('\n')
expect(lineas[1]).toContain('alfonso castillo')
expect(lineas[2]).toContain('alfonso castillo')
})
it('incluye clientes y capturas en el CSV', async () => {

View File

@@ -118,8 +118,102 @@ describe('listarContactosLocal', () => {
expect(lista[0]?.id).toBe('c1')
})
it('deduplica por nombre a los registros sin teléfono ni email (gana la ficha con más datos)', async () => {
// Alta fallida: Cliente hueco (payload vacío, sin teléfono ni email) con el
// mismo nombre que su ficha real
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({
id: 'hueco',
nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA',
payloadJson: '{}',
createdAt: new Date('2026-09-11T10:00:00'),
}),
clienteFake({
id: 'completo',
nombreCompleto: 'Luis Enrique Salgado Zavaleta',
celular: '+52 5586880444',
email: 'luisenriquesalgadozavaleta@gmail.com',
curp: 'SAZL990504HDFLVS06',
rfc: 'SAZL990504KY0',
proyecto: 'SOLANA',
unidadCodigo: '9',
payloadJson: '{"datosGenerales":{"estadoCivil":"SOLTERO"}}',
}),
])
const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(1)
expect(lista[0]).toMatchObject({
id: 'completo',
nombre: 'Luis Enrique Salgado Zavaleta',
telefono: '+52 5586880444',
email: 'luisenriquesalgadozavaleta@gmail.com',
estado: 'Cliente',
})
})
it('fusiona el registro hueco aunque llegue después de la ficha completa', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({
id: 'completo',
nombreCompleto: 'GRACIELA BAUTISTA VELASCO',
celular: '+52 6641159092',
email: 'gbautista1304@gmail.com',
}),
clienteFake({ id: 'hueco', nombreCompleto: 'Graciela Bautista Velasco', payloadJson: '{}' }),
])
const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(1)
expect(lista[0]?.id).toBe('completo')
})
it('no fusiona a dos personas distintas con el mismo nombre si tienen teléfonos distintos', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c1', nombreCompleto: 'JUAN PÉREZ', celular: '4771234567' }),
clienteFake({ id: 'c2', nombreCompleto: 'JUAN PÉREZ', celular: '6649998877' }),
])
const lista = (await listarContactosLocal()).contactos
expect(lista.map((c) => c.id).sort()).toEqual(['c1', 'c2'])
})
it('un Cliente hueco se fusiona con la captura del mismo nombre (gana la que trae más datos)', async () => {
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({
id: 'hueco',
nombreCompleto: 'Guadalupe',
curp: '',
rfc: '',
payloadJson: '{}',
createdAt: new Date('2026-09-15T10:00:00'),
}),
])
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({
leadId: 'loc_g',
nombreLead: 'Guadalupe',
telefono: '+52 664 222 6090',
email: 'lupisintj1@hotmail.com',
etapa: 'Contactado',
}),
])
const lista = (await listarContactosLocal()).contactos
expect(lista).toHaveLength(1)
expect(lista[0]).toMatchObject({
id: 'loc_g',
telefono: '+52 664 222 6090',
email: 'lupisintj1@hotmail.com',
estado: 'Contactado',
})
})
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
// 101 clientes: la página 1 llena 100 y detecta que hay más
// 101 clientes: la paginación se aplica DESPUÉS de fusionar fuentes, así
// que el servicio pide el universo completo y corta en memoria
prismaMock.cliente.findMany.mockResolvedValue(
Array.from({ length: 101 }, (_, i) =>
clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }),
@@ -132,13 +226,12 @@ describe('listarContactosLocal', () => {
expect(pagina1.contactos).toHaveLength(100)
expect(pagina1.hayMas).toBe(true)
// La página 2 recibe el siguiente bloque (skip=100, take=101)
prismaMock.cliente.findMany.mockResolvedValue([
clienteFake({ id: 'c101', celular: '4770000100' }),
])
// La página 2 trae la fila más vieja (orden desc por fecha: la 101 va
// primero en la página 1)
const pagina2 = await listarContactosLocal(undefined, 2)
expect(pagina2.pagina).toBe(2)
expect(pagina2.contactos).toHaveLength(1)
expect(pagina2.contactos[0]?.id).toBe('c1')
expect(pagina2.hayMas).toBe(false)
})
})
@@ -233,4 +326,110 @@ describe('detalleContactoLocal', () => {
expect(detalle.relaciones.oportunidades).toHaveLength(1)
expect(detalle.historial.length).toBeGreaterThan(0)
})
it('completa el perfil con el payloadJson del alta (estado civil, nacimiento, actividad, dirección)', async () => {
prismaMock.cliente.findFirst.mockResolvedValue(
clienteFake({
id: 'c1',
nombreCompleto: 'LUIS ENRIQUE SALGADO ZAVALETA',
celular: '5586880444',
email: 'luis@correo.mx',
curp: '',
rfc: '',
payloadJson: JSON.stringify({
datosGenerales: {
estadoCivil: 'SOLTERO',
fechaNacimiento: '1999-05-04',
actividadOcupacion: 'PILOTO AVIADOR',
direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA',
},
empleo: { compania: 'FRASA', direccion: 'AV COLINA DE SAN PABLO' },
// El co-acreditado repite los campos vacíos: no debe pisar los reales
coacreditado: { tieneCoacreditado: false, generales: { estadoCivil: '', fechaNacimiento: '', actividadOcupacion: '', direccion: '' } },
}),
}),
)
const detalle = await detalleContactoLocal('c1')
expect(detalle?.contacto).toMatchObject({
estadoCivil: 'SOLTERO',
fechaNacimiento: '1999-05-04',
actividadEconomica: 'PILOTO AVIADOR',
direccion: 'C.SAN FELIPE EJIDO FRANCISCO VILLA',
})
})
it('toma el perfilamiento del datosJson de la captura cuando no hay Cliente', async () => {
const captura = capturaFake({
leadId: 'loc_1',
nombreLead: 'RAMON MORALES',
telefono: '6643535353',
email: 'ramon.morales@gmail.com',
datosJson: JSON.stringify({
edad: 41,
estadoCivil: 'Casado(a)',
ocupacion: 'empleado',
rangoSalarial: '50000',
}),
})
prismaMock.leadCaptura.findUnique.mockResolvedValue(captura)
prismaMock.leadCaptura.findMany.mockResolvedValue([captura])
const detalle = await detalleContactoLocal('loc_1')
expect(detalle?.contacto).toMatchObject({
nombre: 'RAMON MORALES',
estadoCivil: 'Casado(a)',
actividadEconomica: 'empleado',
})
// No se inventa fecha de nacimiento a partir de la edad
expect(detalle?.contacto['fechaNacimiento']).toBe('')
expect(detalle?.contacto['direccion']).toBe('')
})
it('incluye los documentos del registro hueco de la misma persona (no se pierden al fusionar)', async () => {
prismaMock.cliente.findFirst.mockResolvedValue(
clienteFake({
id: 'c1',
nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA',
celular: '6643998451',
email: 'daleman@frasa.com.mx',
}),
)
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'hueco', nombreCompleto: 'DEYSI LORENA ALEMAN FIGUEROA', celular: null, email: null },
{ id: 'otro', nombreCompleto: 'OTRA PERSONA', celular: null, email: null },
])
prismaMock.documento.findMany.mockResolvedValue([
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
])
const detalle = await detalleContactoLocal('c1')
expect(prismaMock.documento.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { clienteId: { in: ['c1', 'hueco'] } } }),
)
expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true)
})
it('si la ficha visible es la captura, también se muestran los documentos de su Cliente hueco', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(
capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }),
)
prismaMock.leadCaptura.findMany.mockResolvedValue([
capturaFake({ leadId: 'loc_g', nombreLead: 'Guadalupe', telefono: '6642226090', etapa: 'Contactado' }),
])
prismaMock.cliente.findMany.mockResolvedValue([
{ id: 'hueco', nombreCompleto: 'Guadalupe', celular: null, email: null },
])
prismaMock.documento.findMany.mockResolvedValue([
{ id: 'd1', tipo: 'INE', createdAt: new Date('2026-09-01T12:00:00') },
])
const detalle = await detalleContactoLocal('loc_g')
expect(prismaMock.documento.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { clienteId: { in: ['hueco'] } } }),
)
expect(detalle?.historial.some((h) => h.detalle === 'Documento subido (INE)')).toBe(true)
})
})

View File

@@ -1,4 +1,11 @@
import { claveTelefono, type DetalleContacto, type EventoHistorialContacto, type RelacionCrm } from './contactos-detalle.service.js'
import {
aplanarPerfil,
claveNombre,
claveTelefono,
type DetalleContacto,
type EventoHistorialContacto,
type RelacionCrm,
} from './contactos-detalle.service.js'
import { prisma } from './prisma.js'
/**
@@ -20,10 +27,10 @@ export interface ContactoListaLocal {
/**
* Lista local: Clientes (alta de clientes) + capturas del embudo en boards
* OPORTUNIDADES/APARTADO, deduplicados por teléfono (gana el Cliente: tiene la
* ficha completa), más reciente primero. Paginada de 100 en 100 (el front
* pagina con `?pagina=N` y muestra el límite). Filtra por usuario actual:
* clientes capturados por él + leads asignados a él.
* OPORTUNIDADES/APARTADO, deduplicados (gana el Cliente: tiene la ficha
* completa), más reciente primero. Paginada de 100 en 100 (el front pagina con
* `?pagina=N` y muestra el límite). Filtra por usuario actual: clientes
* capturados por él + leads asignados a él.
*/
export const CONTACTOS_POR_PAGINA = 100
@@ -50,16 +57,47 @@ export async function esContactoDelUsuario(contactoId: string, usuarioId: string
return cliente !== null || captura !== null
}
/** Candidato de la lista antes de deduplicar */
interface Candidato {
contacto: ContactoListaLocal
/** La ficha de Cliente (alta completa) gana a la captura al fusionar */
esCliente: boolean
/** Cuántos datos trae el registro: desempata la fusión de duplicados */
peso: number
}
/** Cuenta los valores no vacíos de la ficha (para saber cuál está más completa) */
function pesoDeFicha(valores: readonly (string | null | undefined)[], conPerfil: boolean): number {
const llenos = valores.filter((v) => v !== null && v !== undefined && v.trim() !== '').length
return llenos + (conPerfil ? 1 : 0)
}
/** true si el JSON trae algo más que un objeto/contenedor vacío */
function tieneDatos(json: string | null | undefined): boolean {
if (!json) return false
return Object.keys(aplanarPerfil(json)).length > 0
}
/**
* Lista de contactos locales deduplicada en dos pasos:
* 1. por teléfono (últimos 10 dígitos), como siempre;
* 2. por nombre para los registros SIN teléfono ni email — las altas fallidas
* dejan un Cliente hueco con el mismo nombre que su ficha real, y antes se
* repetía siempre porque la clave de teléfono era ''.
* Al fusionar se conserva el registro con más datos y se completan sus vacíos
* con los del otro (por teléfono gana el Cliente: es la ficha del alta), sin
* perder documentos ni relaciones: el detalle une también las dos fichas.
*/
export async function listarContactosLocal(usuarioId?: string, pagina = 1): Promise<ListaContactos> {
const porPagina = CONTACTOS_POR_PAGINA
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
const skip = (paginaValida - 1) * porPagina
// +1 por fuente para detectar si queda más (hayMas) sin una consulta extra
// La paginación se aplica DESPUÉS de fusionar las dos fuentes: antes se hacía
// skip/take por fuente y el dedupe posterior perdía contactos entre páginas
// y daba hayMas=false antes de tiempo.
const clientes = await prisma.cliente.findMany({
where: usuarioId ? { capturadoPorId: usuarioId } : {},
orderBy: { createdAt: 'desc' },
skip,
take: porPagina + 1,
})
const capturas = await prisma.leadCaptura.findMany({
where: {
@@ -67,61 +105,142 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
...(usuarioId ? { usuarioId } : {}),
},
orderBy: { updatedAt: 'desc' },
skip,
take: porPagina + 1,
})
const vistos = new Set<string>()
const lista: ContactoListaLocal[] = []
const agregar = (entrada: ContactoListaLocal): void => {
// Clave por teléfono; si no hay, por nombre + email (antes los contactos sin
// teléfono se duplicaban siempre)
const tel = claveTelefono(entrada.telefono)
const clave = tel !== ''
? tel
: `nombre:${entrada.nombre.trim().toLowerCase()}|${(entrada.email ?? '').trim().toLowerCase()}`
if (vistos.has(clave)) return
vistos.add(clave)
lista.push(entrada)
const lista: Candidato[] = []
const porTelefono = new Map<string, number>()
const porNombre = new Map<string, number>()
/**
* Deja en la posición `i` la ficha ganadora, completando sus vacíos con los
* del otro registro. Por teléfono gana el Cliente (mismo teléfono = misma
* persona, y su alta es la ficha completa); por nombre gana el que tiene más
* datos, que es el criterio de la fusión de duplicados huecos.
*/
const fusionar = (i: number, nueva: Candidato, tel: string, nombre: string, porNombreClave: boolean): void => {
const previa = lista[i]!
const ganaNueva = porNombreClave
? nueva.peso !== previa.peso
? nueva.peso > previa.peso
: nueva.esCliente
: nueva.esCliente !== previa.esCliente
? nueva.esCliente
: nueva.peso > previa.peso
const [ganadora, perdedora] = ganaNueva ? [nueva, previa] : [previa, nueva]
lista[i] = {
esCliente: ganadora.esCliente,
peso: ganadora.peso,
contacto: {
...ganadora.contacto,
nombre: ganadora.contacto.nombre.trim() !== '' ? ganadora.contacto.nombre : perdedora.contacto.nombre,
telefono: ganadora.contacto.telefono !== '' ? ganadora.contacto.telefono : perdedora.contacto.telefono,
email: ganadora.contacto.email !== '' ? ganadora.contacto.email : perdedora.contacto.email,
estado: ganadora.contacto.estado !== '' ? ganadora.contacto.estado : perdedora.contacto.estado,
},
}
if (tel !== '') porTelefono.set(tel, i)
porNombre.set(nombre, i)
}
/** ¿El registro ya agregado en esa posición no tiene teléfono ni email? */
const esHueco = (i: number): boolean => {
const c = lista[i]!.contacto
return c.telefono.trim() === '' && c.email.trim() === ''
}
const agregar = (nueva: Candidato): void => {
const tel = claveTelefono(nueva.contacto.telefono)
const nombre = claveNombre(nueva.contacto.nombre)
const email = nueva.contacto.email.trim()
const iNombre = porNombre.get(nombre)
// Sin teléfono ni email el nombre es lo único que identifica a la persona.
// Con teléfono o email sólo se une a un registro hueco del mismo nombre (la
// misma persona antes de completar su ficha), nunca a otro teléfono.
const iSinContacto =
tel === '' && email === ''
? iNombre
: iNombre !== undefined && esHueco(iNombre)
? iNombre
: undefined
const iTelefono = tel !== '' ? porTelefono.get(tel) : undefined
const i = iTelefono ?? iSinContacto
if (i !== undefined) {
fusionar(i, nueva, tel, nombre, iTelefono === undefined)
return
}
lista.push(nueva)
const indice = lista.length - 1
if (tel !== '') porTelefono.set(tel, indice)
porNombre.set(nombre, indice)
}
for (const c of clientes) {
agregar({
id: c.id,
nombre: c.nombreCompleto,
telefono: c.celular ?? '',
email: c.email ?? '',
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
updatedAt: c.createdAt.toISOString(),
fuente: 'local',
contacto: {
id: c.id,
nombre: c.nombreCompleto,
telefono: c.celular ?? '',
email: c.email ?? '',
estado: c.proyecto.trim() !== '' ? 'Cliente' : '',
updatedAt: c.createdAt.toISOString(),
fuente: 'local',
},
esCliente: true,
peso: pesoDeFicha([c.celular, c.email, c.curp, c.rfc, c.proyecto, c.unidadCodigo], tieneDatos(c.payloadJson)),
})
}
for (const c of capturas) {
agregar({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
updatedAt: c.updatedAt.toISOString(),
fuente: 'local',
contacto: {
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
email: c.email ?? '',
estado: c.etapa,
updatedAt: c.updatedAt.toISOString(),
fuente: 'local',
},
esCliente: false,
peso: pesoDeFicha([c.telefono, c.email, c.desarrollo, c.modelo], tieneDatos(c.datosJson)),
})
}
const ordenada = lista.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
const hayMas = ordenada.length > porPagina
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
return {
contactos: ordenada.slice(0, porPagina),
contactos: ordenada.slice(skip, skip + porPagina),
pagina: paginaValida,
porPagina,
hayMas,
hayMas: ordenada.length > skip + porPagina,
}
}
/**
* Clientes huecos con el mismo nombre que el contacto: un alta fallida deja un
* registro sin teléfono ni email (payload vacío) que la lista fusiona con su
* ficha real. Sólo se miran los huecos — nombre + un teléfono/email propios no
* identifican a la misma persona. `excluirId` omite el propio contacto.
*/
async function huecosConMismoNombre(nombre: string, excluirId: string | null): Promise<string[]> {
const huecos = await prisma.cliente.findMany({
where: {
AND: [
...(excluirId !== null ? [{ id: { not: excluirId } }] : []),
{ OR: [{ celular: null }, { celular: '' }] },
{ OR: [{ email: null }, { email: '' }] },
],
},
select: { id: true, nombreCompleto: true },
})
const clave = claveNombre(nombre)
return huecos.filter((c) => claveNombre(c.nombreCompleto) === clave).map((c) => c.id)
}
/**
* Detalle local del contacto. El id puede ser cliente.id, leadId (loc_* o id
* heredado) o mondayContactoId (compatibilidad de datos). Las relaciones se
* arman por teléfono (últimos 10 dígitos) o email sobre LeadCaptura; el
* historial se deriva de capturas + alta + documentos + cotizaciones locales.
* El perfil (estado civil, fecha de nacimiento, actividad económica, dirección)
* se completa con el payloadJson del alta y el datosJson de las capturas.
* null si no existe en ninguna tabla.
*/
export async function detalleContactoLocal(contactoId: string): Promise<DetalleContacto | null> {
@@ -160,8 +279,22 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
orderBy: { createdAt: 'desc' },
})
: []
const documentos = cliente
? await prisma.documento.findMany({ where: { clienteId: cliente.id }, orderBy: { createdAt: 'desc' } })
// Duplicados huecos de la misma persona (altas fallidas: mismo nombre, sin
// teléfono ni email). La lista los fusiona en una sola ficha, así que el
// detalle muestra también sus documentos para no perderlos, tanto si el
// contacto visible es el Cliente como si es la captura.
const huecos =
cliente !== null
? await huecosConMismoNombre(cliente.nombreCompleto, cliente.id)
: capturaBase !== null
? await huecosConMismoNombre(capturaBase.nombreLead, null)
: []
const idsConDocumentos = [...(cliente !== null ? [cliente.id] : []), ...huecos]
const documentos = idsConDocumentos.length
? await prisma.documento.findMany({
where: { clienteId: { in: idsConDocumentos } },
orderBy: { createdAt: 'desc' },
})
: []
// Nombres de capturistas/vendedores (ids locales → nombre para mostrar)
@@ -245,6 +378,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
})),
].sort((a, b) => b.fechaISO.localeCompare(a.fechaISO))
// Perfil del alta (payloadJson del Cliente) y de las capturas de afluencia
// (datosJson): de ahí salen estado civil, fecha de nacimiento, actividad
// económica y dirección, que no son columnas de la BD. Gana el primer valor
// no vacío, así una fuente posterior no pisa un dato real con un vacío.
const perfiles = [
aplanarPerfil(cliente?.payloadJson),
...[capturaBase, ...relacionadas].map((c) => aplanarPerfil(c?.datosJson)),
]
const dePerfil = (...claves: string[]): string => {
for (const clave of claves) {
for (const perfil of perfiles) {
const valor = (perfil[clave] ?? '').trim()
if (valor !== '') return valor
}
}
return ''
}
const contacto: Record<string, string> =
cliente !== null
? {
@@ -252,14 +403,24 @@ export async function detalleContactoLocal(contactoId: string): Promise<DetalleC
telefono: cliente.celular ?? '',
email: cliente.email ?? '',
estado: 'Cliente',
curp: cliente.curp,
rfc: cliente.rfc,
curp: cliente.curp || dePerfil('curp'),
rfc: cliente.rfc || dePerfil('rfc'),
estadoCivil: dePerfil('estadoCivil'),
fechaNacimiento: dePerfil('fechaNacimiento'),
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
direccion: dePerfil('direccion'),
}
: {
nombre: capturaBase!.nombreLead,
telefono: capturaBase!.telefono ?? '',
email: capturaBase!.email ?? '',
estado: capturaBase!.etapa,
curp: dePerfil('curp'),
rfc: dePerfil('rfc'),
estadoCivil: dePerfil('estadoCivil'),
fechaNacimiento: dePerfil('fechaNacimiento'),
actividadEconomica: dePerfil('actividadOcupacion', 'ocupacion', 'actividadEconomica'),
direccion: dePerfil('direccion'),
}
return { contacto, propiedad, relaciones, historial }
@@ -286,6 +447,9 @@ export async function exportarCSV(
},
orderBy: { updatedAt: 'desc' },
})
// Nombre del asesor que dio de alta cada contacto (para la columna Asesor)
const usuarios = await prisma.usuario.findMany({ select: { id: true, username: true } })
const nombreAsesor = new Map(usuarios.map((u) => [u.id, u.username]))
interface FilaCSV {
Nombre: string
@@ -294,15 +458,21 @@ export async function exportarCSV(
Desarrollo: string
Estatus: string
'Última actividad': string
Asesor: string
}
const filas: FilaCSV[] = []
// Dedupe en dos pasos (igual que el listado): por teléfono y, cuando no hay
// teléfono, por nombre normalizado — antes un contacto sin teléfono salía
// duplicado en el CSV.
const vistos = new Set<string>()
const claveNombre = (nombre: string): string => nombre.trim().toLowerCase().replace(/\s+/g, ' ')
for (const c of clientes) {
const clave = (c.celular ?? '').replace(/\D/g, '').slice(-10)
if (clave !== '' && vistos.has(clave)) continue
if (clave !== '') vistos.add(clave)
const claveTelefono = (c.celular ?? '').replace(/\D/g, '').slice(-10)
const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(c.nombreCompleto)}`
if (vistos.has(clave)) continue
vistos.add(clave)
filas.push({
Nombre: c.nombreCompleto,
Teléfono: c.celular ?? '',
@@ -310,13 +480,15 @@ export async function exportarCSV(
Desarrollo: c.proyecto,
Estatus: 'Cliente',
'Última actividad': c.createdAt.toISOString().slice(0, 10),
Asesor: c.capturadoPorId ? (nombreAsesor.get(c.capturadoPorId) ?? '') : '',
})
}
for (const cap of capturas) {
const clave = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
if (clave !== '' && vistos.has(clave)) continue
if (clave !== '') vistos.add(clave)
const claveTelefono = (cap.telefono ?? '').replace(/\D/g, '').slice(-10)
const clave = claveTelefono !== '' ? `tel:${claveTelefono}` : `nom:${claveNombre(cap.nombreLead)}`
if (vistos.has(clave)) continue
vistos.add(clave)
filas.push({
Nombre: cap.nombreLead,
Teléfono: cap.telefono ?? '',
@@ -324,6 +496,7 @@ export async function exportarCSV(
Desarrollo: cap.desarrollo ?? '',
Estatus: cap.etapa,
'Última actividad': cap.updatedAt.toISOString().slice(0, 10),
Asesor: cap.usuarioId ? (nombreAsesor.get(cap.usuarioId) ?? '') : '',
})
}
@@ -339,10 +512,10 @@ export async function exportarCSV(
}
// Generar CSV
const encabezado = 'Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad'
const encabezado = 'Nombre,Teléfono,Email,Desarrollo,Estatus,Última actividad,Asesor'
const escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s)
const lineas = filtradas.map((f) =>
[f.Nombre, f.Teléfono, f.Email, f.Desarrollo, f.Estatus, f['Última actividad']].map(escape).join(',')
[f.Nombre, f.Teléfono, f.Email, f.Desarrollo, f.Estatus, f['Última actividad'], f.Asesor].map(escape).join(',')
)
return [encabezado, ...lineas].join('\n')

View File

@@ -122,7 +122,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0, N = 4)', () => {
})
describe('SENDA — unidad sin fecha de entrega', () => {
it('N = null, mensualidades "—" y total pagado null', () => {
it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => {
const sinFecha = unidad({
codigo: 'SENDA-411-25-A101',
desarrollo: 'SENDA',
@@ -131,10 +131,12 @@ describe('SENDA — unidad sin fecha de entrega', () => {
precioConAcabados: 1948000,
})
const p = calcularPropuesta(sinFecha, 'BANCARIO', false, APARTADO_DEFAULT)
expect(p.numMensualidades).toBeNull()
expect(p.mensualidad).toBeNull()
expect(p.notaMensualidades).toBe('sin fecha de entrega')
expect(totalPagado(p, APARTADO_DEFAULT)).toBeNull()
// Antes salía numMensualidades/mensualidad null y "— (sin fecha de entrega)"
expect(p.numMensualidades).toBe(6) // nDefault de SENDA
expect(p.notaMensualidades).toBeUndefined()
expect(p.mensualidadesTotal).toBeCloseTo(97400, 2)
expect(p.mensualidad).toBeCloseTo(97400 / 6, 4)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2)
expect(p.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
expect(p.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
})
@@ -149,12 +151,13 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => {
mesesParaFirma: 1,
})
it('INFONAVIT con enganche 0% (guarda: no resta el apartado)', () => {
it('INFONAVIT con enganche 0%: el apartado se descuenta (no se cobra encima del precio)', () => {
const p = calcularPropuesta(casa6, 'INFONAVIT', false, APARTADO_DEFAULT)
expect(p.enganche).toBe(0)
expect(p.numMensualidades).toBe(1)
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
expect(p.mensualidadesTotal).toBeCloseTo(108000, 2)
expect(p.pagoFinal).toBeCloseTo(2432000, 2)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2560000, 2)
})
it('BANCARIO 5% sobre final', () => {
@@ -164,3 +167,82 @@ describe('SOLANA — casa 6 (L = 2,560,000, mesesParaFirma = 1)', () => {
expect(p.pagoFinal).toBeCloseTo(2304000, 2)
})
})
describe('cuadre del total: apartado y enganche personalizado', () => {
const TIPOS = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO'] as const
const CASOS = [
{ desarrollo: 'DIAGONAL', precio: 4_415_000 },
{ desarrollo: 'ATELIER', precio: 3_997_500 },
{ desarrollo: 'ESTANZA', precio: 2_437_250 },
{ desarrollo: 'SENDA', precio: 2_451_750 },
{ desarrollo: 'SOLANA', precio: 2_560_000 },
]
const unidadDe = (desarrollo: string, precio: number) =>
unidad({
codigo: `${desarrollo}-1`,
desarrollo,
precioSinAcabados: precio,
precioConAcabados: precio,
})
it('las 4 propuestas de cada desarrollo cuadran con su precio final', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
const p = calcularPropuesta(unidadDe(caso.desarrollo, caso.precio), tipo, false, APARTADO_DEFAULT)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(p.precioFinal, 2)
}
}
})
it('nunca hay bloques negativos y el total es el precio (apartados y enganches extremos)', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
for (const apartado of [0, APARTADO_DEFAULT, 200_000]) {
for (const enganchePct of [undefined, 0.05, 0.2, 0.5, 1]) {
const p = calcularPropuesta(
unidadDe(caso.desarrollo, caso.precio),
tipo,
false,
apartado,
undefined,
enganchePct,
)
expect(p.enganche).toBeGreaterThanOrEqual(0)
expect(p.mensualidadesTotal ?? 0).toBeGreaterThanOrEqual(0)
expect(p.pagoFinal).toBeGreaterThanOrEqual(0)
expect(totalPagado(p, apartado)).toBeCloseTo(p.precioFinal, 2)
}
}
}
}
})
it('enganche personalizado alto (20%) no infla el total: sigue siendo el precio', () => {
const u101 = unidad({
codigo: 'U-101',
desarrollo: 'DIAGONAL',
precioSinAcabados: 4_415_000,
precioConAcabados: 4_579_000,
})
const p = calcularPropuesta(u101, 'BANCARIO', false, APARTADO_DEFAULT, undefined, 0.2)
// Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2)
expect(p.mensualidadesTotal).toBe(0)
expect(p.pagoFinal).toBeCloseTo(4_415_000 - APARTADO_DEFAULT - p.enganche, 2)
})
it('apartado mayor al enganche: el enganche queda en 0 (nunca negativo) y el total cuadra', () => {
const u101 = unidad({
codigo: 'U-101',
desarrollo: 'DIAGONAL',
precioSinAcabados: 4_415_000,
precioConAcabados: 4_579_000,
})
// 2.5% de 4,415,000 = 110,375; con apartado 200,000 el neto sería −89,625
const p = calcularPropuesta(u101, 'BANCARIO', false, 200_000)
expect(p.enganche).toBe(0)
// El apartado de más se acredita en las mensualidades (remanente real)
expect(p.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - p.pagoFinal, 2)
expect(totalPagado(p, 200_000)).toBeCloseTo(4_415_000, 2)
})
})

View File

@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest'
import {
calcularEsquemaFrasa,
validarEsquemaInput,
PCT_ENGANCHE_FIRMA,
PCT_ENGANCHE,
PCT_ENGANCHE_MAXIMO,
PCT_ESCRITURACION,
MENSUALIDADES_DEFAULT,
MENSUALIDADES_MIN,
MENSUALIDADES_MAX,
} from './cotizacion-esquema.service.js'
/**
* Esquema único FRASA 2026: apartado libre + enganche de firma 2.5% +
* enganche 7.5% diferible + escrituración 5.5% (firma o diferida). Servicio
* puro (sin prisma): el API recalcula server-side y no confía en los montos
* del cliente. Debe dar EXACTAMENTE los mismos números que
* apps/cotizador/src/lib/esquema-frasa.ts (caso dorado del cliente).
*/
/** Caso dorado del cliente (28 sep 2026): casa de $1,000,000 */
const CASO = { precio: 1000000, apartado: 15000, numMensualidades: 14 }
describe('calcularEsquemaFrasa — caso dorado $1,000,000 / apartado $15,000', () => {
it('escrituración de contado: firma $65,000 y mensualidades de $75,000/14', () => {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: false })
expect(r.engancheFirmaTotal).toBe(25000) // 2.5%
expect(r.saldoFirma).toBe(10000) // 25,000 − 15,000 apartado acreditado
expect(r.excedenteApartado).toBe(0)
expect(r.enganche).toBe(75000) // 7.5%
expect(r.escrituracion).toBe(55000) // 5.5%
expect(r.pagoUnicoFirma).toBe(65000) // 10,000 + 55,000
expect(r.baseDiferible).toBe(75000)
expect(r.mensualidad).toBeCloseTo(75000 / 14, 2) // 5,357.14
expect(r.totalPagado).toBeCloseTo(1000000 * (PCT_ENGANCHE_FIRMA + PCT_ENGANCHE + PCT_ESCRITURACION), 2)
})
it('escrituración diferida: firma $10,000 y mensualidades de $130,000/14', () => {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: true })
expect(r.saldoFirma).toBe(10000)
expect(r.pagoUnicoFirma).toBe(10000)
expect(r.baseDiferible).toBe(130000) // 75,000 + 55,000
expect(r.mensualidad).toBeCloseTo(130000 / 14, 2) // 9,285.71
})
it('el total siempre cuadra: apartado + firma + diferible', () => {
for (const dif of [false, true]) {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: dif })
expect(r.apartado + r.pagoUnicoFirma + r.baseDiferible).toBeCloseTo(r.totalPagado, 6)
expect(r.totalPagado).toBeCloseTo(155000, 2) // 15.5% del precio
}
})
})
describe('calcularEsquemaFrasa — apartado vs enganche de firma', () => {
it('apartado exactamente el 2.5%: saldo a firma 0', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 25000, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(0)
expect(r.excedenteApartado).toBe(0)
expect(r.enganche).toBe(75000)
})
it('apartado mayor al 2.5%: el excedente se acredita al enganche (no se cobra dos veces)', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 30000, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(0)
expect(r.excedenteApartado).toBe(5000)
expect(r.enganche).toBe(70000) // 75,000 − 5,000
expect(r.totalPagado).toBeCloseTo(155000, 2) // 30k + 70k + 55k
})
it('apartado $0: el cliente paga todo el 2.5% a la firma', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 0, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(25000)
expect(r.enganche).toBe(75000)
})
})
describe('validarEsquemaInput — guardas del esquema', () => {
const valido = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
it('acepta el caso dorado', () => {
expect(validarEsquemaInput(valido)).toBeNull()
})
it('rechaza precio no positivo, apartado negativo y apartado mayor al precio', () => {
expect(validarEsquemaInput({ ...valido, precio: 0 })).toContain('precio')
expect(validarEsquemaInput({ ...valido, apartado: -1 })).toContain('apartado')
expect(validarEsquemaInput({ ...valido, apartado: 2000000 })).toContain('superar')
})
it('rechaza mensualidades fuera de rango o no enteras', () => {
expect(validarEsquemaInput({ ...valido, numMensualidades: 0 })).toContain('Mínimo')
expect(validarEsquemaInput({ ...valido, numMensualidades: 37 })).toContain('Máximo')
expect(validarEsquemaInput({ ...valido, numMensualidades: 2.5 })).toContain('entero')
})
it('calcularEsquemaFrasa lanza el mensaje de validación', () => {
expect(() => calcularEsquemaFrasa({ ...valido, numMensualidades: 0 })).toThrow('Mínimo')
})
it('constantes del esquema (globales para todos los desarrollos)', () => {
expect(PCT_ENGANCHE_FIRMA).toBe(0.025)
expect(PCT_ENGANCHE).toBe(0.075)
expect(PCT_ESCRITURACION).toBe(0.055)
expect(MENSUALIDADES_DEFAULT).toBe(14)
expect(MENSUALIDADES_MIN).toBe(1)
expect(MENSUALIDADES_MAX).toBe(36)
})
})
describe('calcularEsquemaFrasa (API) — enganche adicional (hasta 90% de anticipo)', () => {
const base = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
it('sin adicional: 0 y saldo a financiar del 84.5%', () => {
const r = calcularEsquemaFrasa(base)
expect(r.engancheAdicional).toBe(0)
expect(r.saldoAFinanciar).toBeCloseTo(845000, 2)
})
it('el adicional reduce el saldo a financiar peso a peso', () => {
const r = calcularEsquemaFrasa({ ...base, engancheAdicional: 400000 })
expect(r.saldoAFinanciar).toBeCloseTo(445000, 2)
expect(r.totalPagado).toBeCloseTo(155000, 2)
})
it('rechaza pasar del 90% de enganche total', () => {
expect(PCT_ENGANCHE_MAXIMO).toBe(0.9)
expect(() => calcularEsquemaFrasa({ ...base, engancheAdicional: 745001 })).toThrow(/90%/)
})
it('rechaza adicional negativo', () => {
expect(validarEsquemaInput({ ...base, engancheAdicional: -1 })).toMatch(/adicional/i)
})
})

View File

@@ -0,0 +1,126 @@
/**
* Esquema de cotización FRASA 2026 (flujo único, aprobado por el cliente):
*
* 1. APARTADO: cantidad libre (sin mínimo), aparta la unidad. Hoy.
* 2. ENGANCHE DE FIRMA: 2.5% del precio. El apartado se acredita DENTRO de
* este concepto → lo que se paga a la firma es el saldo restante
* (si el apartado supera el 2.5%, el excedente pasa al enganche).
* 3. ENGANCHE: 7.5% del precio, diferible en N mensualidades (N a elección
* del vendedor).
* 4. GASTOS DE ESCRITURACIÓN: 5.5% del precio. El cliente los salda en un
* pago a la firma O los difiere junto con el enganche.
*
* Espejo en TypeScript de apps/cotizador/src/lib/esquema-frasa.ts: las fórmulas
* deben mantenerse idénticas en ambos lados (el API recalcula server-side y no
* confía en los montos que manda el cliente). Servicio puro: no toca prisma.
* Porcentajes globales para todos los desarrollos (confirmado por el cliente
* el 28 sep 2026).
*/
export const PCT_ENGANCHE_FIRMA = 0.025
export const PCT_ENGANCHE = 0.075
export const PCT_ESCRITURACION = 0.055
/** Tope total de enganche (esquema + adicional): el cliente puede dar hasta
* 90% de anticipo, quedando un mínimo del 10% del precio a financiar */
export const PCT_ENGANCHE_MAXIMO = 0.9
export const MENSUALIDADES_DEFAULT = 14
export const MENSUALIDADES_MIN = 1
export const MENSUALIDADES_MAX = 36
export interface EsquemaFrasaInput {
/** Precio de la unidad (ya resuelto acabados/precio único) */
precio: number
/** Apartado: cantidad libre (>= 0) */
apartado: number
/** Número de mensualidades del diferimiento (elección del vendedor) */
numMensualidades: number
/** true = la escrituración se difiere con el enganche; false = se paga a la firma */
escrituracionDiferida: boolean
/** Enganche adicional (anticipo extra, >= 0): opcional; el total de
* anticipos (esquema + adicional) no puede superar el 90% del precio */
engancheAdicional?: number
}
export interface EsquemaFrasaResultado {
precio: number
apartado: number
/** 2.5% del precio (concepto completo) */
engancheFirmaTotal: number
/** Lo que se paga a la firma por el enganche de firma (>= 0) */
saldoFirma: number
/** Apartado que excedió el 2.5% y se acredita al enganche (>= 0) */
excedenteApartado: number
/** Enganche del 7.5%, neto del excedente del apartado */
enganche: number
escrituracion: number
escrituracionDiferida: boolean
/** Todo lo que se liquida a la firma: saldoFirma + escrituración si no se difiere */
pagoUnicoFirma: number
/** Lo que entra al diferimiento: enganche (+ escrituración si se difiere) */
baseDiferible: number
numMensualidades: number
mensualidad: number
/** apartado + firma + diferible — debe cuadrar con el precio saldado */
totalPagado: number
/** Anticipo extra sobre el esquema (0 si no se captura) */
engancheAdicional: number
/** Lo que queda por financiar: precio − esquema − adicional (mínimo 10%) */
saldoAFinanciar: number
}
/** Error de validación de entrada; null si es válida */
export function validarEsquemaInput(input: EsquemaFrasaInput): string | null {
if (!Number.isFinite(input.precio) || input.precio <= 0) return 'El precio debe ser mayor a cero'
if (!Number.isFinite(input.apartado) || input.apartado < 0) return 'El apartado no puede ser negativo'
if (input.apartado > input.precio) return 'El apartado no puede superar el precio de la propiedad'
if (!Number.isInteger(input.numMensualidades)) return 'Las mensualidades deben ser un número entero'
if (input.numMensualidades < MENSUALIDADES_MIN) return `Mínimo ${MENSUALIDADES_MIN} mensualidad`
if (input.numMensualidades > MENSUALIDADES_MAX) return `Máximo ${MENSUALIDADES_MAX} mensualidades`
const adicional = input.engancheAdicional ?? 0
if (!Number.isFinite(adicional) || adicional < 0) return 'El enganche adicional no puede ser negativo'
return null
}
export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResultado {
const error = validarEsquemaInput(input)
if (error) throw new Error(error)
const { precio, apartado, numMensualidades, escrituracionDiferida } = input
const engancheAdicional = input.engancheAdicional ?? 0
const engancheFirmaTotal = precio * PCT_ENGANCHE_FIRMA
const saldoFirma = Math.max(0, engancheFirmaTotal - apartado)
const excedenteApartado = Math.max(0, apartado - engancheFirmaTotal)
const enganche = precio * PCT_ENGANCHE - excedenteApartado
const escrituracion = precio * PCT_ESCRITURACION
const pagoUnicoFirma = saldoFirma + (escrituracionDiferida ? 0 : escrituracion)
const baseDiferible = enganche + (escrituracionDiferida ? escrituracion : 0)
const mensualidad = baseDiferible / numMensualidades
const totalPagado = apartado + pagoUnicoFirma + baseDiferible
// El anticipo total (esquema + adicional) no puede superar el 90% del precio
if (totalPagado + engancheAdicional > precio * PCT_ENGANCHE_MAXIMO + 1e-9) {
throw new Error(
`El enganche total no puede superar el ${PCT_ENGANCHE_MAXIMO * 100}% del precio (` +
`máx. ${(precio * PCT_ENGANCHE_MAXIMO - totalPagado).toFixed(2)} de enganche adicional)`,
)
}
const saldoAFinanciar = precio - totalPagado - engancheAdicional
return {
precio,
apartado,
engancheFirmaTotal,
saldoFirma,
excedenteApartado,
enganche,
escrituracion,
escrituracionDiferida,
pagoUnicoFirma,
baseDiferible,
numMensualidades,
mensualidad,
totalPagado,
engancheAdicional,
saldoAFinanciar,
}
}

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from 'vitest'
import {
APARTADO_DEFAULT,
DESARROLLOS as DESARROLLOS_LOCALES,
calcularPropuesta,
calcularPropuestas,
totalPagado,
type DescuentosPropuestas,
type ParametrosDesarrollo,
type TipoPropuestaApi,
type UnidadCotizable,
} from './cotizacion.service.js'
import { normalizarReglas } from './reglas-desarrollo.service.js'
/**
* Compatibilidad de las reglas configurables: cotizar con las reglas que llegan
* de la BD (mismo JSON que guarda `ReglaCotizadorDesarrollo`, validado por
* `normalizarReglas`) debe dar EXACTAMENTE los mismos números que con las
* constantes históricas. Unidades: 3 reales por desarrollo del inventario.
*/
function unidad(parcial: Partial<UnidadCotizable> & Pick<UnidadCotizable, 'codigo' | 'desarrollo'>): UnidadCotizable {
return {
modelo: 'X',
fechaEntrega: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: 0,
precioConAcabados: 0,
status: 'DISPONIBLE',
...parcial,
}
}
const UNIDADES: UnidadCotizable[] = [
// DIAGONAL (precio varía con acabados)
unidad({ codigo: 'U-101', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 4415000, precioConAcabados: 4579000 }),
unidad({ codigo: 'U-103', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 3107108.39, precioConAcabados: 3269000 }),
unidad({ codigo: 'U-201', desarrollo: 'DIAGONAL', fechaEntrega: null, precioSinAcabados: 3990000, precioConAcabados: 4150000 }),
// ATELIER (precio único, N fijo = 1)
unidad({ codigo: 'ATELIER-10', desarrollo: 'ATELIER', precioSinAcabados: 3997500, precioConAcabados: 3997500 }),
unidad({ codigo: 'ATELIER-17', desarrollo: 'ATELIER', precioSinAcabados: 3554291, precioConAcabados: 3554291 }),
unidad({ codigo: 'ATELIER-27', desarrollo: 'ATELIER', precioSinAcabados: 2304339, precioConAcabados: 2304339 }),
// ESTANZA (pago final sobre precio de LISTA: el "quirk" del Excel)
unidad({ codigo: 'ESTANZA-17', desarrollo: 'ESTANZA', precioSinAcabados: 2617632, precioConAcabados: 2617632 }),
unidad({ codigo: 'ESTANZA-19', desarrollo: 'ESTANZA', precioSinAcabados: 2340562, precioConAcabados: 2340562 }),
unidad({ codigo: 'ESTANZA-25', desarrollo: 'ESTANZA', precioSinAcabados: 2501683, precioConAcabados: 2501683 }),
// SENDA (N por fecha de entrega; hay piezas sin fecha → nDefault)
unidad({ codigo: 'SENDA-409-1-2', desarrollo: 'SENDA', fechaEntrega: '2026-12-19', precioSinAcabados: 2700000, precioConAcabados: 2700000 }),
unidad({ codigo: 'SENDA-411-1-7', desarrollo: 'SENDA', fechaEntrega: null, precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
unidad({ codigo: 'SENDA-409-1-4', desarrollo: 'SENDA', fechaEntrega: '2026-11-30', precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
// SOLANA (N por unidad: meses para firma; en el espejo viene null → nDefault = 1)
unidad({ codigo: 'SOLANA-6', desarrollo: 'SOLANA', precioSinAcabados: 2560000, precioConAcabados: 2560000 }),
unidad({ codigo: 'SOLANA-7', desarrollo: 'SOLANA', precioSinAcabados: 2730000, precioConAcabados: 2730000 }),
unidad({ codigo: 'SOLANA-9', desarrollo: 'SOLANA', precioSinAcabados: 2990445, precioConAcabados: 2990445 }),
]
const TIPOS: TipoPropuestaApi[] = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']
const DESCUENTOS: DescuentosPropuestas = {
creditoBancario: 0.01,
creditoInfonavit: 0.005,
contado: 0.03,
semiContado: 0.015,
}
/**
* Reglas "de BD": el JSON serializado tal como lo guarda el admin, con los
* campos extra que agrega el módulo de administración (nombre, activo, flags).
* Pasa por `normalizarReglas`, el mismo camino que usa el API al leerlas.
*/
function reglasDeBd(desarrollo: string): ParametrosDesarrollo {
const constantes = DESARROLLOS_LOCALES[desarrollo]!
const json = JSON.parse(
JSON.stringify({
...constantes,
nombre: desarrollo,
activoEnCotizador: true,
propuestas: constantes.propuestas.map((p) => ({ ...p, activa: true })),
}),
) as unknown
const reglas = normalizarReglas(desarrollo, json)
if (!reglas) throw new Error(`Las reglas de BD de ${desarrollo} deberían ser válidas`)
return reglas
}
describe('reglas de BD vs constantes: mismos números', () => {
it('las 4 propuestas de cada unidad dan resultados idénticos', () => {
for (const u of UNIDADES) {
const reglas = reglasDeBd(u.desarrollo)
for (const acabados of [false, true]) {
const conConstantes = calcularPropuestas({
unidad: u,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
})
const conBd = calcularPropuestas({
unidad: u,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
reglas,
})
expect(conBd).toHaveLength(conConstantes.length)
expect(conBd).toEqual(conConstantes)
for (const p of conBd) {
expect(totalPagado(p, APARTADO_DEFAULT)).not.toBeNull()
}
}
}
})
it('la cotización de una propuesta (con descuento, enganche y N del vendedor) es idéntica', () => {
for (const u of UNIDADES) {
const reglas = reglasDeBd(u.desarrollo)
for (const tipo of TIPOS) {
for (const apartado of [0, APARTADO_DEFAULT, 150_000]) {
const conConstantes = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12)
const conBd = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12, reglas)
expect(conBd).toEqual(conConstantes)
}
}
}
})
it('sin reglas en el parámetro se comporta como hoy (respaldo en constantes)', () => {
const u = UNIDADES[0]!
expect(calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, null)).toEqual(
calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT),
)
})
})
describe('reglas del admin que cambian el comportamiento', () => {
const u101 = UNIDADES[0]!
it('un descuento distinto en la BD se usa en el cálculo', () => {
const base = DESARROLLOS_LOCALES['DIAGONAL']!
const reglas = normalizarReglas('DIAGONAL', {
...base,
propuestas: base.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.05 } : p,
),
})!
const [bancario, , contado] = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.05, semiContado: 0 },
reglas,
})
expect(bancario?.precioFinal).toBeCloseTo(4_415_000, 2)
expect(contado?.descuento).toBe(0.05)
expect(contado?.precioFinal).toBeCloseTo(4_415_000 * 0.95, 2)
})
it('una propuesta desactivada no se calcula ni se puede elegir', () => {
const base = DESARROLLOS_LOCALES['DIAGONAL']!
const reglas = normalizarReglas('DIAGONAL', {
...base,
propuestas: base.propuestas.map((p) => ({ ...p, activa: p.tipo !== 'CONTADO' })),
})!
const propuestas = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
reglas,
})
expect(propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
expect(() =>
calcularPropuesta(u101, 'CONTADO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas),
).toThrow(/desactivada/)
})
it('un cambio de parámetros del Excel se refleja (pctEnganche / base del pago final)', () => {
const base = DESARROLLOS_LOCALES['ESTANZA']!
const reglas = normalizarReglas('ESTANZA', {
...base,
propuestas: base.propuestas.map((p) =>
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.1, pagoFinalBase: 'final' } : p,
),
})!
const casa = UNIDADES.find((u) => u.codigo === 'ESTANZA-17')!
const p = calcularPropuesta(casa, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas)
// 10% de enganche sobre precio final, pago final 90% sobre final
expect(p.enganche).toBeCloseTo(0.1 * 2617632 - APARTADO_DEFAULT, 2)
expect(p.pagoFinal).toBeCloseTo(0.9 * 2617632, 2)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2617632, 2)
})
})

View File

@@ -48,10 +48,10 @@ export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
// ---------- Parametrización por desarrollo ----------
type BasePrecio = 'lista' | 'final'
type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
export type BasePrecio = 'lista' | 'final'
export type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
interface PropuestaConfig {
export interface PropuestaConfig {
tipo: TipoPropuesta
claveDescuento: keyof DescuentosPropuestas
titulo: string
@@ -62,13 +62,35 @@ interface PropuestaConfig {
pagoFinalBase: BasePrecio
modoMensualidades: ModoMensualidades
engancheCeroSinApartado?: boolean
/**
* Regla del admin: false => la propuesta no se ofrece ni se puede elegir en
* el cotizador. Ausente/true => vigente (comportamiento histórico).
*/
activa?: boolean
}
interface ParametrosDesarrollo {
export interface ParametrosDesarrollo {
id: string
/** Nombre para mostrar (solo lo traen las reglas guardadas por el admin) */
nombre?: string
precioUnico: boolean
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
nFijo?: number
/**
* N por defecto del desarrollo: se usa cuando el inventario no trae fecha de
* entrega ni meses para firma. Garantiza que la mensualidad y el total
* siempre se calculen (espejo de apps/cotizador/src/data/desarrollos.ts).
*/
nDefault?: number
/** Cuota mensual de mantenimiento (MXN); null => pendiente */
cuotaMantenimiento?: number | null
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
prefijoUnidadPdf?: string
/**
* Regla del admin: false => el desarrollo no se ofrece en el cotizador.
* Ausente/true => disponible (comportamiento histórico).
*/
activoEnCotizador?: boolean
propuestas: PropuestaConfig[]
}
@@ -81,6 +103,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
id: 'DIAGONAL',
precioUnico: false,
fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega (mediana del
// inventario disponible 2026-09: 13 meses hasta la terminación de obra)
nDefault: 13,
propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'remanente' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'lista', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'remanenteLista' },
@@ -116,6 +141,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
id: 'SENDA',
precioUnico: true,
fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega (mediana del
// inventario disponible 2026-09: 6 mensualidades)
nDefault: 6,
propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0.025, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
@@ -127,6 +155,9 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
id: 'SOLANA',
precioUnico: true,
fuenteDeN: 'porUnidad',
// Último recurso si la unidad no trae "meses para firma" (las casas
// vendibles del inventario Excel están en 1 mes)
nDefault: 1,
propuestas: [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario', descuentoDefault: 0, pctEnganche: 0.05, engancheBase: 'final', pctPagoFinal: 0.9, pagoFinalBase: 'final', modoMensualidades: 'porcentaje' },
{ tipo: 'CREDITO_INFONAVIT_COFINAVIT', claveDescuento: 'creditoInfonavit', titulo: 'Crédito Infonavit-Cofinavit', descuentoDefault: 0, pctEnganche: 0, engancheBase: 'final', pctPagoFinal: 0.95, pagoFinalBase: 'final', modoMensualidades: 'porcentaje', engancheCeroSinApartado: true },
@@ -138,10 +169,23 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
const DESARROLLO_DEFAULT = 'DIAGONAL'
function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo {
/**
* Reglas comerciales del desarrollo: las configuradas por el admin (BD) ganan
* cuando corresponden al id pedido; si no, se usan las constantes del módulo.
*/
function obtenerDesarrollo(
id?: string | null,
reglas?: ParametrosDesarrollo | null,
): ParametrosDesarrollo {
if (reglas && (id === undefined || id === null || id === '' || reglas.id === id)) return reglas
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]!
}
/** Propuestas vigentes: el admin puede desactivar una propuesta por desarrollo */
export function propuestasVigentes(params: ParametrosDesarrollo): PropuestaConfig[] {
return params.propuestas.filter((p) => p.activa !== false)
}
export interface PropuestaResultado {
tipo: TipoPropuesta
titulo: string
@@ -162,6 +206,11 @@ export interface CotizacionInput {
apartado: number
descuentos: DescuentosPropuestas
desarrollo?: string
/**
* Reglas comerciales efectivas del desarrollo (las configuradas por el admin
* o su respaldo en constantes). Si no vienen, se usan las constantes.
*/
reglas?: ParametrosDesarrollo
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
numMeses?: number
}
@@ -174,7 +223,9 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES))
}
/** Resuelve N según la fuente del desarrollo (null si no se puede conocer) */
/** Resuelve N según la fuente del desarrollo; cae al plazo por defecto del
* desarrollo (`nDefault`) cuando el inventario no trae el dato, para que la
* mensualidad y el total SIEMPRE se calculen. */
export function resolverNumMensualidades(
unidad: UnidadCotizable,
params: ParametrosDesarrollo,
@@ -183,9 +234,11 @@ export function resolverNumMensualidades(
case 'fijo':
return params.nFijo ?? 1
case 'porUnidad':
return unidad.mesesParaFirma ?? null
return unidad.mesesParaFirma ?? params.nDefault ?? null
case 'fechaEntrega':
return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null
return unidad.fechaEntrega
? calcularNumMensualidades(unidad.fechaEntrega)
: (params.nDefault ?? null)
}
}
@@ -196,6 +249,42 @@ function baseComun(precioLista: number, descuento: number) {
return { montoDescuento, precioFinal }
}
/** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */
interface BloquesPago {
enganche: number
mensualidades: number | null
pagoFinal: number
}
/**
* Cuadra los bloques para que apartado + enganche + mensualidades + pago final
* sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la
* propuesta), aunque el apartado o un enganche personalizado descuadren las
* fórmulas:
* - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando
* el apartado supera el enganche, ni mensualidades negativas con enganche
* alto);
* - el excedente se recorta de las mensualidades (bloque flexible del
* remanente) y, si no alcanza, del pago final. Nunca se suma por encima del
* precio.
*/
function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago {
const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal
const enganche = Math.max(0, bloques.enganche)
let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades)
let pagoFinal = Math.max(0, bloques.pagoFinal)
let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total
if (excedente > 0 && mensualidades !== null) {
const recorte = Math.min(mensualidades, excedente)
mensualidades -= recorte
excedente -= recorte
}
if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente)
return { enganche, mensualidades, pagoFinal }
}
/** Calcula una propuesta comercial con los parámetros dados */
function calcularPropuestaConfig(
cfg: PropuestaConfig,
@@ -217,17 +306,21 @@ function calcularPropuestaConfig(
cfg.pctEnganche <= 0.05
const pctEnganche = aplicaOverride ? Math.max(0.025, enganchePct) : cfg.pctEnganche
// Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de
// SOLANA lo deja en 0 cuando el % es 0
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
const enganche =
const engancheBruto =
cfg.engancheCeroSinApartado && pctEnganche === 0
? 0
: pctEnganche * baseEnganche - apartado
: pctEnganche * baseEnganche
// Fórmula del Excel: enganche neto del apartado (puede salir negativo si el
// apartado supera el enganche; cuadrarBloques lo acota a 0)
const engancheExcel = engancheBruto - apartado
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal
let mensualidadesTotal: number | null = null
let mensualidad: number | null = null
let mensualidadesExcel: number | null = null
let notaMensualidades: string | undefined
if (cfg.modoMensualidades !== 'ninguna') {
if (n === null) {
@@ -235,22 +328,26 @@ function calcularPropuestaConfig(
} else {
switch (cfg.modoMensualidades) {
case 'remanente':
mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel
break
case 'remanenteLista':
// Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel
break
case 'porcentaje':
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
break
}
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
mensualidadesTotal = Math.max(0, mensualidadesTotal)
mensualidad = mensualidadesTotal / n
}
}
const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, {
enganche: engancheExcel,
mensualidades: mensualidadesExcel,
pagoFinal: pagoFinalExcel,
})
const mensualidad = mensualidades === null || n === null ? null : mensualidades / n
return {
tipo: cfg.tipo,
titulo: cfg.titulo,
@@ -260,7 +357,7 @@ function calcularPropuestaConfig(
enganche,
numMensualidades: n,
mensualidad,
mensualidadesTotal,
mensualidadesTotal: mensualidades,
pagoFinal,
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
}
@@ -293,13 +390,16 @@ export function indiceMenorTotal(totales: number[]): number {
return indice
}
/** Calcula las propuestas comerciales del desarrollo de la unidad */
/** Calcula las propuestas comerciales vigentes del desarrollo de la unidad */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input
const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
const params = obtenerDesarrollo(
input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT,
input.reglas ?? null,
)
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
return params.propuestas.map((cfg) =>
return propuestasVigentes(params).map((cfg) =>
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n),
)
}
@@ -321,6 +421,10 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
* `descuento` opcional (fracción); si no viene, usa el default de la propuesta.
* `enganchePct` opcional (fracción, mínimo 0.025); solo aplica a esquemas con
* crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo.
* `numMeses` opcional: número de mensualidades elegido por el vendedor en el
* cotizador; si no viene se resuelve con la fuente del desarrollo.
* `reglas` opcional: reglas comerciales efectivas del desarrollo de la unidad
* (BD del admin con respaldo en constantes); sin ellas se usan las constantes.
*/
export function calcularPropuesta(
unidad: UnidadCotizable,
@@ -329,15 +433,22 @@ export function calcularPropuesta(
apartado: number = APARTADO_DEFAULT,
descuento?: number,
enganchePct?: number,
numMeses?: number,
reglas?: ParametrosDesarrollo | null,
): PropuestaResultado {
const params = obtenerDesarrollo(unidad.desarrollo)
const params = obtenerDesarrollo(unidad.desarrollo, reglas ?? null)
const tipoInterno = TIPO_PROPUESTA_API[tipo]
const cfg = params.propuestas.find((p) => p.tipo === tipoInterno)
const cfg = propuestasVigentes(params).find((p) => p.tipo === tipoInterno)
if (!cfg) {
throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`)
const desactivada = params.propuestas.some((p) => p.tipo === tipoInterno)
throw new Error(
desactivada
? `La propuesta ${tipo} está desactivada para el desarrollo ${params.id}`
: `La propuesta ${tipo} no aplica para el desarrollo ${params.id}`,
)
}
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = resolverNumMensualidades(unidad, params)
const n = numMeses ?? resolverNumMensualidades(unidad, params)
const d = descuento ?? cfg.descuentoDefault
return calcularPropuestaConfig(cfg, precioLista, apartado, d, n, enganchePct ?? null)
}

View File

@@ -16,6 +16,7 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(),
},
automatizacionState: {
findFirst: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
@@ -43,6 +44,7 @@ beforeEach(() => {
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data)
prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.findFirst.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
})
@@ -234,9 +236,26 @@ describe('CrmLocalService.apartar', () => {
email: 'luis@correo.com',
desarrollo: 'ATELIER',
modelo: 'SOHO',
datosJson: '{"edad":35}',
datosJson: JSON.stringify({
edad: 35,
estadoCivil: 'Casado',
tipoEmpleo: 'Formal',
rangoSalarial: 'B. 20,000-40,000',
tipoEconomia: 'Formal',
califica: 'Si',
loQuierePara: 'Para vivir',
}),
}
it('sin afluencia completa rechaza el apartado (regla de negocio)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
...filaOportunidad,
datosJson: '{"edad":35}',
})
await expect(svc.apartar('loc_op', 'u-1')).rejects.toThrow(/afluencia/i)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
})
it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
const apartado = await svc.apartar('loc_op', 'u-1')
@@ -311,8 +330,326 @@ describe('CrmLocalService.buscarLocales', () => {
modeloInteres: 'SOHO',
})
expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' })
// La búsqueda por nombre incluye variantes con/sin acento: 'ana' también
// consulta 'ána'/'aná' además del término literal
const whereLead = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
expect(whereLead['AND'][1]['OR']).toEqual(
expect.arrayContaining([
{ nombreLead: { contains: 'ana' } },
{ telefono: { contains: 'ana' } },
]),
)
})
it('buscar nombre sin acento encuentra el registro con tilde (jazmin -> Jazmin)', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_ac',
nombreLead: 'Jazmín Estiven',
telefono: '6641112233',
telefonoNorm: '6641112233',
email: null,
etapa: 'NUEVO',
origen: null,
board: 'LEADS',
desarrollo: null,
modelo: null,
updatedAt: new Date('2026-09-29T10:00:00Z'),
},
])
prismaMock.cliente.findMany.mockResolvedValue([])
const r = await svc.buscarLocales('jazmin')
expect(r.map((x) => x.id)).toContain('loc_ac')
const whereLead = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
expect(whereLead['AND'][1]['OR']).toEqual(
expect.arrayContaining([
{ nombreLead: { contains: 'jazmin' } },
{ nombreLead: { contains: 'jazmín' } },
]),
)
})
it('encuentra por telefono aunque el registro guarde espacios/ladas duplicadas', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_tel',
nombreLead: 'Jazmin Estiven',
telefono: '+52 +52 664 782 5023',
telefonoNorm: '6647825023',
email: null,
etapa: 'Intento de contacto',
origen: 'PROSPECCION ASESOR',
board: 'LEADS',
desarrollo: 'DIAGONAL',
modelo: null,
updatedAt: new Date('2026-09-29T10:00:00Z'),
},
])
prismaMock.cliente.findMany.mockResolvedValue([])
const r = await svc.buscarLocales('6647825023')
expect(r).toHaveLength(1)
expect(r[0]).toMatchObject({ id: 'loc_tel', nombre: 'Jazmin Estiven' })
// El match telefonico va contra telefonoNorm (digitos), no el telefono crudo
expect(prismaMock.leadCaptura.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { OR: [{ nombreLead: { contains: 'ana' } }, { telefono: { contains: 'ana' } }] } }),
expect.objectContaining({
where: expect.objectContaining({
AND: [
{},
{
OR: [
{ nombreLead: { contains: '6647825023' } },
{ telefono: { contains: '6647825023' } },
{ telefonoNorm: { contains: '6647825023' } },
],
},
],
}),
}),
)
})
it('buscar con lada completa (+52 ...) encuentra el registro sin lada', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([
{
leadId: 'loc_tel2',
nombreLead: 'Jazmin Estiven',
telefono: '664 782 5023',
telefonoNorm: '6647825023',
email: null,
etapa: 'Intento de contacto',
origen: null,
board: 'LEADS',
desarrollo: null,
modelo: null,
updatedAt: new Date('2026-09-29T10:00:00Z'),
},
])
prismaMock.cliente.findMany.mockResolvedValue([])
const r = await svc.buscarLocales('+52 664 782 5023')
expect(r.map((x) => x.id)).toContain('loc_tel2')
})
})
describe('CrmLocalService.crearLead — dedup con etapas cerradas', () => {
it('el dedup por teléfono excluye etapas cerradas: limpieza/descartes/cancelados no bloquean la re-captura', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'loc_nueva', ...data }))
const r = await svc.crearLead({ nombre: 'Reintento', telefono: '6641234567' }, 'u-1')
expect('duplicado' in r).toBe(false)
expect(prismaMock.leadCaptura.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
etapa: { notIn: ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] },
}),
}),
)
})
it('el dedup por email usa el mismo criterio (cualquier tablero, sin etapas cerradas)', async () => {
prismaMock.leadCaptura.findFirst
.mockResolvedValueOnce(null) // teléfono
.mockResolvedValueOnce(null) // email
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => ({ id: 'loc_nueva', ...data }))
await svc.crearLead({ nombre: 'Reintento Mail', telefono: '', email: 'reintento@correo.com' }, 'u-1')
expect(prismaMock.leadCaptura.findFirst).toHaveBeenLastCalledWith({
where: {
OR: [{ email: 'reintento@correo.com' }],
etapa: { notIn: ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado'] },
},
})
})
})
describe('CrmLocalService.buscarLocales — privacidad por vendedor', () => {
it('soloPropio filtra leads por usuario y clientes por capturadoPorId', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([])
await svc.buscarLocales('ana', { usuarioId: 'u-9', soloPropio: true })
const wherePrivado = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
expect(wherePrivado['AND'][0]).toEqual({ usuarioId: 'u-9' })
expect(wherePrivado['AND'][1]['OR']).toEqual(
expect.arrayContaining([
{ nombreLead: { contains: 'ana' } },
{ telefono: { contains: 'ana' } },
]),
)
const whereCliente = prismaMock.cliente.findMany.mock.calls[0][0]['where']
expect(whereCliente['AND'][0]).toEqual({ capturadoPorId: 'u-9' })
expect(whereCliente['AND'][1]['OR']).toEqual(
expect.arrayContaining([
{ nombreCompleto: { contains: 'ana' } },
{ celular: { contains: 'ana' } },
]),
)
})
it('sin alcance la búsqueda sigue siendo global (roles de supervisión)', async () => {
prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.cliente.findMany.mockResolvedValue([])
await svc.buscarLocales('ana')
const whereGlobal = prismaMock.leadCaptura.findMany.mock.calls[0][0]['where']
expect(whereGlobal['AND'][0]).toEqual({})
expect(whereGlobal['AND'][1]['OR']).toEqual(
expect.arrayContaining([
{ nombreLead: { contains: 'ana' } },
{ telefono: { contains: 'ana' } },
]),
)
})
})
describe('normalizaTelefono', () => {
it('quita formato y toma los últimos 10 dígitos', async () => {
const { normalizaTelefono } = await import('./crm-local.service.js')
expect(normalizaTelefono('+52 664 225 7841')).toBe('6642257841')
expect(normalizaTelefono('+526198790165')).toBe('6198790165')
expect(normalizaTelefono('+52 52 664 225 7841')).toBe('6642257841') // lada duplicada
expect(normalizaTelefono('4771234567')).toBe('4771234567')
})
it('conserva 7-9 dígitos; rechaza menos de 7 y vacíos', async () => {
const { normalizaTelefono } = await import('./crm-local.service.js')
expect(normalizaTelefono('12345678')).toBe('12345678')
expect(normalizaTelefono('123456')).toBeNull()
expect(normalizaTelefono('')).toBeNull()
expect(normalizaTelefono(null)).toBeNull()
expect(normalizaTelefono(undefined)).toBeNull()
})
})
describe('CrmLocalService dedup normalizado (anti-duplicados)', () => {
it('crearLead guarda telefonoNorm aunque el teléfono venga con espacios', async () => {
const r = await svc.crearLead({ nombre: 'Guadalupe Avila', telefono: '+52 664 225 7841' }, 'u-1')
expect('duplicado' in r).toBe(false)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ telefono: '+52 664 225 7841', telefonoNorm: '6642257841' }),
})
})
it('el dedup busca por telefonoNorm exacto (no por contains con espacios)', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'loc_viejo', nombreLead: 'Guadalupe' })
const r = await svc.crearLead({ nombre: 'Guadalupe', telefono: '664 225 7841' })
expect(r).toEqual({ duplicado: { id: 'loc_viejo', nombre: 'Guadalupe' } })
expect(prismaMock.leadCaptura.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ telefonoNorm: '6642257841' }),
}),
)
expect(prismaMock.leadCaptura.create).not.toHaveBeenCalled()
})
it('crearAfluencia guarda telefonoNorm y dedup por teléfono normalizado', async () => {
const r = await svc.crearAfluencia({ nombre: 'Luis Demo', telefono: '+52 (664) 111-2233' }, 'u-2')
expect('duplicado' in r).toBe(false)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ telefonoNorm: '6641112233' }),
})
})
it('email se guarda en minúsculas y el dedup lo busca también en minúsculas', async () => {
prismaMock.leadCaptura.findFirst.mockResolvedValue(null)
const r = await svc.crearLead({ nombre: 'Ana Mail', email: 'Ana@Correo.COM' }, 'u-1')
expect('duplicado' in r).toBe(false)
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ email: 'ana@correo.com' }),
})
})
it('convertirLead propaga telefonoNorm a la oportunidad nueva', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_lead',
oportunidadId: null,
usuarioId: 'u-1',
nombreLead: 'Ana Propaga',
telefono: '+52 664 225 7841',
telefonoNorm: '6642257841',
email: null,
origen: null,
desarrollo: null,
modelo: null,
datosJson: null,
})
await svc.convertirLead('loc_lead')
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ board: 'OPORTUNIDADES', telefonoNorm: '6642257841' }),
})
})
it('dos capturas simultáneas del mismo teléfono: sólo una crea (lock anti-carrera)', async () => {
// findFirst devuelve null la primera vez y "encuentra" la fila ya creada
// a partir de la segunda: sin lock, la carrera crearía dos filas.
const tienda = new Map<string, { leadId: string; nombreLead: string }>()
prismaMock.leadCaptura.findFirst.mockImplementation(async ({ where }) => {
const tel = where?.telefonoNorm as string | undefined
return tel ? (tienda.get(tel) ?? null) : null
})
prismaMock.leadCaptura.create.mockImplementation(async ({ data }) => {
tienda.set(data.telefonoNorm, { leadId: data.leadId, nombreLead: data.nombreLead })
return { id: 'row', ...data }
})
const [r1, r2] = await Promise.all([
svc.crearLead({ nombre: 'Doble Click', telefono: '6641112233' }, 'u-1'),
svc.crearLead({ nombre: 'Doble Click', telefono: '6641112233' }, 'u-1'),
])
const duplicados = [r1, r2].filter((r) => 'duplicado' in r).length
const creados = [r1, r2].filter((r) => !('duplicado' in r)).length
expect(creados).toBe(1)
expect(duplicados).toBe(1)
})
})
describe('CrmLocalService.reasignarCadena', () => {
it('lead sin convertir: solo mueve su propia fila', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'L1', board: 'LEADS', oportunidadId: null,
})
prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 1 })
const n = await svc.reasignarCadena('L1', 'u-nueva')
expect(n).toBe(1)
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: { in: ['L1'] } },
data: { usuarioId: 'u-nueva' },
})
// Sin oportunidad no busca ni lead enlazado ni apartado
expect(prismaMock.automatizacionState.findFirst).not.toHaveBeenCalled()
// Las actividades pendientes también pasan al nuevo vendedor
expect(prismaMock.recordatorioVendedor.updateMany).toHaveBeenCalledWith({
where: { leadId: { in: ['L1'] }, completado: false },
data: { usuarioId: 'u-nueva' },
})
})
it('oportunidad convertida: mueve lead + oportunidad + apartado', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'O1', board: 'OPORTUNIDADES', oportunidadId: 'O1',
})
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'L1' })
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:O1', valor: 'A1' })
prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 3 })
const n = await svc.reasignarCadena('O1', 'u-nueva')
expect(n).toBe(3)
const where = prismaMock.leadCaptura.updateMany.mock.calls[0][0]['where']
expect(where['leadId']['in'].sort()).toEqual(['A1', 'L1', 'O1'])
})
it('apartado: reconstruye la cadena completa hacia atrás', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'A1', board: 'APARTADO', oportunidadId: 'A1',
})
prismaMock.automatizacionState.findFirst.mockResolvedValue({ clave: 'apartado-creado:O1', valor: 'A1' })
prismaMock.leadCaptura.findFirst.mockResolvedValue({ leadId: 'L1' })
prismaMock.automatizacionState.findUnique.mockResolvedValue({ clave: 'apartado-creado:O1', valor: 'A1' })
prismaMock.leadCaptura.updateMany.mockResolvedValue({ count: 3 })
const n = await svc.reasignarCadena('A1', 'u-nueva')
expect(n).toBe(3)
const where = prismaMock.leadCaptura.updateMany.mock.calls[0][0]['where']
expect(where['leadId']['in'].sort()).toEqual(['A1', 'L1', 'O1'])
})
it('id inexistente: 404 en vez de "éxito" silencioso', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(null)
await expect(svc.reasignarCadena('loc_fantasma', 'u-nueva')).rejects.toThrow('Lead no encontrado')
expect(prismaMock.leadCaptura.updateMany).not.toHaveBeenCalled()
})
})

View File

@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto'
import { AppError, NotFoundError } from '../utils/errors.js'
import { afluenciaCompleta } from '../config/vocabulario.js'
import { claveApartadoCreado } from './automatizaciones.service.js'
import { calcularClienteClave } from './recordatorio-vendedor.service.js'
import { prisma } from './prisma.js'
@@ -76,19 +77,28 @@ export class CrmLocalService {
* teléfono y email entre los leads locales (mismo contrato 409 de siempre).
*/
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
// Dedup por teléfono (normalizado a los últimos 10 dígitos) en CUALQUIER
// tablero: evita que la misma persona quede como lead y como afluencia.
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
const telLimpio = soloDigitos(datos.telefono).slice(-10)
if (telLimpio.length >= 7) {
const existente = await buscarPorTelefono(telLimpio)
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
}
const telNorm =
datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
const emailNorm =
datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
// Serializa la captura por teléfono/email: dos envíos simultáneos no deben
// pasar juntos la validación de duplicado.
return conLock(telNorm ? `tel:${telNorm}` : emailNorm ? `mail:${emailNorm}` : `cap:${usuarioId ?? 'anon'}`, async () => {
// Dedup por teléfono normalizado en CUALQUIER tablero: evita que la
// misma persona quede como lead y como afluencia.
if (telNorm) {
const existente = await buscarPorTelefono(telNorm)
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } }
}
// Dedup por email
if (datos.email !== undefined && datos.email.trim() !== '') {
// Dedup por email (minúsculas): mismo criterio que el teléfono (cualquier
// tablero, sin etapas cerradas)
if (emailNorm) {
// Busca el email normalizado y, si venía distinto (mayúsculas), también
// tal cual: los registros viejos conservan el formato original.
const variantes = [{ email: emailNorm }]
if (datos.email && datos.email.trim() !== emailNorm) variantes.push({ email: datos.email.trim() })
const existenteEmail = await prisma.leadCaptura.findFirst({
where: { email: datos.email.trim(), board: 'LEADS' },
where: { OR: variantes, etapa: { notIn: ETAPAS_CERRADAS } },
})
if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } }
}
@@ -100,7 +110,8 @@ export class CrmLocalService {
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
telefonoNorm: telNorm,
email: emailNorm,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
board: 'LEADS',
@@ -110,6 +121,7 @@ export class CrmLocalService {
},
})
return { id: fila.leadId, oportunidadId: null }
})
}
/**
@@ -123,35 +135,22 @@ export class CrmLocalService {
if (oportunidadId !== undefined) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
// Sin fila local (id heredado del CRM anterior): se crea la sombra con
// ese id para que la captura no se pierda en silencio.
// Sin fila local: id inválido o liga guardada de una limpieza anterior.
// Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se
// rechaza para no sembrar registros huérfanos en Oportunidades.
if (!existente) {
await prisma.leadCaptura.create({
data: {
leadId: oportunidadId,
oportunidadId,
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
})
return { id: oportunidadId, oportunidadId, actualizada: true }
throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`)
}
// Sólo se pisan los campos capturados: lo que no viene en el payload se
// conserva (antes se borraba con null y se perdía la cita agendada).
const cambio: Record<string, unknown> = { pendienteSync: true }
if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre
if (datos.telefono !== undefined) cambio['telefono'] = datos.telefono
if (datos.email !== undefined) cambio['email'] = datos.email
if (datos.telefono !== undefined) {
cambio['telefono'] = datos.telefono
cambio['telefonoNorm'] = datos.telefono.trim() === '' ? null : normalizaTelefono(datos.telefono)
}
if (datos.email !== undefined) cambio['email'] = datos.email.trim() === '' ? null : datos.email.trim().toLowerCase()
if (datos.origen !== undefined) cambio['origen'] = datos.origen
if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo
if (modelo !== undefined) cambio['modelo'] = modelo
@@ -176,12 +175,16 @@ export class CrmLocalService {
return { id: oportunidadId, oportunidadId, actualizada: true }
}
if (datos.telefono !== undefined && datos.telefono.trim() !== '') {
const telLimpio = soloDigitos(datos.telefono).slice(-10)
if (telLimpio.length >= 7) {
const duplicado = await buscarPorTelefono(telLimpio)
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
}
const telNorm =
datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
const emailNorm =
datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
// Dedup + creación serializados por teléfono/email (mismo criterio que
// crearLead: dos envíos simultáneos no deben duplicar).
return conLock(telNorm ? `tel:${telNorm}` : emailNorm ? `mail:${emailNorm}` : `afl:${usuarioId ?? 'anon'}`, async () => {
if (telNorm) {
const duplicado = await buscarPorTelefono(telNorm)
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } }
}
const id = idLocal()
@@ -193,7 +196,8 @@ export class CrmLocalService {
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
telefonoNorm: telNorm,
email: emailNorm,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
@@ -208,6 +212,7 @@ export class CrmLocalService {
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
])
return { id, oportunidadId: id }
})
}
/**
@@ -244,9 +249,10 @@ export class CrmLocalService {
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
): Promise<void> {
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false)
// Guardar fechaCita en datosJson para que la oportunidad la muestre en el calendario
// Guardar fechaCita en datosJson (oportunidad y apartado) para que la
// muestre Mi día y el calendario
const updateData: Record<string, unknown> = { etapa: label, pendienteSync: true }
if (opciones.fecha !== undefined && board === 'OPORTUNIDADES') {
if (opciones.fecha !== undefined && (board === 'OPORTUNIDADES' || board === 'APARTADO')) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
let datos: Record<string, unknown> = {}
if (existente?.datosJson) {
@@ -317,6 +323,7 @@ export class CrmLocalService {
usuarioId: lead.usuarioId,
nombreLead: lead.nombreLead,
telefono: lead.telefono,
telefonoNorm: lead.telefonoNorm,
email: lead.email,
origen: lead.origen,
desarrollo: lead.desarrollo,
@@ -344,8 +351,16 @@ export class CrmLocalService {
* oportunidad pasan a apuntar al apartado para que sobrevivan al cambio de
* board. Idempotente con el mismo dedup `apartado-creado:<opId>` de las
* automatizaciones.
*
* `opciones.unidadCodigo` deja la unidad apartada en el `datosJson` del
* apartado (la cotización del vendedor o la unidad de interés): es lo que
* después leen el bloqueo de inventario y Mi día.
*/
async apartar(oportunidadIdLocal: string, usuarioId?: string) {
async apartar(
oportunidadIdLocal: string,
usuarioId?: string,
opciones: { unidadCodigo?: string | undefined } = {},
) {
// Sombra para ids heredados sin fila local
const oportunidad = await this.filaLocal(
oportunidadIdLocal,
@@ -354,6 +369,11 @@ export class CrmLocalService {
!esIdLocal(oportunidadIdLocal),
)
// Regla de negocio: no se aparta sin la afluencia completa del cliente
if (!afluenciaCompleta(oportunidad.datosJson)) {
throw new AppError(400, 'Primero debes capturar la afluencia del cliente (botón Afluencia) antes de apartar')
}
const clave = claveApartadoCreado(oportunidadIdLocal)
const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
if (previo !== null) {
@@ -362,6 +382,7 @@ export class CrmLocalService {
}
const apartadoId = idLocal()
const datosJson = conUnidadCodigo(oportunidad.datosJson, opciones.unidadCodigo)
const resultados = await prisma.$transaction([
prisma.leadCaptura.update({
where: { leadId: oportunidadIdLocal },
@@ -373,17 +394,18 @@ export class CrmLocalService {
usuarioId: oportunidad.usuarioId,
nombreLead: oportunidad.nombreLead,
telefono: oportunidad.telefono,
telefonoNorm: oportunidad.telefonoNorm,
email: oportunidad.email,
origen: oportunidad.origen,
desarrollo: oportunidad.desarrollo,
modelo: oportunidad.modelo,
board: 'APARTADO',
etapa: 'Pago Apartado',
datosJson: oportunidad.datosJson,
datosJson,
// Fecha límite del apartado en la COLUMNA: la leen el KPI "Apartados
// por vencer", el cron de avisos y la lista de Mi día (antes sólo se
// calculaba en el JSON del alta y la columna quedaba vacía).
fechaLimite: fechaLimiteApartado(oportunidad.datosJson),
fechaLimite: fechaLimiteApartado(datosJson),
pendienteSync: true,
},
}),
@@ -404,20 +426,81 @@ export class CrmLocalService {
/**
* Búsqueda local para el picker del alta de clientes (y el buscador del
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular.
* Con `alcance.soloPropio` (rol vendedor) filtra a los registros propios:
* cada asesor sólo encuentra sus leads y contactos, no los de sus compañeros.
*/
async buscarLocales(termino: string): Promise<ResultadoBusquedaLocal[]> {
async buscarLocales(
termino: string,
alcance?: { usuarioId: string; soloPropio: boolean },
): Promise<ResultadoBusquedaLocal[]> {
const propioLead = alcance?.soloPropio ? { usuarioId: alcance.usuarioId } : {}
const propioCliente = alcance?.soloPropio ? { capturadoPorId: alcance.usuarioId } : {}
// Búsqueda telefónica: el teléfono crudo puede traer espacios, ladas o
// caracteres invisibles (p. ej. '+52 +52 664 782 5023'), así que el match
// va contra los dígitos normalizados (telefonoNorm), en ambos sentidos:
// quien busca '6647825023' o '+52 664 782 5023' debe encontrar el registro.
const digitos = termino.replace(/\D/g, '')
const pareceTelefono = /^[\d\s+().-]+$/.test(termino) && digitos.length >= 2
// Con lada (+52 ...) el término supera los 10 dígitos del número nacional:
// se agrega también el sufijo de 10 dígitos para el match en telefonoNorm
const sufijoNacional = digitos.length > 10 ? digitos.slice(-10) : null
const condTelefonoLead =
digitos.length >= 2
? [
{ telefono: { contains: termino } },
{ telefonoNorm: { contains: digitos } },
...(sufijoNacional !== null ? [{ telefonoNorm: { contains: sufijoNacional } }] : []),
]
: [{ telefono: { contains: termino } }]
const condTelefonoCliente = digitos.length >= 2 ? [{ celular: { contains: digitos } }] : []
// Nombres: variantes con/sin acento (y ñ/n) porque el asesor no siempre
// teclea las tildes ('jazmin' debe encontrar a 'Jazmín'). Se genera la
// forma sin acentos más una variante por vocal acentuada (una a la vez),
// suficiente en la práctica y acotado en tamaño.
const A_QUITA: Record<string, string> = {
á: 'a', à: 'a', ä: 'a', â: 'a', é: 'e', è: 'e', ë: 'e', ê: 'e',
í: 'i', ï: 'i', î: 'i', ó: 'o', ö: 'o', ô: 'o', ú: 'u', ü: 'u', û: 'u',
Á: 'A', À: 'A', Ä: 'A', Â: 'A', É: 'E', È: 'E', Ë: 'E', Ê: 'E',
Í: 'I', Ï: 'I', Î: 'I', Ó: 'O', Ö: 'O', Ô: 'O', Ú: 'U', Ü: 'U', Û: 'U',
}
const A_PONE: Record<string, string> = {
a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú', A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú', n: 'ñ', N: 'Ñ',
}
const variantesNombre = (t: string): string[] => {
const base = [...t].map((c) => A_QUITA[c] ?? c).join('')
const variants = new Set<string>([base])
const chars = [...base]
for (let i = 0; i < chars.length; i++) {
const ch = chars[i] as string
const acentuada = A_PONE[ch]
if (acentuada !== undefined) variants.add(chars.slice(0, i).join('') + acentuada + chars.slice(i + 1).join(''))
}
if (base !== t) variants.add(t)
return [...variants]
}
const condNombreLead = variantesNombre(termino).map((v) => ({ nombreLead: { contains: v } }))
const condNombreCliente = variantesNombre(termino).map((v) => ({ nombreCompleto: { contains: v } }))
const capturas = await prisma.leadCaptura.findMany({
where: { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] },
where: { AND: [propioLead, { OR: [...condNombreLead, ...condTelefonoLead] }] },
orderBy: { updatedAt: 'desc' },
take: 15,
take: 50,
})
const clientes = await prisma.cliente.findMany({
where: { OR: [{ nombreCompleto: { contains: termino } }, { celular: { contains: termino } }] },
where: { AND: [propioCliente, { OR: [...condNombreCliente, { celular: { contains: termino } }, ...condTelefonoCliente] }] },
orderBy: { createdAt: 'desc' },
take: 15,
take: 50,
})
/** Teléfono buscado: match si los dígitos del registro contienen los del
* término, o viceversa (búsqueda con lada completa vs registro sin lada). */
const coincideTelefono = (crudo: string | null | undefined): boolean => {
if (digitos.length < 2) return true
const d = (crudo ?? '').replace(/\D/g, '')
return d.includes(digitos) || (d.length >= 6 && digitos.includes(d))
}
const candidatos = [
...capturas.map((c) => ({
...capturas
.filter((c) => !pareceTelefono || coincideTelefono(c.telefono) || c.nombreLead.includes(termino))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
@@ -429,7 +512,9 @@ export class CrmLocalService {
modeloInteres: c.modelo ?? '',
orden: c.updatedAt,
})),
...clientes.map((c) => ({
...clientes
.filter((c) => !pareceTelefono || coincideTelefono(c.celular) || c.nombreCompleto.includes(termino))
.map((c) => ({
id: c.id,
nombre: c.nombreCompleto,
telefono: c.celular ?? '',
@@ -447,6 +532,62 @@ export class CrmLocalService {
.slice(0, 15)
.map(({ orden: _orden, ...resto }) => resto)
}
/**
* Reasignación en cadena (solo admin): mueve el registro y TODA su cadena
* al nuevo vendedor. Un cliente puede tener hasta 3 filas (lead →
* oportunidad → apartado) y cada una tiene su propio leadId; reasignar sólo
* la fila tocada dejaba al cliente "partido" entre dos vendedores (el lead
* con uno y la oportunidad/apartado con el otro). También pasan las
* actividades PENDIENTES (calendario); las completadas quedan como
* historial del vendedor original. Devuelve cuántas filas se movieron.
*/
async reasignarCadena(itemId: string, usuarioId: string): Promise<number> {
const origen = await prisma.leadCaptura.findUnique({ where: { leadId: itemId } })
if (!origen) throw new NotFoundError('Lead no encontrado')
const ids = new Set<string>([itemId])
let oportunidadId: string | null = null
if (origen.board === 'LEADS') {
oportunidadId = origen.oportunidadId
} else if (origen.board === 'OPORTUNIDADES') {
oportunidadId = itemId
} else if (origen.board === 'APARTADO') {
// Cadena inversa: el estado 'apartado-creado:{O}' apunta a este apartado
const estado = await prisma.automatizacionState.findFirst({
where: { clave: { startsWith: 'apartado-creado:' }, valor: itemId },
})
oportunidadId = estado?.clave.slice('apartado-creado:'.length) ?? null
}
if (oportunidadId !== null) {
ids.add(oportunidadId)
// El lead origen (tabla LEADS) apunta a la oportunidad con su columna
const lead = await prisma.leadCaptura.findFirst({
where: { board: 'LEADS', oportunidadId },
select: { leadId: true },
})
if (lead) ids.add(lead.leadId)
// El apartado cuelga de la oportunidad por la clave de automatización
const apartado = await prisma.automatizacionState.findUnique({
where: { clave: claveApartadoCreado(oportunidadId) },
})
if (apartado) ids.add(apartado.valor)
}
const listaIds = [...ids]
const resultados = await prisma.$transaction([
prisma.leadCaptura.updateMany({
where: { leadId: { in: listaIds } },
data: { usuarioId },
}),
prisma.recordatorioVendedor.updateMany({
where: { leadId: { in: listaIds }, completado: false },
data: { usuarioId },
}),
])
return resultados[0].count
}
}
/** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
@@ -473,12 +614,76 @@ function soloDigitos(telefono: string): string {
}
/**
* Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero.
* Así el mismo número no se duplica entre Leads, Oportunidades y Apartados.
* Teléfono normalizado para dedup: sólo dígitos; con 10 o más toma los
* últimos 10 (cubre lada duplicada tipo "+52 52 ..."); con 7-9 conserva
* todos. Menos de 7 dígitos no es buscable (null). Coincide con el backfill
* de la migración 20260926120000_lead_telefono_norm.
*/
async function buscarPorTelefono(diezDigitos: string) {
export function normalizaTelefono(telefono: string | null | undefined): string | null {
if (!telefono) return null
const digitos = soloDigitos(telefono)
if (digitos.length >= 10) return digitos.slice(-10)
if (digitos.length >= 7) return digitos
return null
}
/**
* Mutex en memoria por clave (un sólo proceso Node): serializa la pareja
* validar-duplicado + crear para el mismo teléfono/email. Sin esto, dos
* envíos simultáneos (doble clic en dos pestañas, red lenta) pasaban ambos
* la validación y duplicaban el registro.
*/
const colasLock = new Map<string, Promise<unknown>>()
function conLock<T>(clave: string, fn: () => Promise<T>): Promise<T> {
const encolada = (colasLock.get(clave) ?? Promise.resolve()).then(fn, fn)
colasLock.set(clave, encolada)
const libera = () => {
if (colasLock.get(clave) === encolada) colasLock.delete(clave)
}
void encolada.then(libera, libera)
return encolada
}
/**
* `datosJson` con la unidad apartada dentro (mezcla, no reemplaza: conserva
* fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo
* devuelve el JSON original tal cual.
*/
function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): string | null {
const unidad = unidadCodigo?.trim()
if (!unidad) return datosJson
let datos: Record<string, unknown> = {}
if (datosJson) {
try {
datos = JSON.parse(datosJson) as Record<string, unknown>
} catch {
datos = {}
}
}
return JSON.stringify({ ...datos, unidadCodigo: unidad })
}
/**
* Etapas que cierran el ciclo de un registro: descartados, perdidos y
* cancelados. Ya NO bloquean una captura nueva con el mismo teléfono/email:
* tras una limpieza (o un descarte) el vendedor debe poder volver a capturar
* a esa persona. Verificado en vivo: restos de APARTADO cancelados bloqueaban
* re-capturas tras la última limpieza de leads.
*/
const ETAPAS_CERRADAS = ['Descartado', 'Perdido', 'Cancelo', 'Cancelacion Apartado']
/**
* Busca un contacto por teléfono normalizado (columna telefonoNorm) en
* CUALQUIER tablero, excluyendo etapas cerradas. Así el mismo número no se
* duplica entre tableros activos, pero los registros limpiados o descartados
* no estorban. La búsqueda anterior por `telefono contains` fallaba contra
* teléfonos guardados con espacios ("+52 664 225 7841" no contiene
* "6642257841") y dejaba pasar duplicados.
*/
async function buscarPorTelefono(telefonoNormalizado: string) {
return prisma.leadCaptura.findFirst({
where: { telefono: { contains: diezDigitos } },
where: { telefonoNorm: telefonoNormalizado, etapa: { notIn: ETAPAS_CERRADAS } },
orderBy: { createdAt: 'desc' },
})
}

View File

@@ -0,0 +1,496 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
desarrollo: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
modeloDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
reglaCotizadorDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
upsert: vi.fn(),
count: vi.fn(),
},
unidad: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
cotizacion: { count: vi.fn() },
leadCaptura: { count: vi.fn() },
cliente: { count: vi.fn() },
planPago: { count: vi.fn() },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import {
actualizarDesarrollo,
agregarModelo,
crearDesarrollo,
crearUnidades,
crearUnidadesSchema,
eliminarDesarrollo,
eliminarModelo,
eliminarUnidad,
guardarReglas,
listarDesarrollos,
listarDesarrollosPublicos,
obtenerDesarrolloPublico,
parametrosDesarrolloSchema,
} from './desarrollos.service.js'
/** Fila de Desarrollo tal como la devuelve Prisma (con relaciones) */
function filaDesarrollo(over: Record<string, unknown> = {}) {
return {
id: 'd-1',
clave: 'SENDA',
nombre: 'Senda',
estado: 'ACTIVO',
tipo: 'VERTICAL',
constructora: null,
direccion: null,
zona: 'Zapopan',
descripcion: null,
amenidades: 'alberca, gym',
fechaInicioObra: null,
fechaEntregaProgramada: new Date('2026-12-01T00:00:00.000Z'),
fechaEntregaReprogramada: null,
activoEnCotizador: true,
activoEnAlta: true,
prefijoUnidadPdf: 'CASA',
imagenUrl: null,
logoUrl: null,
createdAt: new Date('2026-09-21T00:00:00.000Z'),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
modelos: [],
reglas: null,
...over,
}
}
function filaUnidad(over: Record<string, unknown> = {}) {
return {
id: 'u-1',
codigo: 'SENDA-1',
desarrollo: 'SENDA',
modelo: 'HIMALAYA',
manzana: '',
lote: '',
nivel: '',
supConstruccion: 120,
supExcedente: 0,
supEstacionamiento: 1,
precioLista: 3000000,
precioAcabados: 3200000,
status: 'DISPONIBLE',
etapa: '',
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...over,
}
}
const REGLAS_VALIDAS = {
precioUnico: true,
fuenteDeN: 'fechaEntrega' as const,
nDefault: 6,
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [
{
tipo: 'CONTADO' as const,
claveDescuento: 'contado' as const,
titulo: 'Contado',
descuentoDefault: 0.02,
pctEnganche: 0.9,
engancheBase: 'final' as const,
pctPagoFinal: 0.1,
pagoFinalBase: 'final' as const,
modoMensualidades: 'ninguna' as const,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.count.mockResolvedValue(0)
prismaMock.cotizacion.count.mockResolvedValue(0)
prismaMock.leadCaptura.count.mockResolvedValue(0)
prismaMock.cliente.count.mockResolvedValue(0)
prismaMock.planPago.count.mockResolvedValue(0)
// create devuelve lo que recibe (más id/updatedAt), como Prisma
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: `id-${String(data['codigo'])}`,
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...data,
}))
})
// ── Fichas ────────────────────────────────────────────────────────────
describe('crearDesarrollo', () => {
it('rechaza una clave duplicada con 409', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
await expect(crearDesarrollo({ clave: 'SENDA', nombre: 'Senda' })).rejects.toMatchObject({
status: 409,
message: 'Ya existe un desarrollo con la clave SENDA',
})
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
})
it('normaliza la clave a mayúsculas y aplica los defaults', async () => {
prismaMock.desarrollo.findUnique
.mockResolvedValueOnce(null) // no existe la clave
.mockResolvedValueOnce(filaDesarrollo()) // lectura de la ficha creada
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
const ficha = await crearDesarrollo({ clave: ' senda ', nombre: 'Senda' })
expect(prismaMock.desarrollo.create).toHaveBeenCalledWith({
data: expect.objectContaining({
clave: 'SENDA',
nombre: 'Senda',
estado: 'ACTIVO',
tipo: 'VERTICAL',
activoEnCotizador: true,
activoEnAlta: true,
}),
})
expect(ficha.clave).toBe('SENDA')
})
})
describe('listarDesarrollos', () => {
it('cuenta unidades totales/disponibles y expone tieneReglas', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({ reglas: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedAt: new Date(), updatedPorId: null } }),
filaDesarrollo({ id: 'd-2', clave: 'SOLE', nombre: 'Sole' }),
])
prismaMock.unidad.findMany.mockResolvedValue([
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
{ desarrollo: 'SENDA', status: 'VENDIDO' },
{ desarrollo: 'SOLE', status: 'disponible' },
])
const { desarrollos } = await listarDesarrollos()
expect(desarrollos[0]).toMatchObject({ clave: 'SENDA', unidadesTotales: 2, unidadesDisponibles: 1, tieneReglas: true })
expect(desarrollos[1]).toMatchObject({ clave: 'SOLE', unidadesTotales: 1, unidadesDisponibles: 1, tieneReglas: false })
})
})
describe('actualizarDesarrollo', () => {
it('404 si el desarrollo no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(actualizarDesarrollo('nope', { nombre: 'X' })).rejects.toMatchObject({ status: 404 })
})
it('no deja cambiar la clave si tiene unidades', async () => {
prismaMock.desarrollo.findUnique
.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' })
.mockResolvedValueOnce(null) // clave destino libre
prismaMock.unidad.count.mockResolvedValue(4)
await expect(actualizarDesarrollo('d-1', { clave: 'SENDA2' })).rejects.toMatchObject({ status: 409 })
expect(prismaMock.desarrollo.update).not.toHaveBeenCalled()
})
})
// ── Borrado bloqueado ─────────────────────────────────────────────────
describe('eliminarDesarrollo', () => {
it('409 con el mensaje acordado si tiene inventario y operación', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
prismaMock.cotizacion.count.mockResolvedValue(7)
await expect(eliminarDesarrollo('d-1')).rejects.toMatchObject({
status: 409,
message: 'El desarrollo tiene 2 unidades y 7 registros de operación; desactívalo en vez de borrarlo',
})
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
})
it('borra si no hay inventario ni operación', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
await eliminarDesarrollo('d-1')
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
})
})
// ── Inventario ────────────────────────────────────────────────────────
describe('crearUnidades — generador', () => {
it('genera una unidad por número del rango con {clave} y {n}', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const resultado = await crearUnidades('d-1', {
generar: { patron: '{clave}-401-{n}', desde: 1, hasta: 3, precioLista: 2500000, supConstruccion: 88 },
})
expect(resultado.creadas).toBe(3)
expect(resultado.unidades.map((u) => u.codigo)).toEqual(['SENDA-401-1', 'SENDA-401-2', 'SENDA-401-3'])
expect(prismaMock.unidad.create).toHaveBeenCalledTimes(3)
expect(prismaMock.unidad.create).toHaveBeenCalledWith({
data: expect.objectContaining({
codigo: 'SENDA-401-1',
desarrollo: 'SENDA',
status: 'DISPONIBLE',
precioLista: 2500000,
supConstruccion: 88,
precioAcabados: 0,
}),
})
})
it('el esquema rechaza un patrón sin {n} y un body con ambos modos', () => {
expect(() => crearUnidadesSchema.parse({ generar: { patron: 'SENDA-401', desde: 1, hasta: 3, precioLista: 1 } })).toThrow()
expect(() =>
crearUnidadesSchema.parse({ unidades: [{ precioLista: 1 }], generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }),
).toThrow()
expect(() => crearUnidadesSchema.parse({})).toThrow()
})
it('rechaza un rango invertido', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
await expect(
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 5, hasta: 1, precioLista: 1 } }),
).rejects.toMatchObject({ status: 400 })
})
it('resuelve el modelo por id validando que sea del desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA' })
const resultado = await crearUnidades('d-1', {
generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-1', precioLista: 1 },
})
expect(resultado.unidades[0]?.modelo).toBe('HIMALAYA')
})
it('rechaza un modelo de otro desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-9', desarrolloId: 'd-9', nombre: 'AJENO' })
await expect(
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-9', precioLista: 1 } }),
).rejects.toMatchObject({ status: 400 })
})
})
describe('crearUnidades — alta manual y duplicados', () => {
it('arma el código con manzana y lote cuando no se envía', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const resultado = await crearUnidades('d-1', {
unidades: [{ manzana: '4', lote: '12', precioLista: 100, precioAcabados: 120 }],
})
expect(resultado.unidades[0]?.codigo).toBe('SENDA-4-12')
})
it('409 y no crea nada si un código ya existe en la BD', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany
.mockResolvedValueOnce([]) // codigos del desarrollo (correlativo)
.mockResolvedValueOnce([{ codigo: 'SENDA-9' }]) // ya existe
await expect(
crearUnidades('d-1', { unidades: [{ codigo: 'SENDA-9', precioLista: 1 }] }),
).rejects.toMatchObject({ status: 409, message: 'Códigos de unidad duplicados: SENDA-9' })
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
expect(prismaMock.$transaction).not.toHaveBeenCalled()
})
it('409 si el lote repetido aparece dos veces en la misma alta', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([])
await expect(
crearUnidades('d-1', {
unidades: [
{ codigo: 'SENDA-7', precioLista: 1 },
{ codigo: 'SENDA-7', precioLista: 2 },
],
}),
).rejects.toMatchObject({ status: 409 })
})
})
describe('eliminarUnidad', () => {
it('409 si la unidad tiene cotizaciones o ventas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
prismaMock.cotizacion.count.mockResolvedValue(2)
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 409 })
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
})
it('404 si la unidad no pertenece al desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad({ desarrollo: 'SOLANA' }))
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 404 })
})
it('borra una unidad libre', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
await eliminarUnidad('d-1', 'SENDA-1')
expect(prismaMock.unidad.delete).toHaveBeenCalledWith({ where: { codigo: 'SENDA-1' } })
})
})
// ── Modelos ───────────────────────────────────────────────────────────
describe('eliminarModelo', () => {
it('409 si el modelo tiene unidades', async () => {
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
id: 'm-1',
nombre: 'HIMALAYA',
desarrollo: { clave: 'SENDA' },
})
prismaMock.unidad.count.mockResolvedValue(79)
await expect(eliminarModelo('m-1')).rejects.toMatchObject({ status: 409 })
expect(prismaMock.modeloDesarrollo.delete).not.toHaveBeenCalled()
})
it('borra el modelo sin unidades', async () => {
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
id: 'm-1',
nombre: 'NUEVO',
desarrollo: { clave: 'SENDA' },
})
await eliminarModelo('m-1')
expect(prismaMock.modeloDesarrollo.delete).toHaveBeenCalledWith({ where: { id: 'm-1' } })
})
})
describe('agregarModelo', () => {
it('409 si el nombre ya existe en el desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1' })
await expect(agregarModelo('d-1', { nombre: 'HIMALAYA' })).rejects.toMatchObject({ status: 409 })
})
})
// ── Reglas del cotizador ──────────────────────────────────────────────
describe('parametrosDesarrolloSchema', () => {
it('acepta la forma real del cotizador', () => {
expect(parametrosDesarrolloSchema.parse(REGLAS_VALIDAS)).toMatchObject({ precioUnico: true })
})
it('rechaza porcentajes fuera de 0..1', () => {
const invalido = {
...REGLAS_VALIDAS,
propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], pctEnganche: 1.5 }],
}
expect(() => parametrosDesarrolloSchema.parse(invalido)).toThrow()
})
it('rechaza enums desconocidos y claves de más', () => {
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, fuenteDeN: 'otra' })).toThrow()
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], modoMensualidades: 'raro' }] })).toThrow()
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, extra: 1 })).toThrow()
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], descuentoDefault: -1 }] })).toThrow()
})
})
describe('guardarReglas', () => {
it('hace upsert del JSON validado y devuelve el objeto parseado', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
desarrolloId: 'd-1',
parametrosJson: JSON.stringify(REGLAS_VALIDAS),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
updatedPorId: 'u-admin',
})
const guardadas = await guardarReglas('d-1', REGLAS_VALIDAS, 'u-admin')
expect(prismaMock.reglaCotizadorDesarrollo.upsert).toHaveBeenCalledWith({
where: { desarrolloId: 'd-1' },
update: { parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
create: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
})
expect(guardadas.parametros.precioUnico).toBe(true)
expect(guardadas.updatedPorId).toBe('u-admin')
})
it('404 si el desarrollo no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(guardarReglas('nope', REGLAS_VALIDAS, null)).rejects.toMatchObject({ status: 404 })
})
})
// ── Público ───────────────────────────────────────────────────────────
describe('listarDesarrollosPublicos', () => {
it('solo publica activos no cerrados, con inventario disponible y sin datos personales', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({
modelos: [
{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
],
}),
])
prismaMock.unidad.findMany.mockResolvedValue([
filaUnidad({ codigo: 'SENDA-1', nivel: '4' }),
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 999 }),
])
const { desarrollos } = await listarDesarrollosPublicos()
const d = desarrollos[0]
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith({
where: { activoEnCotizador: true, estado: { not: 'CERRADO' } },
include: expect.anything(),
orderBy: { clave: 'asc' },
})
expect(d?.unidadesDisponibles).toEqual([
{
codigo: 'SENDA-1',
modelo: 'HIMALAYA',
nivel: '4',
precioLista: 3000000,
precioAcabados: 3200000,
supConstruccion: 120,
},
])
expect(d?.modelos[0]).toMatchObject({ nombre: 'HIMALAYA', disponibles: 1, precioDesde: 3000000 })
// Nunca consulta tablas con datos de clientes y la ficha no trae esos campos
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
const json = JSON.stringify(desarrollos)
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado']) {
expect(json.toLowerCase()).not.toContain(prohibido)
}
})
it('404 si la clave no existe o no es pública', async () => {
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
await expect(obtenerDesarrolloPublico('NOEXISTE')).rejects.toMatchObject({ status: 404 })
})
})

View File

@@ -0,0 +1,805 @@
import { z } from 'zod'
import { prisma } from './prisma.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
/**
* Módulo Desarrollos: la BD es la fuente de verdad del catálogo de
* desarrollos (ficha comercial, modelos de unidad y reglas del cotizador).
*
* Sustituye a las listas fijas del código (DESARROLLOS de
* config/campos-departamento.ts y las constantes de
* apps/cotizador/src/data/desarrollos.ts), que hoy siguen funcionando como
* espejo hasta que el resto de los frentes migre a esta tabla.
*
* El inventario sigue viviendo en `Unidad`, ligado por `desarrollo` = `clave`
* del desarrollo (el `codigo` de la unidad se forma con esa clave).
*/
/** Estados comerciales del desarrollo */
export const ESTADOS_DESARROLLO = ['ACTIVO', 'PREVENTA', 'CERRADO'] as const
/** VERTICAL = edificio (deptos); HORIZONTAL = casas en lote */
export const TIPOS_DESARROLLO = ['VERTICAL', 'HORIZONTAL'] as const
/** Formatos de propuesta del cotizador */
export const TIPOS_PROPUESTA = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
] as const
/** Claves de descuento editables del cotizador */
export const CLAVES_DESCUENTO = ['creditoBancario', 'creditoInfonavit', 'contado', 'semiContado'] as const
const CLAVE_DESARROLLO = /^[A-Z0-9-]{2,20}$/
// ── Validación de las reglas del cotizador ────────────────────────────
const fraccion = (campo: string) =>
z.number().min(0, `${campo} debe estar entre 0 y 1`).max(1, `${campo} debe estar entre 0 y 1`)
const propuestaSchema = z
.object({
tipo: z.enum(TIPOS_PROPUESTA),
claveDescuento: z.enum(CLAVES_DESCUENTO),
titulo: z.string().trim().min(1),
descuentoDefault: fraccion('descuentoDefault'),
pctEnganche: fraccion('pctEnganche'),
engancheBase: z.enum(['lista', 'final']),
pctPagoFinal: fraccion('pctPagoFinal'),
pagoFinalBase: z.enum(['lista', 'final']),
// 'porcentaje' es el modo de la mayoría de los desarrollos; sin él la
// config real del cotizador no valida (el brief lo omitía por error).
modoMensualidades: z.enum(['remanente', 'remanenteLista', 'porcentaje', 'ninguna']),
// SOLANA Infonavit: si el enganche es 0 y no hay apartado, no se resta
engancheCeroSinApartado: z.boolean().optional(),
})
.strict()
/**
* Forma del JSON de reglas: la config que antes vivía en
* apps/cotizador/src/data/desarrollos.ts. `id`/`nombre` se aceptan porque los
* objetos del cotizador los traen, pero la fuente de verdad del nombre es la
* ficha del desarrollo.
*/
export const parametrosDesarrolloSchema = z
.object({
id: z.string().trim().min(1).optional(),
nombre: z.string().trim().min(1).optional(),
precioUnico: z.boolean(),
fuenteDeN: z.enum(['fechaEntrega', 'fijo', 'porUnidad']),
// N fijo cuando fuenteDeN === 'fijo' (entrega inmediata)
nFijo: z.number().int().min(0).optional(),
nDefault: z.number().int().min(0),
// null => el cotizador muestra "PENDIENTE" (desarrollos ya entregados)
cuotaMantenimiento: z.number().min(0).nullable(),
prefijoUnidadPdf: z.string().trim().min(1),
propuestas: z.array(propuestaSchema).min(1, 'Se requiere al menos una propuesta'),
})
.strict()
export type ParametrosDesarrolloJson = z.infer<typeof parametrosDesarrolloSchema>
// ── Validación de la ficha ────────────────────────────────────────────
export const crearDesarrolloSchema = z
.object({
clave: z
.string()
.trim()
.regex(CLAVE_DESARROLLO, 'La clave debe ser mayúsculas sin espacios (2 a 20 caracteres: A-Z, 0-9, -)'),
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
estado: z.enum(ESTADOS_DESARROLLO).optional(),
tipo: z.enum(TIPOS_DESARROLLO).optional(),
constructora: z.string().trim().min(1).nullable().optional(),
direccion: z.string().trim().min(1).nullable().optional(),
zona: z.string().trim().min(1).nullable().optional(),
descripcion: z.string().trim().min(1).nullable().optional(),
amenidades: z.string().trim().min(1).nullable().optional(),
fechaInicioObra: z.coerce.date().nullable().optional(),
fechaEntregaProgramada: z.coerce.date().nullable().optional(),
fechaEntregaReprogramada: z.coerce.date().nullable().optional(),
activoEnCotizador: z.boolean().optional(),
activoEnAlta: z.boolean().optional(),
prefijoUnidadPdf: z.string().trim().min(1).nullable().optional(),
imagenUrl: z.string().trim().min(1).nullable().optional(),
logoUrl: z.string().trim().min(1).nullable().optional(),
})
.strict()
export const actualizarDesarrolloSchema = crearDesarrolloSchema.partial()
export const crearModeloSchema = z
.object({
nombre: z.string().trim().min(1, 'El nombre del modelo es obligatorio'),
recamaras: z.number().int().min(0).nullable().optional(),
banos: z.number().min(0).nullable().optional(),
estacionamientos: z.number().int().min(0).nullable().optional(),
supConstruccion: z.number().min(0).nullable().optional(),
activo: z.boolean().optional(),
})
.strict()
export const actualizarModeloSchema = crearModeloSchema.partial()
const unidadManualSchema = z
.object({
codigo: z.string().trim().min(1).optional(),
manzana: z.string().trim().optional(),
lote: z.string().trim().optional(),
nivel: z.string().trim().optional(),
modeloId: z.string().trim().min(1).optional(),
precioLista: z.number().min(0, 'precioLista es obligatorio'),
precioAcabados: z.number().min(0).optional(),
supConstruccion: z.number().min(0).optional(),
supExcedente: z.number().min(0).optional(),
supEstacionamiento: z.number().min(0).optional(),
})
.strict()
/** Tope del generador: un rango más grande huele a error de captura */
export const MAX_UNIDADES_POR_ALTA = 1000
const generarUnidadesSchema = z
.object({
patron: z
.string()
.trim()
.min(1)
.refine((p) => p.includes('{n}'), 'El patrón debe incluir {n} (el número de la unidad)')
// El código final sigue el formato de la clave: mayúsculas sin espacios
.refine((p) => !/[^A-Za-z0-9{}_-]/.test(p), 'El patrón solo admite letras, números, guiones, {clave} y {n}'),
desde: z.number().int(),
hasta: z.number().int(),
modeloId: z.string().trim().min(1).optional(),
precioLista: z.number().min(0),
precioAcabados: z.number().min(0).optional(),
supConstruccion: z.number().min(0).optional(),
})
.strict()
export const crearUnidadesSchema = z
.object({
unidades: z.array(unidadManualSchema).min(1).max(MAX_UNIDADES_POR_ALTA).optional(),
generar: generarUnidadesSchema.optional(),
})
.strict()
.refine((b) => (b.unidades === undefined) !== (b.generar === undefined), {
message: 'Envía `unidades` (alta manual) o `generar` (rango), pero no ambos',
})
export type CrearUnidadesBody = z.infer<typeof crearUnidadesSchema>
/** Edición de una unidad existente (PATCH): precios, m², ubicación y modelo */
export const actualizarUnidadSchema = z
.object({
manzana: z.string().trim().optional(),
lote: z.string().trim().optional(),
nivel: z.string().trim().optional(),
modeloId: z.string().trim().min(1).optional(),
precioLista: z.number().min(0).optional(),
precioAcabados: z.number().min(0).optional(),
supConstruccion: z.number().min(0).optional(),
supExcedente: z.number().min(0).optional(),
supEstacionamiento: z.number().min(0).optional(),
status: z.string().trim().min(1).optional(),
})
.strict()
export type ActualizarUnidadBody = z.infer<typeof actualizarUnidadSchema>
// ── Helpers ───────────────────────────────────────────────────────────
interface ModeloFila {
id: string
nombre: string
recamaras: number | null
banos: number | null
estacionamientos: number | null
supConstruccion: number | null
activo: boolean
}
function modeloDto(m: ModeloFila) {
return {
id: m.id,
nombre: m.nombre,
recamaras: m.recamaras,
banos: m.banos,
estacionamientos: m.estacionamientos,
supConstruccion: m.supConstruccion,
activo: m.activo,
}
}
function esDisponible(status: string): boolean {
return status.trim().toUpperCase() === 'DISPONIBLE'
}
/** Clave de desarrollo normalizada (la tabla Unidad la guarda en mayúsculas) */
export function normalizarClave(clave: string): string {
return clave.trim().toUpperCase()
}
/** Conteos por desarrollo a partir de una lectura del inventario */
function conteosPorDesarrollo(unidades: { desarrollo: string; status: string }[]): Map<string, { total: number; disponibles: number }> {
const mapa = new Map<string, { total: number; disponibles: number }>()
for (const u of unidades) {
const clave = normalizarClave(u.desarrollo)
const actual = mapa.get(clave) ?? { total: 0, disponibles: 0 }
actual.total++
if (esDisponible(u.status)) actual.disponibles++
mapa.set(clave, actual)
}
return mapa
}
function parsearReglas(regla: { parametrosJson: string; updatedAt: Date; updatedPorId: string | null } | null) {
if (regla === null) return null
let parametros: unknown
try {
parametros = JSON.parse(regla.parametrosJson)
} catch {
parametros = null
}
return {
parametros,
updatedAt: regla.updatedAt,
updatedPorId: regla.updatedPorId,
}
}
/** Desarrollo + relaciones tal como lo devuelve Prisma en las lecturas de ficha */
const INCLUDE_FICHA = { modelos: { orderBy: { nombre: 'asc' } }, reglas: true } as const
async function fichaPorId(id: string) {
const fila = await prisma.desarrollo.findUnique({ where: { id }, include: INCLUDE_FICHA })
if (fila === null) return null
const unidades = await prisma.unidad.findMany({
where: { desarrollo: fila.clave },
select: { status: true },
})
const porEstatus: Record<string, number> = {}
for (const u of unidades) {
const estatus = u.status.trim().toUpperCase() || 'SIN_ESTATUS'
porEstatus[estatus] = (porEstatus[estatus] ?? 0) + 1
}
return {
id: fila.id,
clave: fila.clave,
nombre: fila.nombre,
estado: fila.estado,
tipo: fila.tipo,
constructora: fila.constructora,
direccion: fila.direccion,
zona: fila.zona,
descripcion: fila.descripcion,
amenidades: fila.amenidades,
fechaInicioObra: fila.fechaInicioObra,
fechaEntregaProgramada: fila.fechaEntregaProgramada,
fechaEntregaReprogramada: fila.fechaEntregaReprogramada,
activoEnCotizador: fila.activoEnCotizador,
activoEnAlta: fila.activoEnAlta,
prefijoUnidadPdf: fila.prefijoUnidadPdf,
imagenUrl: fila.imagenUrl,
logoUrl: fila.logoUrl,
createdAt: fila.createdAt,
updatedAt: fila.updatedAt,
modelos: fila.modelos.map(modeloDto),
reglas: parsearReglas(fila.reglas),
unidadesTotales: unidades.length,
unidadesDisponibles: unidades.filter((u) => esDisponible(u.status)).length,
unidadesPorEstatus: porEstatus,
}
}
export type FichaDesarrollo = NonNullable<Awaited<ReturnType<typeof fichaPorId>>>
/** La ficha completa o 404 */
export async function obtenerDesarrollo(id: string): Promise<FichaDesarrollo> {
const ficha = await fichaPorId(id)
if (ficha === null) throw new NotFoundError('Desarrollo no encontrado')
return ficha
}
// ── Listado ───────────────────────────────────────────────────────────
export async function listarDesarrollos() {
const [desarrollos, unidades] = await Promise.all([
prisma.desarrollo.findMany({ include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
prisma.unidad.findMany({ select: { desarrollo: true, status: true } }),
])
const conteos = conteosPorDesarrollo(unidades)
return {
desarrollos: desarrollos.map((d) => {
const c = conteos.get(normalizarClave(d.clave)) ?? { total: 0, disponibles: 0 }
return {
id: d.id,
clave: d.clave,
nombre: d.nombre,
estado: d.estado,
tipo: d.tipo,
activoEnCotizador: d.activoEnCotizador,
activoEnAlta: d.activoEnAlta,
modelos: d.modelos.map(modeloDto),
unidadesTotales: c.total,
unidadesDisponibles: c.disponibles,
tieneReglas: d.reglas !== null,
}
}),
}
}
// ── Alta / cambio / borrado ───────────────────────────────────────────
export async function crearDesarrollo(data: z.infer<typeof crearDesarrolloSchema>) {
const clave = normalizarClave(data.clave)
const existente = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
if (existente !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
const fila = await prisma.desarrollo.create({
data: {
clave,
nombre: data.nombre,
estado: data.estado ?? 'ACTIVO',
tipo: data.tipo ?? 'VERTICAL',
constructora: data.constructora ?? null,
direccion: data.direccion ?? null,
zona: data.zona ?? null,
descripcion: data.descripcion ?? null,
amenidades: data.amenidades ?? null,
fechaInicioObra: data.fechaInicioObra ?? null,
fechaEntregaProgramada: data.fechaEntregaProgramada ?? null,
fechaEntregaReprogramada: data.fechaEntregaReprogramada ?? null,
activoEnCotizador: data.activoEnCotizador ?? true,
activoEnAlta: data.activoEnAlta ?? true,
prefijoUnidadPdf: data.prefijoUnidadPdf ?? null,
imagenUrl: data.imagenUrl ?? null,
logoUrl: data.logoUrl ?? null,
},
})
return obtenerDesarrollo(fila.id)
}
export async function actualizarDesarrollo(id: string, data: z.infer<typeof actualizarDesarrolloSchema>) {
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
// Solo se actualizan los campos enviados (los undefined se descartan)
const cambios: Record<string, unknown> = {}
for (const [campo, valor] of Object.entries(data)) {
if (valor !== undefined) cambios[campo] = valor
}
if (data.clave !== undefined) {
const clave = normalizarClave(data.clave)
if (clave !== actual.clave) {
const ocupada = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
if (ocupada !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
// El inventario se liga por clave: renombrar la clave sin arrastrar las
// unidades las dejaría huérfanas, así que solo se permite sin unidades.
const unidades = await prisma.unidad.count({ where: { desarrollo: actual.clave } })
if (unidades > 0) {
throw new AppError(409, `El desarrollo tiene ${unidades} unidades; no se puede cambiar la clave`)
}
}
cambios['clave'] = clave
}
await prisma.desarrollo.update({ where: { id }, data: cambios as never })
return obtenerDesarrollo(id)
}
/** Registros de operación ligados al desarrollo: impiden borrarlo */
async function contarRegistrosDeOperacion(clave: string, codigos: string[]): Promise<number> {
const porUnidad = codigos.length > 0 ? { unidadCodigo: { in: codigos } } : null
const [cotizaciones, leads, clientes, planes] = await Promise.all([
prisma.cotizacion.count({ where: porUnidad === null ? { desarrollo: clave } : { OR: [{ desarrollo: clave }, porUnidad] } }),
prisma.leadCaptura.count({ where: { desarrollo: clave } }),
prisma.cliente.count({ where: { proyecto: clave } }),
prisma.planPago.count({ where: { proyecto: clave } }),
])
return cotizaciones + leads + clientes + planes
}
export async function eliminarDesarrollo(id: string): Promise<void> {
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
const unidades = await prisma.unidad.findMany({ where: { desarrollo: actual.clave }, select: { codigo: true } })
const operacion = await contarRegistrosDeOperacion(
actual.clave,
unidades.map((u) => u.codigo),
)
if (unidades.length > 0 || operacion > 0) {
throw new AppError(
409,
`El desarrollo tiene ${unidades.length} unidades y ${operacion} registros de operación; desactívalo en vez de borrarlo`,
)
}
await prisma.desarrollo.delete({ where: { id } })
}
// ── Modelos ───────────────────────────────────────────────────────────
export async function agregarModelo(desarrolloId: string, data: z.infer<typeof crearModeloSchema>) {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const repetido = await prisma.modeloDesarrollo.findUnique({
where: { desarrolloId_nombre: { desarrolloId, nombre: data.nombre } },
select: { id: true },
})
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
return prisma.modeloDesarrollo.create({
data: {
desarrolloId,
nombre: data.nombre,
recamaras: data.recamaras ?? null,
banos: data.banos ?? null,
estacionamientos: data.estacionamientos ?? null,
supConstruccion: data.supConstruccion ?? null,
activo: data.activo ?? true,
},
})
}
export async function actualizarModelo(modeloId: string, data: z.infer<typeof actualizarModeloSchema>) {
const actual = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
if (actual === null) throw new NotFoundError('Modelo no encontrado')
if (data.nombre !== undefined && data.nombre !== actual.nombre) {
const repetido = await prisma.modeloDesarrollo.findUnique({
where: { desarrolloId_nombre: { desarrolloId: actual.desarrolloId, nombre: data.nombre } },
select: { id: true },
})
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
}
const cambios: Record<string, unknown> = {}
for (const [campo, valor] of Object.entries(data)) {
if (valor !== undefined) cambios[campo] = valor
}
return prisma.modeloDesarrollo.update({ where: { id: modeloId }, data: cambios as never })
}
export async function eliminarModelo(modeloId: string): Promise<void> {
const actual = await prisma.modeloDesarrollo.findUnique({
where: { id: modeloId },
include: { desarrollo: { select: { clave: true } } },
})
if (actual === null) throw new NotFoundError('Modelo no encontrado')
const unidades = await prisma.unidad.count({
where: { desarrollo: actual.desarrollo.clave, modelo: actual.nombre },
})
if (unidades > 0) {
throw new AppError(409, `El modelo ${actual.nombre} tiene ${unidades} unidades; no se puede eliminar`)
}
await prisma.modeloDesarrollo.delete({ where: { id: modeloId } })
}
// ── Reglas del cotizador ──────────────────────────────────────────────
export async function guardarReglas(desarrolloId: string, parametros: ParametrosDesarrolloJson, usuarioId: string | null) {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const parametrosJson = JSON.stringify(parametros)
const regla = await prisma.reglaCotizadorDesarrollo.upsert({
where: { desarrolloId },
update: { parametrosJson, updatedPorId: usuarioId },
create: { desarrolloId, parametrosJson, updatedPorId: usuarioId },
})
return {
desarrolloId: regla.desarrolloId,
parametros: JSON.parse(regla.parametrosJson) as ParametrosDesarrolloJson,
updatedAt: regla.updatedAt,
updatedPorId: regla.updatedPorId,
}
}
// ── Inventario ────────────────────────────────────────────────────────
interface UnidadNueva {
codigo: string
modelo: string
manzana: string
lote: string
nivel: string
precioLista: number
precioAcabados: number
supConstruccion: number
supExcedente: number
supEstacionamiento: number
status: string
}
/** Modelo del desarrollo por id (valida que pertenezca al desarrollo) */
async function nombreModelo(desarrolloId: string, modeloId: string): Promise<string> {
const modelo = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
if (modelo === null || modelo.desarrolloId !== desarrolloId) {
throw new BadRequestError('El modelo no pertenece a este desarrollo')
}
return modelo.nombre
}
/** Correlativo siguiente de `${clave}-n` en el inventario del desarrollo */
function correlativoDesdeCodigos(clave: string, codigos: string[]): number {
const patron = new RegExp(`^${clave}-(\\d+)$`)
let max = 0
for (const codigo of codigos) {
const m = patron.exec(codigo)
if (m === null) continue
const n = Number(m[1])
if (n > max) max = n
}
return max
}
export async function crearUnidades(
desarrolloId: string,
body: CrearUnidadesBody,
): Promise<{ creadas: number; unidades: UnidadNueva[] }> {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true, clave: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const clave = desarrollo.clave
let filas: UnidadNueva[]
if (body.generar !== undefined) {
const g = body.generar
if (g.hasta < g.desde) throw new BadRequestError('El rango es inválido: `hasta` debe ser mayor o igual a `desde`')
if (g.hasta - g.desde + 1 > MAX_UNIDADES_POR_ALTA) {
throw new BadRequestError(`El rango genera más de ${MAX_UNIDADES_POR_ALTA} unidades`)
}
const modelo = g.modeloId !== undefined ? await nombreModelo(desarrolloId, g.modeloId) : ''
filas = []
for (let n = g.desde; n <= g.hasta; n++) {
const codigo = g.patron.replaceAll('{clave}', clave).replaceAll('{n}', String(n))
filas.push({
codigo,
modelo,
manzana: '',
lote: '',
nivel: '',
precioLista: g.precioLista,
precioAcabados: g.precioAcabados ?? 0,
supConstruccion: g.supConstruccion ?? 0,
supExcedente: 0,
supEstacionamiento: 0,
status: 'DISPONIBLE',
})
}
} else {
const manuales = body.unidades ?? []
const existentes = await prisma.unidad.findMany({ where: { desarrollo: clave }, select: { codigo: true } })
let correlativo = correlativoDesdeCodigos(clave, existentes.map((u) => u.codigo))
filas = []
for (const u of manuales) {
const modelo = u.modeloId !== undefined ? await nombreModelo(desarrolloId, u.modeloId) : ''
// Sin código explícito se arma con manzana/lote o con un correlativo
const codigo =
u.codigo ??
(u.manzana !== undefined && u.manzana !== '' && u.lote !== undefined && u.lote !== ''
? `${clave}-${u.manzana}-${u.lote}`
: `${clave}-${++correlativo}`)
filas.push({
codigo,
modelo,
manzana: u.manzana ?? '',
lote: u.lote ?? '',
nivel: u.nivel ?? '',
precioLista: u.precioLista,
precioAcabados: u.precioAcabados ?? 0,
supConstruccion: u.supConstruccion ?? 0,
supExcedente: u.supExcedente ?? 0,
supEstacionamiento: u.supEstacionamiento ?? 0,
status: 'DISPONIBLE',
})
}
}
const codigos = filas.map((f) => f.codigo)
const repetidosEnLote = [...new Set(codigos.filter((c, i) => codigos.indexOf(c) !== i))]
const yaEnBd =
codigos.length > 0
? (await prisma.unidad.findMany({ where: { codigo: { in: codigos } }, select: { codigo: true } })).map((u) => u.codigo)
: []
const duplicados = [...new Set([...repetidosEnLote, ...yaEnBd])]
if (duplicados.length > 0) {
throw new AppError(409, `Códigos de unidad duplicados: ${duplicados.join(', ')}`)
}
const creadas = await prisma.$transaction(filas.map((f) => prisma.unidad.create({ data: { ...f, desarrollo: clave } })))
return {
creadas: creadas.length,
unidades: creadas.map((u) => ({
codigo: u.codigo,
modelo: u.modelo,
manzana: u.manzana,
lote: u.lote,
nivel: u.nivel,
precioLista: u.precioLista,
precioAcabados: u.precioAcabados,
supConstruccion: u.supConstruccion,
supExcedente: u.supExcedente,
supEstacionamiento: u.supEstacionamiento,
status: u.status,
})),
}
}
export async function eliminarUnidad(desarrolloId: string, codigo: string): Promise<void> {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { clave: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const unidad = await prisma.unidad.findUnique({ where: { codigo } })
if (unidad === null || normalizarClave(unidad.desarrollo) !== normalizarClave(desarrollo.clave)) {
throw new NotFoundError(`Unidad ${codigo} no encontrada en el desarrollo`)
}
const [cotizaciones, planes, clientes] = await Promise.all([
prisma.cotizacion.count({ where: { unidadCodigo: codigo } }),
prisma.planPago.count({ where: { unidadCodigo: codigo } }),
prisma.cliente.count({ where: { unidadCodigo: codigo } }),
])
const operacion = cotizaciones + planes + clientes
if (operacion > 0) {
throw new AppError(409, `La unidad ${codigo} tiene ${operacion} cotizaciones o ventas; no se puede eliminar`)
}
await prisma.unidad.delete({ where: { codigo } })
}
/**
* PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m²,
* ubicación, modelo y estatus de una unidad existente.
*/
export async function actualizarUnidad(
desarrolloId: string,
codigo: string,
cambios: ActualizarUnidadBody,
) {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { clave: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const unidad = await prisma.unidad.findUnique({ where: { codigo } })
if (unidad === null || normalizarClave(unidad.desarrollo) !== normalizarClave(desarrollo.clave)) {
throw new NotFoundError(`Unidad ${codigo} no encontrada en el desarrollo`)
}
// modeloId → nombre del modelo en la tabla Unidad
const data: Record<string, unknown> = {}
for (const clave of ['manzana', 'lote', 'nivel', 'precioLista', 'precioAcabados', 'supConstruccion', 'supExcedente', 'supEstacionamiento', 'status'] as const) {
const valor = cambios[clave]
if (valor !== undefined) data[clave] = valor
}
if (cambios.modeloId !== undefined) {
const modelo = await prisma.modeloDesarrollo.findUnique({ where: { id: cambios.modeloId }, select: { nombre: true } })
if (modelo === null) throw new NotFoundError('Modelo no encontrado')
data['modelo'] = modelo.nombre
}
if (Object.keys(data).length === 0) throw new BadRequestError('No hay cambios que aplicar')
return prisma.unidad.update({ where: { codigo }, data })
}
// ── Público (micrositio, sin auth) ────────────────────────────────────
/**
* Ficha pública: SOLO información comercial del desarrollo y su inventario
* disponible. Nunca clientes, cotizaciones ni datos personales.
*/
function fichaPublica(
desarrollo: {
clave: string
nombre: string
estado: string
tipo: string
zona: string | null
direccion: string | null
descripcion: string | null
amenidades: string | null
imagenUrl: string | null
logoUrl: string | null
fechaEntregaProgramada: Date | null
modelos: { nombre: string; recamaras: number | null; banos: number | null; estacionamientos: number | null; supConstruccion: number | null; activo: boolean }[]
},
unidades: {
codigo: string
modelo: string
nivel: string
precioLista: number
precioAcabados: number
supConstruccion: number
}[],
) {
const disponiblesPorModelo = new Map<string, { n: number; precioDesde: number | null }>()
for (const u of unidades) {
const actual = disponiblesPorModelo.get(u.modelo) ?? { n: 0, precioDesde: null }
actual.n++
if (u.precioLista > 0 && (actual.precioDesde === null || u.precioLista < actual.precioDesde)) {
actual.precioDesde = u.precioLista
}
disponiblesPorModelo.set(u.modelo, actual)
}
return {
clave: desarrollo.clave,
nombre: desarrollo.nombre,
estado: desarrollo.estado,
tipo: desarrollo.tipo,
zona: desarrollo.zona,
direccion: desarrollo.direccion,
descripcion: desarrollo.descripcion,
amenidades: desarrollo.amenidades,
imagenUrl: desarrollo.imagenUrl,
logoUrl: desarrollo.logoUrl,
fechaEntregaProgramada: desarrollo.fechaEntregaProgramada,
modelos: desarrollo.modelos
.filter((m) => m.activo)
.map((m) => {
const disp = disponiblesPorModelo.get(m.nombre) ?? { n: 0, precioDesde: null }
return {
nombre: m.nombre,
recamaras: m.recamaras,
banos: m.banos,
estacionamientos: m.estacionamientos,
supConstruccion: m.supConstruccion,
disponibles: disp.n,
precioDesde: disp.precioDesde,
}
}),
unidadesDisponibles: unidades.map((u) => ({
codigo: u.codigo,
modelo: u.modelo,
precioLista: u.precioLista,
precioAcabados: u.precioAcabados,
supConstruccion: u.supConstruccion,
...(u.nivel !== '' ? { nivel: u.nivel } : {}),
})),
}
}
/** Filtro de publicación: activo en cotizador y no cerrado */
const DONDE_PUBLICO = { activoEnCotizador: true, estado: { not: 'CERRADO' } } as const
/** Inventario publicado de un desarrollo (solo unidades DISPONIBLE) */
async function unidadesPublicas(clave: string) {
const filas = await prisma.unidad.findMany({
where: { desarrollo: clave },
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
orderBy: { codigo: 'asc' },
})
return filas.filter((u) => esDisponible(u.status))
}
export async function listarDesarrollosPublicos() {
const [desarrollos, unidades] = await Promise.all([
prisma.desarrollo.findMany({ where: DONDE_PUBLICO, include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
prisma.unidad.findMany({
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
orderBy: { codigo: 'asc' },
}),
])
return {
desarrollos: desarrollos.map((d) =>
fichaPublica(
d,
unidades
.filter((u) => normalizarClave(u.desarrollo) === normalizarClave(d.clave) && esDisponible(u.status))
.map((u) => ({ codigo: u.codigo, modelo: u.modelo, nivel: u.nivel, precioLista: u.precioLista, precioAcabados: u.precioAcabados, supConstruccion: u.supConstruccion })),
),
),
}
}
export async function obtenerDesarrolloPublico(claveCruda: string) {
const clave = normalizarClave(claveCruda)
const fila = await prisma.desarrollo.findFirst({ where: { clave, ...DONDE_PUBLICO }, include: INCLUDE_FICHA })
if (fila === null) throw new NotFoundError('Desarrollo no encontrado')
const unidades = await unidadesPublicas(fila.clave)
return fichaPublica(fila, unidades)
}

View File

@@ -137,6 +137,8 @@ describe('obtenerScores', () => {
where: {
board: { in: ['LEADS', 'OPORTUNIDADES'] },
usuarioId: 'u1',
// Los cerrados (descartados/perdidos) no reciben score
etapa: { notIn: ['Descartado', 'Perdido'] },
},
})
})

View File

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

View File

@@ -5,6 +5,8 @@
* resuelve por el usuarioId de cada fila.
*/
import { camposAfluenciaFaltantes } from '../config/vocabulario.js'
// Estados del board Leads en los que el lead ya no es trabajo activo del vendedor
export const ESTADOS_TERMINALES_LEAD = ['DESCARTADO', 'CALIFICADO', 'ABANDONO']
@@ -73,6 +75,8 @@ export interface LeadMiDia {
/** Vendedor asignado (nombre); '' si no tiene */
vendedor: string
updatedAt: string
/** Fecha de alta (ISO) */
creadoEn: string
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
fuente: 'lead' | 'oportunidad'
/** Último cambio conocido; null si no hay */
@@ -85,6 +89,8 @@ export interface ActividadMiDia {
tipo: string
fecha: string
estado: string
/** Captura (lead/oportunidad/apartado) a la que pertenece la actividad; null si no está ligada */
leadCapturaId: string | null
}
export interface ApartadoPorVencer {
@@ -127,10 +133,14 @@ export interface OportunidadMiDia {
modeloInteres: string
unidadCodigo: string
updatedAt: string
/** Fecha de alta (ISO) */
creadoEn: string
/** Último cambio conocido; null si no hay */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ej. '2026-09-10T14:30' o '2026-09-10') */
fechaCita?: string | undefined
/** ¿Afluencia con perfil completo? Sin esto no puede avanzar a 'Aparto' */
afluenciaCompleta: boolean
}
/** Apartado asignado al vendedor (tab "Mis apartados" de Mi día) */
@@ -146,10 +156,32 @@ export interface ApartadoMiDia {
unidadCodigo: string
estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
/** Fecha de alta (ISO) */
creadoEn: string
/** Último cambio conocido; null si no hay */
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
}
/**
* Registro descartado/abandonado (tab "Descartados" de Mi día, solo admin):
* leads descartados, oportunidades perdidas/abandonadas y apartados
* cancelados. Reasignable a cualquier vendedor.
*/
export interface DescartadoMiDia {
id: string
nombre: string
telefono: string
/** De dónde viene: lead | oportunidad | apartado */
fuente: 'lead' | 'oportunidad' | 'apartado'
etapa: string
motivo: string
/** Vendedor asignado (nombre); '' si no tiene */
vendedor: string
/** Fecha de alta (ISO) */
creadoEn: string
updatedAt: string
}
export interface ResumenMiDia {
generadoEn: string
misLeads: LeadMiDia[]
@@ -165,6 +197,8 @@ export interface ResumenMiDia {
misApartados: ApartadoMiDia[]
/** Todos los apartados incluyendo terminales (para Escrituración) */
todosApartados: ApartadoMiDia[]
/** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
descartados: DescartadoMiDia[]
conteos: {
leadsNuevosSemana: number // míos, últimos 7 días naturales
actividadesAbiertas: number // mías, Estado Abierto
@@ -206,6 +240,8 @@ export interface FilaLocalMiDia {
usuarioId: string
/** Item heredado del CRM anterior (compatibilidad de datos; null en capturas locales) */
mondayItemId: string | null
/** Motivo de descarte/cancelación (columna motivo de LeadCaptura) */
motivo: string | null
pendienteSync: boolean
createdAt: Date
updatedAt: Date
@@ -296,6 +332,7 @@ export function resumenMiDiaLocal(
desarrollo: c.desarrollo ?? '',
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
updatedAt: c.updatedAt.toISOString(),
creadoEn: c.createdAt.toISOString(),
fuente: 'lead' as const,
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
pendienteSync: c.pendienteSync,
@@ -330,12 +367,15 @@ export function resumenMiDiaLocal(
email: c.email ?? '',
etapa: c.etapa,
desarrollo: c.desarrollo ?? '',
origen: c.origen ?? '',
modeloInteres: c.modelo ?? '',
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
unidadCodigo,
updatedAt: c.updatedAt.toISOString(),
creadoEn: c.createdAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
fechaCita,
afluenciaCompleta: camposAfluenciaFaltantes(c.datosJson).length === 0,
pendienteSync: c.pendienteSync,
}
})
@@ -360,6 +400,7 @@ export function resumenMiDiaLocal(
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
creadoEn: c.createdAt.toISOString(),
ultimoCambio: ultimoCambioLocal(c, nombrePorUsuarioId.get(c.usuarioId) ?? ''),
pendienteSync: c.pendienteSync,
}))
@@ -381,6 +422,7 @@ export function resumenMiDiaLocal(
unidadCodigo: unidadPorApartadoId.get(c.leadId) ?? '',
estado: c.etapa,
fechaLimite: c.fechaLimite ? c.fechaLimite.toISOString().slice(0, 10) : '',
creadoEn: c.createdAt.toISOString(),
ultimoCambio: null,
pendienteSync: c.pendienteSync,
}))
@@ -449,6 +491,7 @@ export function resumenMiDiaLocal(
tipo: a.tipo,
fecha: a.fechaVencimiento ? a.fechaVencimiento.toISOString().slice(0, 10) : '',
estado: a.estado,
leadCapturaId: a.leadCapturaId,
}))
const todasMisActividades: ActividadMiDia[] = actividadesAbiertasOrdenadas
const misActividadesHoy: ActividadMiDia[] = actividadesAbiertasOrdenadas
@@ -529,6 +572,28 @@ export function resumenMiDiaLocal(
}
}
// --- Descartados/abandonados (tab "Descartados", solo admin) ---
// Leads DESCARTADO/ABANDONO, oportunidades PERDIDO/ABANDONO y apartados
// cancelados. Reasignables a cualquier vendedor desde la UI.
const esDescartado = (etapa: string): boolean => {
const e = etapa.trim().toUpperCase()
return e.includes('DESCART') || e.includes('PERDID') || e.includes('ABANDONO') || e.includes('CANCEL')
}
const descartados: DescartadoMiDia[] = capturas
.filter((c) => esDescartado(c.etapa))
.map((c) => ({
id: c.leadId,
nombre: c.nombreLead,
telefono: c.telefono ?? '',
fuente: c.board === 'LEADS' ? ('lead' as const) : c.board === 'OPORTUNIDADES' ? ('oportunidad' as const) : ('apartado' as const),
etapa: c.etapa,
motivo: c.motivo ?? '',
vendedor: nombrePorUsuarioId.get(c.usuarioId) ?? '',
creadoEn: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
}))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
return {
generadoEn: ahora.toISOString(),
misLeads,
@@ -541,6 +606,7 @@ export function resumenMiDiaLocal(
misApartados,
// Todos los apartados incluyendo terminales (para Escrituración)
todosApartados: todosApartadosLocal,
descartados,
conteos: {
leadsNuevosSemana: [...leadsLocales, ...oportunidadesLocales].filter(
(c) => c.createdAt.getTime() >= hace7DiasMs,

View File

@@ -8,6 +8,10 @@ const prismaMock = vi.hoisted(() => ({
update: vi.fn(),
delete: vi.fn(),
},
actividadLocal: {
create: vi.fn(),
updateMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -55,6 +59,17 @@ describe('RecordatorioVendedorService', () => {
fechaRecordar: new Date('2026-09-10T10:00:00'),
},
})
// La tarea también se agenda en el calendario del vendedor
expect(prismaMock.actividadLocal.create).toHaveBeenCalledWith({
data: {
titulo: 'Llamar a Juan',
descripcion: 'Sobre cotización',
usuarioId,
leadCapturaId: null,
fechaVencimiento: new Date('2026-09-10T10:00:00'),
tipo: 'TAREA',
},
})
})
})
@@ -87,13 +102,17 @@ describe('RecordatorioVendedorService', () => {
})
describe('completar', () => {
it('marca como completado un recordatorio existente', async () => {
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' })
it('marca como completado un recordatorio existente y cierra su tarea del calendario', async () => {
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1', leadId: 'loc_1', usuarioId })
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
const result = await svc.completar('r1')
expect(result.completado).toBe(true)
expect(prismaMock.actividadLocal.updateMany).toHaveBeenCalledWith({
where: { leadCapturaId: 'loc_1', usuarioId, tipo: 'TAREA', estado: 'ABIERTA' },
data: { estado: 'CERRADA', fechaCierre: expect.any(Date) },
})
expect(prismaMock.recordatorioVendedor.update).toHaveBeenCalledWith({
where: { id: 'r1' },
data: { completado: true },

View File

@@ -31,7 +31,7 @@ export interface OpcionesListarRecordatorio {
export class RecordatorioVendedorService {
async crear(usuarioId: string, data: DatosCrearRecordatorio) {
return prisma.recordatorioVendedor.create({
const recordatorio = await prisma.recordatorioVendedor.create({
data: {
usuarioId,
titulo: data.titulo.trim(),
@@ -41,6 +41,20 @@ export class RecordatorioVendedorService {
fechaRecordar: new Date(data.fechaRecordar),
},
})
// La tarea también vive en el calendario del vendedor: se crea la
// ActividadLocal ligada al cliente para que aparezca en "Mis actividades"
// y en el calendario de Mi día.
await prisma.actividadLocal.create({
data: {
titulo: recordatorio.titulo,
descripcion: recordatorio.descripcion,
usuarioId,
leadCapturaId: recordatorio.leadId,
fechaVencimiento: recordatorio.fechaRecordar,
tipo: 'TAREA',
},
})
return recordatorio
}
async listar(usuarioId: string, opciones: OpcionesListarRecordatorio = {}) {
@@ -67,9 +81,39 @@ export class RecordatorioVendedorService {
})
}
/**
* Notas de la bitácora de un cliente: visibles para TODOS los usuarios
* (así lo pidió operación: las notas hechas en la bitácora las debe ver
* cualquier usuario que abra el cliente). Filtra por lead y/o
* clienteClave, sin restricción de usuario.
*/
async listarPorCliente(filtro: { leadId?: string | null; clienteClave?: string | null }) {
const or: Record<string, unknown>[] = []
if (filtro.leadId) or.push({ leadId: filtro.leadId })
if (filtro.clienteClave) or.push({ clienteClave: filtro.clienteClave })
if (or.length === 0) return []
return prisma.recordatorioVendedor.findMany({
where: { OR: or },
orderBy: { fechaRecordar: 'asc' },
})
}
async completar(id: string) {
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
if (!existente) throw new NotFoundError('Recordatorio no encontrado')
// Cierra también la tarea del calendario (ActividadLocal abierta ligada al
// mismo cliente y vendedor) para que desaparezca de "Mis actividades".
if (existente.leadId !== null) {
await prisma.actividadLocal.updateMany({
where: {
leadCapturaId: existente.leadId,
usuarioId: existente.usuarioId,
tipo: 'TAREA',
estado: 'ABIERTA',
},
data: { estado: 'CERRADA', fechaCierre: new Date() },
})
}
return prisma.recordatorioVendedor.update({
where: { id },
data: { completado: true },

View File

@@ -1,4 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
// La pasada consulta un admin de respaldo para los planes sin vendedor
// (antes caían a 'sin-destinatario' y nadie los veía).
const prismaMock = vi.hoisted(() => ({ usuario: { findFirst: vi.fn() } }))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import type { Pago, PlanPago } from './cobranza.service.js'
import {
RecordatoriosCobranzaService,
@@ -231,7 +238,29 @@ describe('RecordatoriosCobranzaService.ejecutarPasada', () => {
expect(log).toHaveLength(1)
})
it('sin destinatario (plan sin vendedor): solo registra en el log, sin canal', async () => {
it('sin destinatario (plan sin vendedor): el recordatorio cae al administrador de respaldo', async () => {
prismaMock.usuario.findFirst.mockResolvedValue({ id: 'admin-qa' })
const planesSinVendedor = [
plan({
id: 'p1',
vendedorId: null,
pagos: [pago({ id: 'pg1', fechaVencimiento: isoMasDias(HOY, 2) })],
}),
]
const { store, log } = storeFake(planesSinVendedor)
const { canal, envios } = canalFake()
const svc = new RecordatoriosCobranzaService(store, [canal])
const r = await svc.ejecutarPasada({ ahora: HOY })
expect(r.enviados).toHaveLength(1)
expect(r.enviados[0]).toMatchObject({ canal: 'fake', destinatario: 'admin-qa' })
expect(envios).toHaveLength(1)
expect(envios[0].destinatario).toMatchObject({ vendedorId: 'admin-qa', origen: 'admin-fallback' })
expect(log).toHaveLength(1)
})
it('sin destinatario y SIN admin en el sistema: solo registra en el log, sin canal', async () => {
prismaMock.usuario.findFirst.mockResolvedValue(null)
const planesSinVendedor = [
plan({
id: 'p1',

View File

@@ -130,10 +130,11 @@ export function debeEnviar(
export interface DestinatarioRecordatorio {
/** usuarioId del vendedor responsable; null = no hay a quién asignar */
vendedorId: string | null
origen: 'vendedor' | 'ninguno'
origen: 'vendedor' | 'ninguno' | 'admin-fallback'
}
/** Regla pura de selección: vendedor del plan → nadie */
/** Regla pura de selección: vendedor del plan → nadie (el fallback a admin se
* resuelve en la pasada, que tiene acceso a la BD). */
export function elegirDestinatario(vendedorId: string | null): DestinatarioRecordatorio {
if (vendedorId !== null) return { vendedorId, origen: 'vendedor' }
return { vendedorId: null, origen: 'ninguno' }
@@ -218,9 +219,21 @@ export class RecordatoriosCobranzaService {
const planes = await this.store.cargarPlanes()
const candidatos = candidatosRecordatorio(planes, ahora, diasAntes)
// Planes sin vendedor asignado (p. ej. cartera importada sin asesor): el
// recordatorio cae al administrador para que no se pierda y pueda
// reasignar el plan. Antes quedaban en 'sin-destinatario' y nadie los veía.
const adminFallback = await prisma.usuario.findFirst({
where: { rol: 'admin', activo: true },
orderBy: { createdAt: 'asc' },
select: { id: true },
})
const resultado: ResultadoPasada = { dryRun, evaluados: [], enviados: [], fallidos: [] }
for (const c of candidatos) {
const destinatario = elegirDestinatario(c.vendedorId)
let destinatario = elegirDestinatario(c.vendedorId)
if (destinatario.origen === 'ninguno' && adminFallback) {
destinatario = { vendedorId: adminFallback.id, origen: 'admin-fallback' }
}
const ultimo = await this.store.ultimoRecordatorio(c.pagoId, c.tipo)
if (!debeEnviar(ultimo, c.tipo, ahora, revolverDias)) {

View File

@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado (los tests no tocan BD) ----------
// `reglaCotizadorDesarrollo` y `desarrollo` se activan/desactivan por test:
// simulan el cliente Prisma con los modelos generados o sin ellos (lectura
// cruda de las tablas).
const prismaMock = vi.hoisted(() => ({
$queryRawUnsafe: vi.fn(),
reglaCotizadorDesarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
desarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import { DESARROLLOS, type ParametrosDesarrollo } from './cotizacion.service.js'
import {
getReglasDesarrollo,
getReglasEfectivas,
getReglasGuardadas,
normalizarReglas,
} from './reglas-desarrollo.service.js'
/** Reglas con la forma exacta que guarda la BD (JSON serializado) */
function reglasJson(params: ParametrosDesarrollo, extras: Record<string, unknown> = {}): string {
return JSON.stringify({
...params,
...extras,
propuestas: params.propuestas.map((p) => ({ ...p, activa: true })),
})
}
function usarModeloPrisma(): ReturnType<typeof vi.fn> {
const findMany = vi.fn()
prismaMock.reglaCotizadorDesarrollo = { findMany }
return findMany
}
/** Ficha del desarrollo (tabla Desarrollo) para el catálogo del servicio */
function ficha(id: string, clave: string, extras: Record<string, unknown> = {}) {
return {
id,
clave,
nombre: clave.charAt(0) + clave.slice(1).toLowerCase(),
activoEnCotizador: true,
prefijoUnidadPdf: 'CASA',
...extras,
}
}
function usarCatalogo(filas: unknown[]): void {
prismaMock.desarrollo = { findMany: vi.fn().mockResolvedValue(filas) }
}
/** Propuesta del fixture (JSON crudo) por tipo */
function brinda(propuestas: Record<string, unknown>[], tipo: string): Record<string, unknown> {
const p = propuestas.find((x) => x['tipo'] === tipo)
if (!p) throw new Error(`La propuesta ${tipo} no está en el fixture`)
return p
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.reglaCotizadorDesarrollo = undefined
prismaMock.desarrollo = undefined
prismaMock.$queryRawUnsafe.mockReset()
prismaMock.$queryRawUnsafe.mockResolvedValue([])
})
describe('normalizarReglas (JSON de la BD)', () => {
it('acepta el JSON con la forma de las constantes y conserva los valores', () => {
const reglas = normalizarReglas('DIAGONAL', JSON.parse(reglasJson(DESARROLLOS['DIAGONAL']!)))
expect(reglas).not.toBeNull()
expect(reglas?.id).toBe('DIAGONAL')
expect(reglas?.propuestas).toHaveLength(4)
expect(reglas?.propuestas[2]?.descuentoDefault).toBe(0.02)
})
it('marca la propuesta desactivada por el admin (activa/activo)', () => {
const bruto = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as {
propuestas: Record<string, unknown>[]
}
brinda(bruto.propuestas, 'CONTADO').activa = false
const reglas = normalizarReglas('SENDA', bruto)
expect(reglas?.propuestas.find((p) => p.tipo === 'CONTADO')?.activa).toBe(false)
})
it('rechaza JSON sin la forma esperada (precioUnico, propuestas, claves)', () => {
expect(normalizarReglas('DIAGONAL', null)).toBeNull()
expect(normalizarReglas('DIAGONAL', 'DIAGONAL')).toBeNull()
expect(normalizarReglas('DIAGONAL', { precioUnico: true })).toBeNull()
expect(
normalizarReglas('DIAGONAL', {
precioUnico: true,
fuenteDeN: 'fijo',
propuestas: [{ tipo: 'CONTADO' }],
}),
).toBeNull()
})
})
describe('getReglasGuardadas / getReglasEfectivas', () => {
it('sin filas guardadas devuelve las constantes actuales (compatibilidad)', async () => {
usarModeloPrisma().mockResolvedValue([])
const efectivas = await getReglasEfectivas()
expect(Object.keys(efectivas).sort()).toEqual(Object.keys(DESARROLLOS).sort())
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
expect(efectivas[id]).toEqual(constantes)
}
expect(await getReglasDesarrollo('SENDA')).toEqual(DESARROLLOS['SENDA'])
})
it('las reglas de la BD ganan a las constantes del desarrollo', async () => {
const conDescuento = {
...DESARROLLOS['DIAGONAL']!,
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.07 } : p,
),
}
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: reglasJson(conDescuento) },
])
const reglas = await getReglasDesarrollo('DIAGONAL')
const contado = reglas.propuestas.find((p) => p.tipo === 'CONTADO')
expect(contado?.descuentoDefault).toBe(0.07)
expect(DESARROLLOS['DIAGONAL']!.propuestas[2]?.descuentoDefault).toBe(0.02)
// Los demás desarrollos siguen en constantes
expect((await getReglasDesarrollo('SOLANA')).propuestas[1]?.pctEnganche).toBe(0)
})
it('usa la columna activoEnCotizador y también el campo dentro del JSON', async () => {
usarModeloPrisma().mockResolvedValue([
{
desarrolloId: 'SOLANA',
parametrosJson: reglasJson(DESARROLLOS['SOLANA']!),
activoEnCotizador: false,
},
{
desarrolloId: 'ESTANZA',
parametrosJson: reglasJson(DESARROLLOS['ESTANZA']!, { activoEnCotizador: false }),
},
])
expect((await getReglasDesarrollo('SOLANA')).activoEnCotizador).toBe(false)
expect((await getReglasDesarrollo('ESTANZA')).activoEnCotizador).toBe(false)
expect((await getReglasDesarrollo('DIAGONAL')).activoEnCotizador).toBeUndefined()
})
it('resuelve la clave del desarrollo por su id (las reglas apuntan al uuid) y lee su ficha', async () => {
usarModeloPrisma().mockResolvedValue([
{
desarrolloId: 'uuid-diagonal',
parametrosJson: reglasJson({ ...DESARROLLOS['DIAGONAL']!, nombre: undefined as never }),
},
])
usarCatalogo([
ficha('uuid-diagonal', 'DIAGONAL', { activoEnCotizador: false, nombre: 'Diagonal Residencial' }),
ficha('uuid-senda', 'SENDA'),
])
const reglas = await getReglasDesarrollo('DIAGONAL')
// La clave sale del catálogo (id → clave), no del uuid
expect(reglas.propuestas).toHaveLength(4)
// El nombre y la bandera salen de la ficha del desarrollo
expect(reglas.nombre).toBe('Diagonal Residencial')
expect(reglas.activoEnCotizador).toBe(false)
// Las reglas efectivas y el desarrollo activo siguen saliendo de constantes
const efectivas = await getReglasEfectivas()
expect(efectivas['SENDA']).toEqual(DESARROLLOS['SENDA'])
expect(efectivas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
})
it('ignora filas sin desarrollo identificable o sin parámetros', async () => {
const sinClave = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as Record<string, unknown>
delete sinClave['id']
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: '', parametrosJson: JSON.stringify(sinClave) },
{ desarrolloId: 'SENDA', parametrosJson: null },
null,
])
expect((await getReglasGuardadas()).size).toBe(0)
})
it('lee la tabla por SQL crudo cuando el cliente Prisma no tiene el modelo', async () => {
prismaMock.$queryRawUnsafe.mockImplementation(async (sql: string) => {
if (sql.includes('"ReglaCotizadorDesarrollo"')) {
return [
{ desarrolloId: 'uuid-senda', parametrosJson: reglasJson(DESARROLLOS['SENDA']!) },
{ desarrollo: 'SOLANA', parametros: reglasJson(DESARROLLOS['SOLANA']!) },
]
}
return [ficha('uuid-senda', 'SENDA'), ficha('uuid-solana', 'SOLANA')]
})
const reglas = await getReglasEfectivas()
expect(prismaMock.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('"ReglaCotizadorDesarrollo"'),
)
expect(reglas['SENDA']?.propuestas).toHaveLength(4)
expect(reglas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
})
})
describe('respaldo en constantes (nunca falla la cotización)', () => {
it('JSON corrupto: respaldo en las constantes con warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: '{"propuestas": [' },
])
const reglas = await getReglasDesarrollo('DIAGONAL')
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
warn.mockRestore()
})
it('JSON válido pero con forma inesperada: respaldo en las constantes con warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: JSON.stringify({ precioUnico: true }) },
])
const reglas = await getReglasDesarrollo('DIAGONAL')
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('forma inesperada'))
warn.mockRestore()
})
it('tabla inexistente: respaldo en las constantes, sin lanzar y con un solo warning', async () => {
vi.resetModules()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
prismaMock.$queryRawUnsafe.mockRejectedValue(new Error('no such table: ReglaCotizadorDesarrollo'))
const servicio = await import('./reglas-desarrollo.service.js')
const reglas = await servicio.getReglasDesarrollo('SENDA')
const efectivas = await servicio.getReglasEfectivas()
expect(reglas).toEqual(DESARROLLOS['SENDA'])
expect(Object.keys(efectivas)).toHaveLength(Object.keys(DESARROLLOS).length)
expect(warn).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('constantes'), expect.any(Error))
warn.mockRestore()
})
})

View File

@@ -0,0 +1,417 @@
/**
* Reglas comerciales del cotizador por desarrollo.
*
* Las reglas las configura el admin y viven en la tabla `ReglaCotizadorDesarrollo`
* (una fila por desarrollo, con el JSON de parámetros). Este servicio las lee y
* las valida; **nunca** puede tumbar una cotización: si el desarrollo no tiene
* reglas guardadas, la tabla no existe todavía o el JSON viene corrupto, se cae
* a las constantes de `cotizacion.service.ts` (los valores históricos) y se
* registra un warning.
*/
import { prisma } from './prisma.js'
import {
DESARROLLOS,
type BasePrecio,
type DescuentosPropuestas,
type ModoMensualidades,
type ParametrosDesarrollo,
type PropuestaConfig,
type TipoPropuesta,
} from './cotizacion.service.js'
/** Reglas efectivas indexadas por id de desarrollo ('DIAGONAL', 'SENDA', …) */
export type ReglasDesarrollo = Record<string, ParametrosDesarrollo>
const DESARROLLO_DEFAULT = 'DIAGONAL'
const TIPOS: ReadonlyArray<TipoPropuesta> = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
]
const CLAVES_DESCUENTO: ReadonlyArray<keyof DescuentosPropuestas> = [
'creditoBancario',
'creditoInfonavit',
'contado',
'semiContado',
]
const BASES: ReadonlyArray<BasePrecio> = ['lista', 'final']
const MODOS: ReadonlyArray<ModoMensualidades> = [
'ninguna',
'remanente',
'remanenteLista',
'porcentaje',
]
function esTexto(valor: unknown): valor is string {
return typeof valor === 'string' && valor.trim() !== ''
}
function esNumero(valor: unknown): valor is number {
return typeof valor === 'number' && Number.isFinite(valor)
}
function esBase(valor: unknown): valor is BasePrecio {
return typeof valor === 'string' && (BASES as ReadonlyArray<string>).includes(valor)
}
function esModo(valor: unknown): valor is ModoMensualidades {
return typeof valor === 'string' && (MODOS as ReadonlyArray<string>).includes(valor)
}
/** Normaliza una propuesta del JSON de la BD; null si está corrupta */
function normalizarPropuesta(bruto: unknown): PropuestaConfig | null {
if (typeof bruto !== 'object' || bruto === null) return null
const p = bruto as Record<string, unknown>
const tipo = p['tipo']
const clave = p['claveDescuento']
if (typeof tipo !== 'string' || !(TIPOS as ReadonlyArray<string>).includes(tipo)) return null
if (typeof clave !== 'string' || !(CLAVES_DESCUENTO as ReadonlyArray<string>).includes(clave))
return null
if (!esTexto(p['titulo'])) return null
if (!esNumero(p['descuentoDefault']) || !esNumero(p['pctEnganche']) || !esNumero(p['pctPagoFinal']))
return null
if (!esBase(p['engancheBase']) || !esBase(p['pagoFinalBase'])) return null
if (!esModo(p['modoMensualidades'])) return null
const propuesta: PropuestaConfig = {
tipo: tipo as TipoPropuesta,
claveDescuento: clave as keyof DescuentosPropuestas,
titulo: p['titulo'].trim(),
descuentoDefault: p['descuentoDefault'],
pctEnganche: p['pctEnganche'],
engancheBase: p['engancheBase'],
pctPagoFinal: p['pctPagoFinal'],
pagoFinalBase: p['pagoFinalBase'],
modoMensualidades: p['modoMensualidades'],
}
if (p['engancheCeroSinApartado'] === true) propuesta.engancheCeroSinApartado = true
const activa = p['activa'] ?? p['activo']
if (typeof activa === 'boolean') propuesta.activa = activa
return propuesta
}
/**
* Valida y normaliza el JSON de reglas de un desarrollo. Devuelve null (para
* que el llamador caiga a las constantes) si el JSON no tiene la forma esperada.
*/
export function normalizarReglas(
desarrolloId: string,
bruto: unknown,
): ParametrosDesarrollo | null {
if (typeof bruto !== 'object' || bruto === null || Array.isArray(bruto)) return null
const r = bruto as Record<string, unknown>
if (typeof r['precioUnico'] !== 'boolean') return null
const fuente = r['fuenteDeN']
if (fuente !== 'fechaEntrega' && fuente !== 'fijo' && fuente !== 'porUnidad') return null
const propuestasBrutas = r['propuestas']
if (!Array.isArray(propuestasBrutas) || propuestasBrutas.length === 0) return null
const propuestas: PropuestaConfig[] = []
for (const propuestaBruta of propuestasBrutas) {
const propuesta = normalizarPropuesta(propuestaBruta)
if (!propuesta) return null // una propuesta corrupta invalida el JSON completo
propuestas.push(propuesta)
}
const id = desarrolloId.trim().toUpperCase()
const params: ParametrosDesarrollo = {
id,
precioUnico: r['precioUnico'],
fuenteDeN: fuente,
propuestas,
}
if (esTexto(r['nombre'])) params.nombre = r['nombre'].trim()
if (esNumero(r['nFijo'])) params.nFijo = r['nFijo']
if (esNumero(r['nDefault'])) params.nDefault = r['nDefault']
if (r['cuotaMantenimiento'] === null) params.cuotaMantenimiento = null
else if (esNumero(r['cuotaMantenimiento'])) params.cuotaMantenimiento = r['cuotaMantenimiento']
if (esTexto(r['prefijoUnidadPdf'])) params.prefijoUnidadPdf = r['prefijoUnidadPdf'].trim()
const activo = r['activoEnCotizador'] ?? r['activo']
if (typeof activo === 'boolean') params.activoEnCotizador = activo
return params
}
/** Fila de reglas ya mapeada a nombres de campo conocidos */
interface FilaReglas {
desarrolloId: string
parametros: unknown
activoEnCotizador: boolean | undefined
}
/** Ficha mínima del desarrollo (tabla `Desarrollo`): la clave y sus banderas */
interface FichaDesarrollo {
clave: string
activoEnCotizador: boolean | undefined
nombre: string | undefined
prefijoUnidadPdf: string | undefined
}
interface CatalogoDesarrollos {
porClave: Map<string, FichaDesarrollo>
/** Desarrollo.id → clave ('DIAGONAL', 'SENDA', …) */
clavePorId: Map<string, string>
}
/** Primer valor presente de `claves` en la fila que cumpla el filtro */
function primerValor(
fila: Record<string, unknown>,
claves: ReadonlyArray<string>,
acepta: (valor: unknown) => boolean,
): unknown {
for (const clave of claves) {
const valor = fila[clave]
if (valor !== undefined && valor !== null && acepta(valor)) return valor
}
return undefined
}
/** Booleanos de SQLite crudo: 1/0 además de true/false */
function esBandera(valor: unknown): valor is boolean | 0 | 1 {
return typeof valor === 'boolean' || valor === 0 || valor === 1
}
function aBandera(valor: unknown): boolean | undefined {
if (valor === true || valor === 1) return true
if (valor === false || valor === 0) return false
return undefined
}
/**
* Mapea una fila cruda (Prisma o SQL) a `FilaReglas`. Los nombres de columna se
* resuelven por heurística para tolerar cambios del modelo mientras otro agente
* construye el módulo de administración: `desarrolloId`/`desarrollo` y
* `parametrosJson`/`parametros`/`reglas`.
*/
function mapearFila(fila: unknown): FilaReglas | null {
if (typeof fila !== 'object' || fila === null) return null
const f = fila as Record<string, unknown>
const desarrollo = primerValor(
f,
['desarrolloId', 'desarrollo', 'desarrollo_id', 'proyecto', 'id'],
esTexto,
)
const parametros = primerValor(
f,
['parametrosJson', 'parametros_json', 'parametros', 'reglas'],
(v) => typeof v === 'string' || typeof v === 'object',
)
if (typeof desarrollo !== 'string') return null
const activo = primerValor(f, ['activoEnCotizador', 'activo'], esBandera)
return {
desarrolloId: desarrollo,
parametros,
activoEnCotizador: aBandera(activo),
}
}
/** Cliente Prisma con el modelo de reglas, si ya está generado */
function modeloReglas(): { findMany: (args?: unknown) => Promise<unknown[]> } | null {
const cliente = prisma as unknown as Record<string, unknown>
for (const nombre of ['reglaCotizadorDesarrollo', 'reglaCotizador', 'reglasCotizador']) {
const modelo = cliente[nombre] as { findMany?: unknown } | undefined
if (modelo && typeof modelo.findMany === 'function') {
return modelo as { findMany: (args?: unknown) => Promise<unknown[]> }
}
}
return null
}
/**
* Filas de reglas guardadas. Primero intenta el modelo de Prisma; si el cliente
* todavía no lo tiene generado, lee la tabla por SQL crudo. Cualquier fallo se
* traduce en "no hay reglas guardadas" (nunca en un error de cotización).
*/
async function leerFilasReglas(): Promise<FilaReglas[]> {
const modelo = modeloReglas()
if (modelo) {
const filas = await modelo.findMany()
return filas.map(mapearFila).filter((f): f is FilaReglas => f !== null)
}
const crudo = (prisma as unknown as { $queryRawUnsafe?: unknown }).$queryRawUnsafe
if (typeof crudo !== 'function') return []
const filas = await prisma.$queryRawUnsafe<unknown[]>(
'SELECT * FROM "ReglaCotizadorDesarrollo"',
)
return (Array.isArray(filas) ? filas : []).map(mapearFila).filter((f): f is FilaReglas => f !== null)
}
let avisoCatalogoEmitido = false
let avisoLecturaEmitido = false
/**
* Catálogo de desarrollos (tabla `Desarrollo`): de ahí sale la clave real de
* cada fila de reglas (las reglas apuntan al id del desarrollo) y la bandera
* `activoEnCotizador`. Tolerante: si la tabla no existe todavía, el catálogo
* queda vacío y las reglas se resuelven por la clave que traiga el JSON.
*/
async function leerCatalogo(): Promise<CatalogoDesarrollos> {
const porClave = new Map<string, FichaDesarrollo>()
const clavePorId = new Map<string, string>()
let leidas: unknown[]
try {
const modelo = (prisma as unknown as Record<string, unknown>)['desarrollo'] as
| { findMany?: unknown }
| undefined
if (modelo && typeof modelo.findMany === 'function') {
leidas = await (modelo.findMany as (args?: unknown) => Promise<unknown[]>)({
select: { id: true, clave: true, nombre: true, activoEnCotizador: true, prefijoUnidadPdf: true },
})
} else if (typeof prisma.$queryRawUnsafe === 'function') {
leidas = await prisma.$queryRawUnsafe<unknown[]>('SELECT * FROM "Desarrollo"')
} else {
return { porClave, clavePorId }
}
} catch (err) {
if (!avisoCatalogoEmitido) {
avisoCatalogoEmitido = true
// eslint-disable-next-line no-console
console.warn(
'[reglas-cotizador] No se pudo leer el catálogo de desarrollos; se usan las constantes.',
err,
)
}
return { porClave, clavePorId }
}
for (const fila of Array.isArray(leidas) ? leidas : []) {
if (typeof fila !== 'object' || fila === null) continue
const f = fila as Record<string, unknown>
const clave = primerValor(f, ['clave', 'desarrolloId', 'desarrollo'], esTexto)
if (typeof clave !== 'string') continue
const nombre = primerValor(f, ['nombre'], esTexto)
const prefijo = primerValor(f, ['prefijoUnidadPdf'], esTexto)
const ficha: FichaDesarrollo = {
clave: clave.trim().toUpperCase(),
activoEnCotizador: aBandera(primerValor(f, ['activoEnCotizador', 'activo'], esBandera)),
nombre: typeof nombre === 'string' ? nombre : undefined,
prefijoUnidadPdf: typeof prefijo === 'string' ? prefijo : undefined,
}
porClave.set(ficha.clave, ficha)
const id = primerValor(f, ['id'], esTexto)
if (typeof id === 'string') clavePorId.set(id, ficha.clave)
}
return { porClave, clavePorId }
}
/**
* Clave del desarrollo de una fila de reglas: primero el id guardado (uuid) que
* se resuelve con el catálogo, luego el `id` del propio JSON y, si no, la clave
* que venga en la columna.
*/
function claveDeFila(
fila: FilaReglas,
bruto: unknown,
catalogo: CatalogoDesarrollos,
): string | null {
const crudo = fila.desarrolloId.trim()
const delCatalogo = catalogo.clavePorId.get(crudo)
if (delCatalogo !== undefined) return delCatalogo
if (typeof bruto === 'object' && bruto !== null) {
const idJson = (bruto as Record<string, unknown>)['id']
if (esTexto(idJson)) return idJson.trim().toUpperCase()
}
return crudo === '' ? null : crudo.toUpperCase()
}
/**
* Reglas configuradas por el admin, indexadas por clave de desarrollo. Se
* descartan (con warning) las filas con JSON corrupto: esos desarrollos usan sus
* constantes.
*/
export async function getReglasGuardadas(): Promise<Map<string, ParametrosDesarrollo>> {
const guardadas = new Map<string, ParametrosDesarrollo>()
let filas: FilaReglas[]
try {
filas = await leerFilasReglas()
} catch (err) {
if (!avisoLecturaEmitido) {
avisoLecturaEmitido = true
// eslint-disable-next-line no-console
console.warn(
'[reglas-cotizador] No se pudieron leer las reglas guardadas (¿tabla ReglaCotizadorDesarrollo sin crear?); se usan las constantes.',
err,
)
}
return guardadas
}
if (filas.length === 0) return guardadas
const catalogo = await leerCatalogo()
for (const fila of filas) {
if (fila.parametros === undefined) continue
let bruto = fila.parametros
if (typeof bruto === 'string') {
try {
bruto = JSON.parse(bruto)
} catch {
// eslint-disable-next-line no-console
console.warn(
`[reglas-cotizador] JSON inválido para el desarrollo ${fila.desarrolloId}; se usan las constantes.`,
)
continue
}
}
const clave = claveDeFila(fila, bruto, catalogo)
if (clave === null) {
// eslint-disable-next-line no-console
console.warn('[reglas-cotizador] Fila de reglas sin desarrollo; se ignora.')
continue
}
const reglas = normalizarReglas(clave, bruto)
if (!reglas) {
// eslint-disable-next-line no-console
console.warn(
`[reglas-cotizador] Reglas con forma inesperada para el desarrollo ${clave}; se usan las constantes.`,
)
continue
}
// La ficha del desarrollo completa lo que el JSON no traiga
const ficha = catalogo.porClave.get(clave)
if (reglas.nombre === undefined && ficha?.nombre !== undefined) reglas.nombre = ficha.nombre
if (reglas.prefijoUnidadPdf === undefined && ficha?.prefijoUnidadPdf !== undefined) {
reglas.prefijoUnidadPdf = ficha.prefijoUnidadPdf
}
// El desarrollo está activo solo si TODAS las fuentes que hablan de él lo
// dejan activo (la bandera vive en la ficha del desarrollo)
const banderas = [fila.activoEnCotizador, reglas.activoEnCotizador, ficha?.activoEnCotizador].filter(
(valor): valor is boolean => typeof valor === 'boolean',
)
if (banderas.length > 0) reglas.activoEnCotizador = banderas.every((valor) => valor)
guardadas.set(reglas.id, reglas)
}
return guardadas
}
/**
* Reglas efectivas de TODOS los desarrollos: lo guardado por el admin gana; los
* desarrollos sin reglas (o con JSON corrupto) caen a las constantes actuales.
*/
export async function getReglasEfectivas(): Promise<ReglasDesarrollo> {
const guardadas = await getReglasGuardadas()
const efectivas: ReglasDesarrollo = {}
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
efectivas[id] = guardadas.get(id) ?? constantes
}
for (const [id, reglas] of guardadas) {
if (efectivas[id] === undefined) efectivas[id] = reglas
}
return efectivas
}
/** Reglas efectivas de un desarrollo; cae a las constantes (DIAGONAL si es desconocido) */
export async function getReglasDesarrollo(
desarrolloId: string | null | undefined,
): Promise<ParametrosDesarrollo> {
const id = (desarrolloId ?? DESARROLLO_DEFAULT).trim().toUpperCase() || DESARROLLO_DEFAULT
const guardadas = await getReglasGuardadas()
return guardadas.get(id) ?? DESARROLLOS[id] ?? DESARROLLOS[DESARROLLO_DEFAULT]!
}

View File

@@ -11,6 +11,8 @@ function captura(
origen: string | null
desarrollo: string | null
createdAt: Date
updatedAt: Date
datosJson: string | null
}> = {},
) {
return {
@@ -20,6 +22,8 @@ function captura(
origen: overrides.origen ?? 'Facebook',
desarrollo: overrides.desarrollo ?? 'DIAGONAL',
createdAt: overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
updatedAt: overrides.updatedAt ?? overrides.createdAt ?? new Date('2026-09-05T10:00:00'),
datosJson: overrides.datosJson ?? null,
}
}
@@ -73,27 +77,55 @@ describe('construirMiTablero', () => {
captura({ board: 'LEADS' }),
captura({ board: 'LEADS' }),
captura({ board: 'OPORTUNIDADES' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)' }),
captura({ board: 'APARTADO', etapa: 'CANCELADO' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE' }),
captura({ board: 'APARTADO', etapa: 'CANCELO' }),
captura({ board: 'APARTADO', etapa: 'ESCRITURADO' }),
captura({ board: 'LEADS', etapa: 'DESCARTADO' }),
]
const tablero = construirMiTablero(capturas, [], [], AHORA)
expect(tablero.conversion.leads).toBe(3) // 2 activos + 1 descartado
expect(tablero.conversion.oportunidades).toBe(1)
expect(tablero.conversion.apartados).toBe(1) // CANCELADO no cuenta
expect(tablero.conversion.vendidos).toBe(1)
// Cancelo no cuenta; Firma Cliente y Escriturado ya son ventas
expect(tablero.conversion.apartados).toBe(0)
expect(tablero.conversion.vendidos).toBe(2)
expect(tablero.conversion.descartados).toBe(1) // etapa terminal DESCARTADO
})
it('calcula ventas del mes correctamente', () => {
it('calcula ventas del mes con ventas CERRADAS (no cotizaciones)', () => {
// Las cotizaciones ya no cuentan como ventas: se cuentan los apartados con
// firma de cliente o posteriores, sin repetir la misma unidad.
const cots = [
cotizacion({ precioFinal: 850000, createdAt: new Date('2026-09-01T12:00:00') }),
cotizacion({ precioFinal: 750000, createdAt: new Date('2026-09-05T12:00:00') }),
cotizacion({ precioFinal: 900000, createdAt: new Date('2026-08-15T12:00:00') }), // mes anterior
]
const tablero = construirMiTablero([], cots, [], AHORA)
const capturas = [
captura({
leadId: 'loc_apt_1',
board: 'APARTADO',
etapa: 'Firma Cliente',
updatedAt: new Date('2026-09-10T12:00:00'),
datosJson: JSON.stringify({ precioFinal: 1_500_000 }),
}),
captura({
leadId: 'loc_apt_2',
board: 'APARTADO',
etapa: 'Venta',
updatedAt: new Date('2026-09-12T12:00:00'),
datosJson: JSON.stringify({ precioFinal: 2_000_000 }),
}),
// Mismo apartado recotizado: no debe duplicarse (mismo leadId)
captura({
leadId: 'loc_apt_1',
board: 'APARTADO',
etapa: 'Firma Cliente',
updatedAt: new Date('2026-09-15T12:00:00'),
datosJson: JSON.stringify({ precioFinal: 1_500_000 }),
}),
captura({ board: 'APARTADO', etapa: 'Pago Apartado', updatedAt: new Date('2026-09-11T12:00:00') }),
captura({ board: 'APARTADO', etapa: 'Cancelacion Apartado', updatedAt: new Date('2026-09-11T12:00:00') }),
]
const tablero = construirMiTablero(capturas, cots, [], AHORA)
expect(tablero.ventasMes.unidades).toBe(2)
expect(tablero.ventasMes.monto).toBe(1600000) // 850000 + 750000
expect(tablero.ventasMes.monto).toBe(3_500_000)
})
it('agrupa por origen con conteo de convertidos', () => {
@@ -116,9 +148,9 @@ describe('construirMiTablero', () => {
it('agrupa por desarrollo solo con vendidos', () => {
const capturas = [
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'DIAGONAL' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'DIAGONAL' }),
captura({ board: 'APARTADO', etapa: 'ESCRITURADO', desarrollo: 'DIAGONAL' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE (VENTA)', desarrollo: 'ATELIER' }),
captura({ board: 'APARTADO', etapa: 'FIRMA CLIENTE', desarrollo: 'ATELIER' }),
captura({ board: 'APARTADO', etapa: 'NUEVO', desarrollo: 'SENDA' }), // no vendido
]
const tablero = construirMiTablero(capturas, [], [], AHORA)

View File

@@ -80,6 +80,9 @@ export function construirMiTablero(
origen: string | null
desarrollo: string | null
createdAt: Date
/** Necesarios para las ventas cerradas del mes (fecha del cambio y precio) */
updatedAt?: Date
datosJson?: string | null
}[],
cotizaciones: {
precioFinal: number
@@ -119,24 +122,52 @@ export function construirMiTablero(
// -- Conversión (funnel) --
const leads = capturas.filter((c) => c.board === 'LEADS').length
const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length
// Apartados ACTIVOS del funnel: ni cancelados (Cancelo/Cancelacion Apartado)
// ni ya vendidos (Firma Cliente/Escriturado/Titulado pasan a "vendidos")
const apartados = capturas.filter(
(c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO',
(c) =>
c.board === 'APARTADO' &&
!['FIRMA CLIENTE', 'CANCELO', 'CANCELACION APARTADO', 'ESCRITURADO', 'TITULADO'].includes(
c.etapa.toUpperCase(),
),
).length
const vendidos = capturas.filter(
(c) =>
c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()),
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()),
).length
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
// -- Ventas del mes --
// Antes sumaba COTIZACIONES (un inmueble recotizado contaba varias veces y una
// cotización no es una venta). Ahora cuenta ventas cerradas del mes: apartados
// con firma de cliente o posteriores (Venta, Escriturado, Titulado), sin
// repetir la misma unidad.
const inicioMes = new Date(hoy.getFullYear(), hoy.getMonth(), 1)
const cotizacionesMes = cotizaciones.filter(
(ct) => ct.createdAt >= inicioMes,
const esVentaCerrada = (etapa: string): boolean => {
const e = etapa.toUpperCase()
return (
e.includes('FIRMA') || e.includes('VENTA') || e.includes('ESCRITURADO') || e.includes('TITULADO')
) && !e.includes('CANCEL')
}
const ventasDelMes = capturas.filter(
(c) => c.board === 'APARTADO' && esVentaCerrada(c.etapa) && (c.updatedAt ?? c.createdAt) >= inicioMes,
)
const unidadesVendidas = new Map<string, number>()
for (const v of ventasDelMes) {
let monto = 0
try {
const datos = JSON.parse(v.datosJson ?? '{}') as Record<string, unknown>
const precio = Number(datos['precioFinal'] ?? datos['precioVenta'] ?? 0)
monto = Number.isFinite(precio) ? precio : 0
} catch {
monto = 0
}
unidadesVendidas.set(v.leadId, monto)
}
const ventasMes = {
monto: Math.round(cotizacionesMes.reduce((acc, ct) => acc + ct.precioFinal, 0) * 100) / 100,
unidades: cotizacionesMes.length,
monto: Math.round([...unidadesVendidas.values()].reduce((a, b) => a + b, 0) * 100) / 100,
unidades: unidadesVendidas.size,
}
// -- Por origen --
@@ -159,7 +190,7 @@ export function construirMiTablero(
for (const c of capturas) {
if (
c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) &&
['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) &&
c.desarrollo
) {
const dev = c.desarrollo.trim()
@@ -212,6 +243,8 @@ export async function obtenerMiTablero(usuarioId: string): Promise<MiTablero> {
origen: true,
desarrollo: true,
createdAt: true,
updatedAt: true,
datosJson: true,
},
})

View File

@@ -217,7 +217,7 @@ describe('construirResumen', () => {
plan('a', {
pagos: [
pago('p1', '2026-08-01', 50000), // vencido sin abono
pago('p2', '2026-08-10', 50000, 20000), // parcial (no cuenta como VENCIDO)
pago('p2', '2026-08-10', 50000, 20000), // parcial VENCIDO: sí cuenta como atraso
pago('p3', '2026-09-01', 50000), // pendiente futuro
],
}),
@@ -228,9 +228,10 @@ describe('construirResumen', () => {
},
AHORA,
)
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1 })
expect(r.cobranza.pagosVencidos).toBe(2)
expect(r.cobranza.montoVencido).toBe(80000)
expect(r.cobranza.planes).toEqual({ activos: 2, liquidados: 1, cancelados: 1, sinPlan: 0 })
// p1 (vencido sin abono) + p2 (PARCIAL vencido, antes se ignoraba) + p4
expect(r.cobranza.pagosVencidos).toBe(3)
expect(r.cobranza.montoVencido).toBe(110000) // 50,000 + 30,000 restantes + 30,000
expect(r.cobranza.planesAlCorriente).toBe(0)
expect(r.cobranza.planesConVencidos).toBe(2)
})

View File

@@ -1,4 +1,4 @@
import { statusPago, statusPlan, type PlanPago } from './cobranza.service.js'
import { pagoAtrasado, statusPlan, type PlanPago } from './cobranza.service.js'
import type { UnidadCotizable } from './cotizacion.service.js'
import { ETAPAS_PIPELINE } from './etapas.js'
import { prisma } from './prisma.js'
@@ -266,7 +266,9 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number
.map((etapa) => ({ etapa, total: conteoEtapas.get(etapa) ?? 0 })),
]
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0 }
// `sinPlan` = plan sin calendario capturado (no es lo mismo que cancelado:
// 3 planes del maestro llegaron sin pagos y antes se contaban como cancelados)
const planesPorStatus = { activos: 0, liquidados: 0, cancelados: 0, sinPlan: 0 }
let planesAlCorriente = 0
let planesConVencidos = 0
let pagosVencidos = 0
@@ -275,9 +277,12 @@ export function construirResumen(datos: DatosResumen, ahora: Date, dias?: number
const status = statusPlan(plan)
if (status === 'ACTIVO') planesPorStatus.activos++
else if (status === 'LIQUIDADO') planesPorStatus.liquidados++
else if (status === 'SIN_PLAN') planesPorStatus.sinPlan++
else planesPorStatus.cancelados++
if (status !== 'ACTIVO') continue
const vencidos = plan.pagos.filter((p) => statusPago(p, ahora) === 'VENCIDO')
// Un pago PARCIAL vencido también es atraso (antes no se contaba y había
// planes "al corriente" con meses de retraso)
const vencidos = plan.pagos.filter((p) => pagoAtrasado(p, ahora))
if (vencidos.length === 0) planesAlCorriente++
else planesConVencidos++
pagosVencidos += vencidos.length
@@ -435,7 +440,7 @@ export async function cargarDatosLocalesPrisma(): Promise<DatosLocalesReportes>
// Contar actividades abiertas
const actividadesAbiertas = await prisma.actividadLocal.count({ where: { estado: 'ABIERTA' } })
return { leads, oportunidades, unidades: unidadesRows.map(unidadLocalADto), actividadesAbiertas }
return { leads, oportunidades, unidades: unidadesRows.map((u) => unidadLocalADto(u)), actividadesAbiertas }
}
export class ReportesService {

View File

@@ -7,11 +7,15 @@ const prismaMock = vi.hoisted(() => ({
findMany: vi.fn(),
findUnique: vi.fn(),
},
capturaDepartamento: {
findMany: vi.fn(),
},
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import {
CAMPOS_FECHA_ENTREGA,
getUnidadesLocal,
getUnidadLocal,
unidadLocalADto,
@@ -37,10 +41,11 @@ beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.findUnique.mockResolvedValue(null)
prismaMock.capturaDepartamento.findMany.mockResolvedValue([])
})
describe('unidadLocalADto', () => {
it('mapea la fila local al DTO Unidad', () => {
it('mapea la fila local al DTO Unidad (sin fecha de entrega por defecto)', () => {
const dto = unidadLocalADto(fila({ codigo: 'SENDA-101' }))
expect(dto).toEqual({
codigo: 'SENDA-101',
@@ -58,6 +63,10 @@ describe('unidadLocalADto', () => {
})
})
it('con fecha de entrega (captura de departamentos) la deja en el DTO', () => {
expect(unidadLocalADto(fila({ codigo: 'U-101' }), '2027-10-08').fechaEntrega).toBe('2027-10-08')
})
it('etapa vacía → null', () => {
expect(unidadLocalADto(fila({ codigo: 'U-1', etapa: '' })).etapa).toBeNull()
})
@@ -80,11 +89,47 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
})
it('getUnidadLocal: null si no existe', async () => {
it('completa fechaEntrega desde la captura: reprogramada > programada > habitabilidad > inicio de obra', async () => {
prismaMock.unidad.findMany.mockResolvedValue([
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'S-1', desarrollo: 'SENDA' }),
fila({ codigo: 'S-2', desarrollo: 'SENDA' }),
fila({ codigo: 'S-3', desarrollo: 'SENDA' }),
])
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' },
{ unidadCodigo: 'U-1', campo: 'habitabilidad', valor: '2027-10-08' },
{ unidadCodigo: 'U-1', campo: 'entrega_programada_fecha', valor: '2027-11-20' },
{ unidadCodigo: 'S-1', campo: 'habitabilidad', valor: '2027-05-05' },
// Valor vacío / no-fecha: no cuenta como dato
{ unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' },
{ unidadCodigo: 'S-2', campo: 'entrega_programada_fecha', valor: 'pendiente' },
// Reprogramada: manda sobre la programada cuando existe
{ unidadCodigo: 'U-1', campo: 'entrega_reprogramada_fecha', valor: '2028-02-15' },
// Reprogramada vacía: se ignora y gana la programada
{ unidadCodigo: 'S-3', campo: 'entrega_reprogramada_fecha', valor: '' },
{ unidadCodigo: 'S-3', campo: 'entrega_programada_fecha', valor: '2027-07-01' },
])
const todas = await getUnidadesLocal()
expect(todas.map((u) => u.fechaEntrega)).toEqual(['2028-02-15', null, '2027-05-05', null, '2027-07-01'])
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }),
)
})
it('getUnidadLocal: null si no existe; trae la fecha de su captura', async () => {
prismaMock.unidad.findUnique.mockImplementation(async ({ where }) =>
where.codigo === 'U-9' ? fila({ codigo: 'U-9' }) : null,
)
expect((await getUnidadLocal('U-9'))?.codigo).toBe('U-9')
prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-9', campo: 'habitabilidad', valor: '2027-10-08' },
])
const dto = await getUnidadLocal('U-9')
expect(dto?.codigo).toBe('U-9')
expect(dto?.fechaEntrega).toBe('2027-10-08')
expect(await getUnidadLocal('U-X')).toBeNull()
})
})

View File

@@ -6,10 +6,55 @@ import type { UnidadCotizable } from './cotizacion.service.js'
* scripts/seed-unidades-locales.js). Es la única fuente de lectura del
* inventario: misma forma de DTO que consumen controllers y fronts.
*
* La tabla local no guarda fechaEntrega ni mesesParaFirma (no están en el
* seed curado): salen en null, que el cotizador ya tolera (plazo desconocido).
* La tabla `Unidad` no guarda la fecha de entrega: se completa desde la captura
* de obra/escrituración (`CapturaDepartamento`), que es la que llenan los
* departamentos. Sin ese dato el cotizador asume el plazo por defecto del
* desarrollo (ver DESARROLLOS en cotizacion.service.ts).
*/
/**
* Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda,
* en orden de prioridad (el primero con fecha válida gana):
* 1. `entrega_reprogramada_fecha` — Fecha Reprogramada Entrega (la más reciente,
* cuando obra movió la entrega es la fecha que manda)
* 2. `entrega_programada_fecha` — Fecha Programada Entrega Vivienda
* 3. `habitabilidad` — fecha de habitabilidad (OBRA), la de DIAGONAL
* 4. `inicio_obra_fecha` — último recurso (ESCRITURACION)
*/
export const CAMPOS_FECHA_ENTREGA = [
'entrega_reprogramada_fecha',
'entrega_programada_fecha',
'habitabilidad',
'inicio_obra_fecha',
] as const
/** Normaliza 'YYYY-MM-DD' (o ISO completo); null si el valor no es una fecha usable */
function fechaIso(valor: string | null | undefined): string | null {
if (typeof valor !== 'string') return null
const fecha = valor.trim().slice(0, 10)
return /^\d{4}-\d{2}-\d{2}$/.test(fecha) ? fecha : null
}
/** Fecha de entrega (ISO) por código de unidad, según la captura de los departamentos */
async function fechasEntregaPorUnidad(codigos?: string[]): Promise<Map<string, string>> {
const filas = await prisma.capturaDepartamento.findMany({
where: {
campo: { in: [...CAMPOS_FECHA_ENTREGA] },
...(codigos ? { unidadCodigo: { in: codigos } } : {}),
},
select: { unidadCodigo: true, campo: true, valor: true },
})
const porUnidad = new Map<string, string>()
for (const campo of CAMPOS_FECHA_ENTREGA) {
for (const fila of filas) {
if (fila.campo !== campo || porUnidad.has(fila.unidadCodigo)) continue
const fecha = fechaIso(fila.valor)
if (fecha !== null) porUnidad.set(fila.unidadCodigo, fecha)
}
}
return porUnidad
}
/** Fila de la tabla Unidad tal como la devuelve Prisma (estructural) */
export interface FilaUnidad {
codigo: string
@@ -25,11 +70,11 @@ export interface FilaUnidad {
}
/** Fila local → DTO `Unidad` (UnidadCotizable) que consumen controllers y fronts */
export function unidadLocalADto(u: FilaUnidad): UnidadCotizable {
export function unidadLocalADto(u: FilaUnidad, fechaEntrega: string | null = null): UnidadCotizable {
return {
codigo: u.codigo,
modelo: u.modelo,
fechaEntrega: null, // no está en el espejo local: plazo desconocido
fechaEntrega,
supEstacionamiento: u.supEstacionamiento,
supConstruccion: u.supConstruccion,
supExcedente: u.supExcedente,
@@ -47,7 +92,8 @@ export async function getUnidadesLocal(
filtro: { desarrollo?: string | undefined; soloDisponibles?: boolean | undefined } = {},
): Promise<UnidadCotizable[]> {
const rows = await prisma.unidad.findMany({ orderBy: { codigo: 'asc' } })
let lista = rows.map(unidadLocalADto)
const fechas = await fechasEntregaPorUnidad()
let lista = rows.map((u) => unidadLocalADto(u, fechas.get(u.codigo) ?? null))
if (filtro.soloDisponibles === true) {
lista = lista.filter((u) => u.status.trim().toUpperCase() === 'DISPONIBLE')
}
@@ -61,5 +107,7 @@ export async function getUnidadesLocal(
/** Una unidad local por código; null si no existe en el espejo */
export async function getUnidadLocal(codigo: string): Promise<UnidadCotizable | null> {
const row = await prisma.unidad.findUnique({ where: { codigo } })
return row === null ? null : unidadLocalADto(row)
if (row === null) return null
const fechas = await fechasEntregaPorUnidad([codigo])
return unidadLocalADto(row, fechas.get(codigo) ?? null)
}

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

@@ -125,7 +125,7 @@ describe('AltaAfluenciaForm', () => {
fireEvent.change(screen.getByLabelText(/^Estado civil/), { target: { value: 'Casado(a)' } })
fireEvent.change(screen.getByLabelText(/Tipo de empleo/), { target: { value: 'Asalariado' } })
fireEvent.change(screen.getByLabelText(/^Ocupación/), { target: { value: 'Ingeniero' } })
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: 'F-25001.00-30000.00' } })
fireEvent.change(screen.getByLabelText(/Rango salarial/), { target: { value: '20,000-40,000' } })
fireEvent.change(screen.getByLabelText(/^Crédito/), { target: { value: 'Infonavit' } })
fireEvent.change(screen.getByLabelText(/Tipo de economía/), { target: { value: 'Economía mexicana' } })
fireEvent.change(screen.getByLabelText(/^Califica/), { target: { value: 'Sí' } })
@@ -141,7 +141,7 @@ describe('AltaAfluenciaForm', () => {
estadoCivil: 'Casado(a)',
tipoEmpleo: 'Asalariado',
ocupacion: 'Ingeniero',
rangoSalarial: 'F-25001.00-30000.00',
rangoSalarial: '20,000-40,000',
credito: 'Infonavit',
tipoEconomia: 'Economía mexicana',
califica: 'Sí',

View File

@@ -155,7 +155,12 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
nombrePila,
apellidoPaterno,
apellidoMaterno,
telefono: numero,
telefono: (() => {
// Normaliza a dígitos y toma los últimos 10 (limpia ladas duplicadas,
// espacios inseparables y marcas invisibles de copiar/pegar)
const soloDigitos = numero.replace(/\D/g, '')
return soloDigitos.length > 10 ? soloDigitos.slice(-10) : soloDigitos
})(),
email: lead.email,
origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen,
desarrollo,
@@ -173,6 +178,9 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const nombreCompuesto = nombreCompleto()
if (nombreCompuesto === '') nuevos.nombrePila = 'El nombre es obligatorio'
if (!form.telefono.trim()) nuevos.telefono = 'El teléfono es obligatorio'
else if (form.telefono.replace(/\D/g, '').length !== 10) {
nuevos.telefono = 'El teléfono debe tener exactamente 10 dígitos'
}
if (form.email.trim() && !emailSchema.safeParse(form.email).success) {
nuevos.email = 'Correo electrónico inválido'
}
@@ -423,12 +431,19 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
<input
id="afluencia-telefono"
type="tel"
placeholder="664 123 4567"
inputMode="numeric"
maxLength={10}
placeholder="6641234567"
value={form.telefono}
onChange={cambiar('telefono')}
onChange={(e) => {
setForm((prev) => ({ ...prev, telefono: e.target.value.replace(/\D/g, '').slice(0, 10) }))
setErrores((prev) => ({ ...prev, telefono: undefined }))
}}
required
className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
/>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">10 dígitos, sin lada (la lada va en el selector)</p>
{errores.telefono && <p className="mt-1 text-xs text-red-700">{errores.telefono}</p>}
</div>
@@ -582,19 +597,14 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
className={inputClase}
>
<option value="">— Selecciona —</option>
<option value="A-0-10000.00">A-0-10000.00</option>
<option value="B-10000.01-15000.00">B-10000.01-15000.00</option>
<option value="C-15000.01-20000.00">C-15000.01-20000.00</option>
<option value="D-17000.01-22000.00">D-17000.01-22000.00</option>
<option value="E-20001.00-25000.00">E-20001.00-25000.00</option>
<option value="F-25001.00-30000.00">F-25001.00-30000.00</option>
<option value="G-30001.00-35000.00">G-30001.00-35000.00</option>
<option value="H-35001.00-40000.00">H-35001.00-40000.00</option>
<option value="I-40001.00-45000.00">I-40001.00-45000.00</option>
<option value="J-45001.00-50000.00">J-45001.00-50000.00</option>
<option value="K-50001.00-75000.00">K-50001.00-75000.00</option>
<option value="L-75001.00-100000.00">L-75001.00-100000.00</option>
<option value="M-100001.00-500000.00">M-100001.00-500000.00</option>
<option value="0-20,000">A. 0 - 20,000</option>
<option value="20,000-40,000">B. 20,000 - 40,000</option>
<option value="40,001-50,000">C. 40,001 - 50,000</option>
<option value="60,001-80,000">D. 60,001 - 80,000</option>
<option value="80,001-100,000">E. 80,001 - 100,000</option>
<option value="120,001-140,000">F. 120,001 - 140,000</option>
<option value="140,001-180,000">G. 140,001 - 180,000</option>
<option value="200,000-500,000">H. 200,000 - 500,000</option>
</select>
</label>

View File

@@ -117,10 +117,17 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
const { watch, setValue, formState: { errors } } = useFormContext()
const valor = ((watch(nombre) as string) ?? '').trim()
const lada = valor.startsWith('+1') ? '+1' : '+52'
const numero = valor.startsWith('+1') ? valor.slice(2).trim() : valor.startsWith('+52') ? valor.slice(3).trim() : valor
// Normaliza lo que se muestra: dígitos, últimos 10 (limpia ladas duplicadas
// o caracteres invisibles heredados de registros viejos)
const numero = (() => {
const crudo = valor.startsWith('+1') ? valor.slice(2).trim() : valor.startsWith('+52') ? valor.slice(3).trim() : valor
const d = crudo.replace(/\D/g, '')
return d.length > 10 ? d.slice(-10) : d
})()
const actualizar = (nuevaLada: string, nuevoNumero: string) => {
const limpio = nuevoNumero.trim()
// Sólo dígitos y máximo 10 (la lada va en el selector)
const limpio = nuevoNumero.replace(/\D/g, '').slice(0, 10)
// Vacío se guarda vacío (sin prefijo huérfano); con número, compuesto
setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true })
}
@@ -144,12 +151,15 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
<input
id={nombre}
type="tel"
placeholder="664 123 4567"
inputMode="numeric"
maxLength={10}
placeholder="6641234567"
value={numero}
onChange={(e) => actualizar(lada, e.target.value)}
className={claseInput(nombre, errors)}
/>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">10 dígitos, sin lada</p>
<MensajeError nombre={nombre} />
</div>
)

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect } from 'react'
import { useRef } from 'react'
import SignatureCanvas from 'react-signature-canvas'
interface Props {
@@ -9,11 +9,10 @@ interface Props {
export default function SignaturePad({ onFirma }: Props) {
const ref = useRef<SignatureCanvas | null>(null)
useEffect(() => {
// Notifica firma vacía al montar (estado inicial)
onFirma('')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// NOTA: no se notifica '' al montar. El pad se desmonta/remonta cada vez
// que el asesor sale y vuelve al paso de firmas, y ese reset borraba la
// firma ya capturada (los PDFs salían sin firma). El estado vacío inicial
// ya lo maneja el formulario padre.
const limpiar = () => {
ref.current?.clear()

View File

@@ -62,8 +62,16 @@ export const datosGeneralesSchema = z
peso: textoOpcional,
estatura: textoOpcional,
email: z.string().trim().email('Correo electrónico inválido'),
telCasa: textoOpcional,
celular: z.string().trim().min(8, 'El celular es requerido'),
// Teléfonos: 10 dígitos (con o sin lada compuesta; el campo sólo acepta
// dígitos en la UI, así que aquí sólo se valida la longitud mínima)
telCasa: z
.string()
.trim()
.refine((v) => v === '' || v.replace(/\D/g, '').length >= 10, 'El teléfono debe tener 10 dígitos'),
celular: z
.string()
.trim()
.refine((v) => v.replace(/\D/g, '').length >= 10, 'El celular debe tener 10 dígitos'),
viviendaAnios: textoOpcional,
tipoVivienda: z.enum(['PRESTADA', 'RENTADA', 'FAMILIAR', 'PROPIA', '']),
tipoCompra: z.enum(['INVERSION', 'VIVIR_AHI', '']),

View File

@@ -19,6 +19,7 @@ import {
eliminarBorrador,
generarLigaRemota,
guardarBorrador,
importarInformacionEnkontrol,
iniciarProgreso,
listarBorradores,
obtenerBorrador,
@@ -27,7 +28,9 @@ import {
subirDocumentos,
type BorradorResumen,
type LeadResumen,
type ResultadoImportacionEnkontrol,
} from '../services/api'
import { getSesion } from '../services/auth'
import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
import { apartadoAPrellenado } from '../lib/apartadoAlta'
import { leadAPrellenado } from '../lib/leadAlta'
@@ -137,6 +140,29 @@ export default function AltaClientePage() {
const [ligaCopiada, setLigaCopiada] = useState(false)
const [errorLiga, setErrorLiga] = useState('')
// ---------- Importación del documento de Enkontrol (solo administración) ----------
const rolSesion = getSesion()?.usuario.rol ?? ''
const puedeImportarEnkontrol = rolSesion === 'admin' || rolSesion === 'manager'
const [importandoEnkontrol, setImportandoEnkontrol] = useState(false)
const [resultadoImportacion, setResultadoImportacion] = useState<ResultadoImportacionEnkontrol | null>(null)
const [errorImportacion, setErrorImportacion] = useState('')
const inputImportacionRef = useRef<HTMLInputElement>(null)
const importarEnkontrol = async (archivo: File | undefined) => {
if (!archivo) return
setImportandoEnkontrol(true)
setErrorImportacion('')
setResultadoImportacion(null)
try {
setResultadoImportacion(await importarInformacionEnkontrol(archivo))
} catch (err) {
setErrorImportacion(err instanceof Error ? err.message : 'No se pudo importar el documento.')
} finally {
setImportandoEnkontrol(false)
if (inputImportacionRef.current) inputImportacionRef.current.value = ''
}
}
// Afluencia prellenada desde Mis oportunidades (Mi día): actualiza la
// oportunidad existente en vez de crear una nueva
const [afluenciaPrevia, setAfluenciaPrevia] = useState<{
@@ -337,8 +363,8 @@ export default function AltaClientePage() {
const res = datos.progresoId
? await completarProgreso(datos.progresoId, alta)
: await crearAltaCliente(alta)
toast.success("Alta de cliente completada")
if (res.ok) {
toast.success('Alta de cliente completada')
const { fallidos } = await subirDocumentos(res.id, alta)
setRegistro(res.id)
setSinConexion(false)
@@ -610,6 +636,51 @@ export default function AltaClientePage() {
</button>
</div>
{puedeImportarEnkontrol && (
<div className="mt-6 rounded-lg border border-[#E5E7EB] bg-white p-4 dark:border-[#334155] dark:bg-[#0F172A]">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
Importar información de Enkontrol
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Sube el documento de Enkontrol (Excel o CSV). Cada fila se empareja con la ficha
del cliente usando el ID de Enkontrol como campo determinante y su información se
guarda en la ficha.
</p>
<div className="mt-3">
<button
type="button"
onClick={() => inputImportacionRef.current?.click()}
disabled={importandoEnkontrol}
className="rounded-lg border border-accent bg-white px-4 py-2 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
>
{importandoEnkontrol ? 'Importando…' : 'Seleccionar documento…'}
</button>
<input
ref={inputImportacionRef}
type="file"
accept=".xlsx,.xls,.csv"
className="sr-only"
onChange={(e) => void importarEnkontrol(e.target.files?.[0])}
/>
</div>
{resultadoImportacion && (
<p role="status" className="mt-3 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-xs text-green-700 dark:border-green-800 dark:bg-green-900/30 dark:text-green-300">
Importación lista: {resultadoImportacion.actualizados} de {resultadoImportacion.totalFilas}{' '}
clientes actualizados.
{resultadoImportacion.noEncontrados.length > 0 && (
<> Sin ficha local: {resultadoImportacion.noEncontrados.slice(0, 10).join(', ')}
{resultadoImportacion.noEncontrados.length > 10 ? '…' : ''}</>
)}
</p>
)}
{errorImportacion && (
<p role="alert" className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{errorImportacion}
</p>
)}
</div>
)}
<p className="mt-6 text-xs text-green-800/80 dark:text-green-400/80">
Con la liga de firma, el cliente firma su expediente y sube documentos desde su
celular (vigencia 72 h, sin usuario ni contraseña).

View File

@@ -73,8 +73,7 @@ async function enviarAlta(ruta: string, payload: AltaCliente): Promise<Resultado
/**
* Registra el alta en el API Gateway (POST /clientes). El backend persiste en
* SQLite.
*/
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
*/export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
return enviarAlta('/clientes', payload)
}
@@ -414,3 +413,30 @@ export async function subirDocumentoRemoto(token: string, tipo: string, archivo:
})
manejarErrorLiga(res)
}
/** Resultado de la importación del documento de Enkontrol */
export interface ResultadoImportacionEnkontrol {
totalFilas: number
actualizados: number
noEncontrados: string[]
}
/**
* POST /clientes/importar-enkontrol — sube el documento de Enkontrol (Excel o
* CSV). El ID de Enkontrol es el campo determinante: cada fila actualiza la
* ficha del cliente con ese usuarioEnkontrol.
*/
export async function importarInformacionEnkontrol(
archivo: File,
): Promise<ResultadoImportacionEnkontrol> {
const form = new FormData()
form.append('archivo', archivo, archivo.name)
const res = await fetch(`${API_URL}/clientes/importar-enkontrol`, conAuth({ method: 'POST', body: form }))
manejar401(res)
if (!res.ok) {
const cuerpo = (await res.json().catch(() => null)) as { error?: string } | null
throw new Error(cuerpo?.error ?? `No se pudo importar (HTTP ${res.status})`)
}
return (await res.json()) as ResultadoImportacionEnkontrol
}

View File

@@ -1,8 +1,12 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'
import DarkModeToggle from './DarkModeToggle'
export default function Layout({ children }: { children: ReactNode }) {
// El toggle "Mis planes / Todos" vive en ?vista=: se conserva al navegar
// entre Cobranza y Cobranza del día para que el alcance no se pierda.
const { search } = useLocation()
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A] text-[#111827] dark:text-[#E2E8F0]">
<header className="sticky top-0 z-10 border-b border-[#E5E7EB] bg-white/95 backdrop-blur dark:border-[#1E293B] dark:bg-[#1E293B]/95">
@@ -23,13 +27,13 @@ export default function Layout({ children }: { children: ReactNode }) {
<nav className="flex items-center gap-3 text-sm sm:gap-6">
<DarkModeToggle />
<Link
to="/"
to={`/${search}`}
className="text-[#64748B] dark:text-[#94A3B8] transition-colors duration-150 hover:text-[#111827] dark:text-[#E2E8F0]"
>
Cobranza
</Link>
<Link
to="/hoy"
to={`/hoy${search}`}
className="rounded-lg border border-accent px-3 py-2 text-sm font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white sm:px-5 sm:py-2.5"
>
Cobranza del día

View File

@@ -1,13 +1,14 @@
import type { PlanPago } from '../types'
import { diasParaProximoVencimiento, statusPago, statusPlan } from '../lib/cobranza'
import { diasParaProximoVencimiento, pagoAtrasado, statusPago, statusPlan } from '../lib/cobranza'
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS'
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS' | 'SIN_PLAN'
const ETIQUETA: Record<Semaforo, string> = {
VERDE: 'Al corriente',
AMBAR: 'Por vencer / parcial',
ROJO: 'Vencido',
GRIS: 'Cerrado',
SIN_PLAN: 'Sin plan capturado',
}
const CLASE_DOT: Record<Semaforo, string> = {
@@ -15,20 +16,23 @@ const CLASE_DOT: Record<Semaforo, string> = {
AMBAR: 'bg-amber-50 text-amber-700',
ROJO: 'bg-red-50 text-red-700',
GRIS: 'bg-gray-100 text-gray-600',
SIN_PLAN: 'bg-orange-50 text-orange-700',
}
/**
* Semáforo del plan:
* - "sin plan capturado": no hay calendario de pagos (no es lo mismo que liquidado)
* - gris: LIQUIDADO o CANCELADO
* - rojo: tiene pagos VENCIDOS
* - rojo: tiene pagos VENCIDOS o PARCIALES vencidos
* - ámbar: tiene un pago PARCIAL o el próximo vencimiento es en 7 días o menos
* - verde: al corriente
*/
export function semaforoPlan(plan: PlanPago, hoy: Date = new Date()): Semaforo {
if (statusPlan(plan) !== 'ACTIVO') return 'GRIS'
const estados = plan.pagos.map((p) => statusPago(p, hoy))
if (estados.includes('VENCIDO')) return 'ROJO'
if (estados.includes('PARCIAL')) return 'AMBAR'
const status = statusPlan(plan)
if (status === 'SIN_PLAN') return 'SIN_PLAN'
if (status !== 'ACTIVO') return 'GRIS'
if (plan.pagos.some((p) => pagoAtrasado(p, hoy))) return 'ROJO'
if (plan.pagos.some((p) => statusPago(p, hoy) === 'PARCIAL')) return 'AMBAR'
const dias = diasParaProximoVencimiento(plan, hoy)
if (dias !== null && dias <= 7) return 'AMBAR'
return 'VERDE'

Some files were not shown because too many files have changed in this diff Show More