Compare commits

...

30 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
133 changed files with 12947 additions and 1822 deletions

View File

@@ -16,6 +16,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-cron": "^4.6.0", "node-cron": "^4.6.0",
"pg": "^8.23.0",
"web-push": "^3.6.7", "web-push": "^3.6.7",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
@@ -27,6 +28,7 @@
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/node": "^22.15.0", "@types/node": "^22.15.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/pg": "^8.23.1",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"prisma": "^6.14.0", "prisma": "^6.14.0",
"tsx": "^4.20.3", "tsx": "^4.20.3",
@@ -1086,6 +1088,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/qs": {
"version": "6.15.1", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -2658,6 +2672,95 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2719,6 +2822,45 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/prisma": {
"version": "6.19.3", "version": "6.19.3",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
@@ -3098,6 +3240,15 @@
"node": ">=0.10.0" "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": { "node_modules/ssf": {
"version": "0.11.2", "version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
@@ -3600,6 +3751,15 @@
"node": ">=0.8" "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": { "node_modules/zod": {
"version": "3.25.76", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

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

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 clienteNombre String
clienteEmail String? clienteEmail String?
clienteTelefono String? clienteTelefono String?
tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO | FRASA_2026 (esquema único)
acabados Boolean acabados Boolean
apartado Float // TODO prod: Decimal en Postgres apartado Float // TODO prod: Decimal en Postgres
descuento Float // fracción (0.02 = 2%) descuento Float // fracción (0.02 = 2%)
@@ -31,6 +31,20 @@ model Cotizacion {
numMensualidades Int numMensualidades Int
mensualidad Float? // null => N/A (Contado) mensualidad Float? // null => N/A (Contado)
pagoFinal Float 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? mondayItemId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -173,6 +187,10 @@ model LeadCaptura {
usuarioId String // Usuario.id del capturista usuarioId String // Usuario.id del capturista
nombreLead String nombreLead String
telefono 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 del embudo donde vive el registro: LEADS | OPORTUNIDADES | APARTADO
board String @default("LEADS") board String @default("LEADS")
// Etapa/estado local actual (NUEVO al capturar) // Etapa/estado local actual (NUEVO al capturar)
@@ -198,6 +216,7 @@ model LeadCaptura {
@@index([usuarioId]) @@index([usuarioId])
@@index([mondayItemId]) @@index([mondayItemId])
@@index([nombreLead]) @@index([nombreLead])
@@index([telefonoNorm])
} }
// Espejo local del inventario (tablero Unidades de Monday). Lo siembra // Espejo local del inventario (tablero Unidades de Monday). Lo siembra
@@ -299,6 +318,9 @@ model Usuario {
// User ID de Monday (people). null = el usuario aún no tiene cuenta Monday; // 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 // los filtros "míos" de /api/mi-dia responden vacíos con sinMapeoMonday: true
mondayUserId Int? 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) activo Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

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

@@ -16,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', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' },
{ campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' }, { campo: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' }, { 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' }, { campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
], ],
VENTAS: [ VENTAS: [
@@ -31,13 +40,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<string, { campo: string; tipo: stri
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' }, { campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
], ],
ESCRITURACION: [ 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: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
{ campo: 'notaria', tipo: 'text', label: 'Notaría' }, { campo: 'notaria', tipo: 'text', label: 'Notaría' },
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' }, { campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },

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' 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[]> = { const ESPERADOS: Record<string, string[]> = {
vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'], vendedor: ['mi-dia', 'alta-cliente', 'alta-afluencia', 'cotizador', 'mi-tablero', 'contactos', 'chat', 'clientes-maestro', 'captura', 'soporte'],
administracion_venta: [ administracion_venta: [

View File

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

View File

@@ -13,7 +13,7 @@
export const VOCABULARIO = { export const VOCABULARIO = {
// Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del // Estados de la columna status del board Leads: ESTADOS_LEAD_PERMITIDOS del
// API + NUEVO (captura) y DESCARTADO (terminal) // 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') // Etapas del funnel de Oportunidades (labels REALES de la columna status 'Estado')
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'], FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES de la // 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 // Labels oficiales de desarrollo (verificados el 2026-09-04; SOLE y MUTUO
// agregados 2026-09 — existen en captura e inventario) // agregados 2026-09 — existen en captura e inventario)
DESARROLLOS: ['ATELIER', 'DIAGONAL', 'ESTANZA', 'SENDA', 'SOLANA', 'SOLE', 'MUTUO'], 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 } 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 FaseLead = (typeof VOCABULARIO.FASES_LEAD)[number]
export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number] export type FaseOportunidad = (typeof VOCABULARIO.FASES_OPORTUNIDAD)[number]
export type FaseApartado = (typeof VOCABULARIO.FASES_APARTADO)[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' } } }, include: { mensajes: { orderBy: { createdAt: 'asc' } } },
}) })
if (!conversacion) throw new NotFoundError('Conversación no encontrada') if (!conversacion) throw new NotFoundError('Conversación no encontrada')
// Propiedad: misma regla que al borrar — sólo el dueño o un rol de
// supervisión puede leer la conversación.
const tokenLectura = req.usuarioToken
if (tokenLectura && !esAdminOManager(tokenLectura.rol) && conversacion.usuarioId !== tokenLectura.sub) {
throw new AppError(403, 'Solo puedes ver tus conversaciones')
}
res.json({ res.json({
id: conversacion.id, id: conversacion.id,
createdAt: conversacion.createdAt, createdAt: conversacion.createdAt,

View File

@@ -22,6 +22,8 @@ const cambiarEstadoSchema = z.object({
motivo: z.string().optional(), motivo: z.string().optional(),
subcategoria: z.string().optional(), subcategoria: z.string().optional(),
comentario: 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 body = cambiarEstadoSchema.parse(req.body)
const itemId = String(req.params['itemId']) const itemId = String(req.params['itemId'])
await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', { await crmLocal.cambiarEstado(itemId, body.estado, 'APARTADO', {
fecha: body.fecha,
usuarioId: req.usuarioToken?.sub, usuarioId: req.usuarioToken?.sub,
sombra: !esIdLocal(itemId), 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 // Las cancelaciones (apartado y escrituración) guardan el motivo en la fila
// para los reportes de cancelación (Cliente/Motivo). // para los reportes de cancelación (Cliente/Motivo).
const esCancelacion = body.estado === 'Cancelacion Apartado' || body.estado === 'Cancelo' 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(prismaMock.auditoriaLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
createdAt: expect.objectContaining({ gte: expect.any(Date), lte: expect.any(Date) }), createdAt: expect.objectContaining({ gte: expect.any(Date), lt: expect.any(Date) }),
}), }),
}), }),
) )

View File

@@ -26,7 +26,7 @@ describe('buscarEndpoint (GET /api/buscar)', () => {
buscarMock.mockResolvedValue([]) buscarMock.mockResolvedValue([])
const r = res() const r = res()
await buscarEndpoint(req({ q: 'María' }), r) await buscarEndpoint(req({ q: 'María' }), r)
expect(buscarMock).toHaveBeenCalledWith('María', 20) expect(buscarMock).toHaveBeenCalledWith('María', 20, undefined)
expect(r.json).toHaveBeenCalledWith([]) expect(r.json).toHaveBeenCalledWith([])
}) })
@@ -34,25 +34,47 @@ describe('buscarEndpoint (GET /api/buscar)', () => {
buscarMock.mockResolvedValue([]) buscarMock.mockResolvedValue([])
const r = res() const r = res()
await buscarEndpoint(req({}), r) await buscarEndpoint(req({}), r)
expect(buscarMock).toHaveBeenCalledWith('', 20) expect(buscarMock).toHaveBeenCalledWith('', 20, undefined)
}) })
it('limite válido del query string', async () => { it('limite válido del query string', async () => {
buscarMock.mockResolvedValue([]) buscarMock.mockResolvedValue([])
const r = res() const r = res()
await buscarEndpoint(req({ q: 'x', limite: '10' }), r) 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 () => { it('limite inválido o > 50 se clampa a 20', async () => {
buscarMock.mockResolvedValue([]) buscarMock.mockResolvedValue([])
const r = res() const r = res()
await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r) await buscarEndpoint(req({ q: 'x', limite: 'abc' }), r)
expect(buscarMock).toHaveBeenCalledWith('x', 20) expect(buscarMock).toHaveBeenCalledWith('x', 20, undefined)
const r2 = res() const r2 = res()
await buscarEndpoint(req({ q: 'x', limite: '100' }), r2) 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 () => { 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> { export async function buscarEndpoint(req: Request, res: Response): Promise<void> {
const q = typeof req.query.q === 'string' ? req.query.q : '' 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 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) res.json(resultados)
} }

View File

@@ -82,7 +82,7 @@ describe('formaPagoLabel (tipos de crédito del alta)', () => {
describe('crearClienteSchema', () => { describe('crearClienteSchema', () => {
const base = { const base = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
viviendaCredito: { proyecto: 'SENDA' }, viviendaCredito: { proyecto: 'SENDA' },
} }
@@ -102,7 +102,7 @@ describe('crearClienteSchema', () => {
// ---------- Alta local (POST /api/clientes) ---------- // ---------- Alta local (POST /api/clientes) ----------
const altaBase = { const altaBase = {
datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123' }, datosGenerales: { nombreCompleto: 'Cliente Demo', curp: 'CURP123', rfc: 'RFC123', celular: '6649990003' },
viviendaCredito: { proyecto: 'SENDA' }, viviendaCredito: { proyecto: 'SENDA' },
} }
@@ -124,7 +124,15 @@ const oportunidadLocal = {
email: null, email: null,
desarrollo: 'SENDA', desarrollo: 'SENDA',
modelo: 'SOHO', 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', () => { describe('crearCliente (POST /api/clientes) — alta local', () => {
@@ -352,7 +360,7 @@ describe('alta progresiva — local', () => {
mondayApartadoId: null, mondayApartadoId: null,
}) })
const altaCompleta = { 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 }, viviendaCredito: { proyecto: 'SENDA', ubicacion: 'U-999', valorVenta: 1500000, enganche: 20000 },
leadMondayId: 'loc_op_9', leadMondayId: 'loc_op_9',
} }

View File

@@ -3,6 +3,7 @@ import path from 'node:path'
import fs from 'node:fs' import fs from 'node:fs'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import multer from 'multer' import multer from 'multer'
import * as XLSX from 'xlsx'
import { z } from 'zod' import { z } from 'zod'
import { config } from '../config/index.js' import { config } from '../config/index.js'
import { prisma } from '../services/prisma.js' import { prisma } from '../services/prisma.js'
@@ -20,7 +21,8 @@ export const crearClienteSchema = z
curp: z.string().min(1), curp: z.string().min(1),
rfc: z.string().min(1), rfc: z.string().min(1),
email: z.string().optional(), 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(), direccion: z.string().optional(),
// Perfil demográfico (opcionales; viajan en el payload del alta) // Perfil demográfico (opcionales; viajan en el payload del alta)
fechaNacimiento: z.string().optional(), 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 { return {
apartado: { apartado: {
id: captura.leadId, id: captura.leadId,
@@ -545,6 +563,7 @@ async function prefillApartadoLocal(itemId: string) {
Email: cliente.email ?? '', Email: cliente.email ?? '',
CURP: cliente.curp, CURP: cliente.curp,
RFC: cliente.rfc, RFC: cliente.rfc,
...contactoExtra,
} }
: null, : null,
oportunidad: oportunidadLocal oportunidad: oportunidadLocal
@@ -558,3 +577,132 @@ async function prefillApartadoLocal(itemId: string) {
cotizacion: cotizacion ? cotizacionPrefill(cotizacion) : null, 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

@@ -43,7 +43,7 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js' import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
import { DESARROLLOS } from '../services/cotizacion.service.js' import { DESARROLLOS, calcularNumMensualidades } from '../services/cotizacion.service.js'
/** /**
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la * Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
@@ -69,7 +69,28 @@ const bodyBase = {
acabados: false, acabados: false,
clienteNombre: 'Cliente Demo', clienteNombre: 'Cliente Demo',
clienteTelefono: '4771234567', 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 { function reqFake(body: unknown): Request {
@@ -110,10 +131,54 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
expect(status).toHaveBeenCalledWith(201) expect(status).toHaveBeenCalledWith(201)
const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown> const respuesta = json.mock.calls[0]?.[0] as Record<string, unknown>
expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null }) expect(respuesta).toMatchObject({ unidadCodigo: 'U-101', desarrollo: 'DIAGONAL', mondayItemId: null })
expect(respuesta['propuesta']).toBeDefined() expect(respuesta['esquema']).toBeDefined()
expect(respuesta['folio']).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 () => { it('la unidad no existe en el espejo → 404 sin crear cotización', async () => {
prismaMock.unidad.findUnique.mockResolvedValue(null) prismaMock.unidad.findUnique.mockResolvedValue(null)
const { res } = resFake() const { res } = resFake()
@@ -195,7 +260,7 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled() expect(prismaMock.leadCaptura.findFirst).not.toHaveBeenCalled()
}) })
it('toma la fecha de entrega de la captura: mensualidad y total ya no salen nulos', async () => { it('sin numMensualidades usa el default (14), acotado a los meses que faltan para la entrega', async () => {
prismaMock.capturaDepartamento.findMany.mockResolvedValue([ prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' }, { unidadCodigo: 'U-101', campo: 'habitabilidad', valor: '2027-10-08' },
]) ])
@@ -203,15 +268,15 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
await crearCotizacion(reqFake(bodyBase), res) await crearCotizacion(reqFake(bodyBase), res)
const esperadas = Math.min(14, calcularNumMensualidades('2027-10-08'))
const respuesta = json.mock.calls[0]?.[0] as { const respuesta = json.mock.calls[0]?.[0] as {
numMensualidades: number numMensualidades: number
mensualidad: number mensualidad: number
propuesta: { numMensualidades: number; mensualidad: number; mensualidadesTotal: number } esquema: { numMensualidades: number; mensualidad: number; baseDiferible: number }
} }
expect(respuesta.numMensualidades).toBeGreaterThan(0) expect(respuesta.numMensualidades).toBe(esperadas)
expect(respuesta.numMensualidades).toBe(respuesta.propuesta.numMensualidades) expect(respuesta.numMensualidades).toBe(respuesta.esquema.numMensualidades)
expect(respuesta.propuesta.mensualidad).toBeGreaterThan(0) expect(respuesta.esquema.mensualidad).toBeCloseTo(respuesta.esquema.baseDiferible / esperadas, 6)
expect(respuesta.propuesta.mensualidadesTotal).toBeGreaterThan(0)
}) })
it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => { it('las mensualidades elegidas por el vendedor viajan al cálculo y se persisten', async () => {
@@ -219,13 +284,79 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res) await crearCotizacion(reqFake({ ...bodyBase, numMensualidades: 12 }), res)
// DIAGONAL bancario sin acabados: remanente 75,000 en 12 pagos // Enganche diferible $75,000 en 12 pagos
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }), data: expect.objectContaining({ numMensualidades: 12, mensualidad: 6250 }),
}) })
const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; propuesta: { numMensualidades: number } } const respuesta = json.mock.calls[0]?.[0] as { numMensualidades: number; esquema: { numMensualidades: number } }
expect(respuesta.numMensualidades).toBe(12) expect(respuesta.numMensualidades).toBe(12)
expect(respuesta.propuesta.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 () => { it('unidad no DISPONIBLE (apartada/vendida) → 409 y no se crea cotización', async () => {
@@ -266,47 +397,41 @@ describe('crearCotizacion — reglas comerciales del admin', () => {
] ]
} }
it('sin reglas guardadas cotiza igual que siempre (respaldo en constantes)', async () => { it('sin reglas guardadas cotiza con el esquema único (respaldo en constantes)', async () => {
const { res, status } = resFake() const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res) await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201) expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0 }), data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0, precioFinal: 1_000_000 }),
}) })
}) })
it('usa el descuento configurado por el admin', async () => { it('los descuentos que configure el admin YA NO aplican: el esquema único no los usa', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue( prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }), reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
) )
const { res, status } = resFake() const { res, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res) await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201) expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({ expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0.1, precioFinal: 900_000 }), data: expect.objectContaining({ descuento: 0, precioFinal: 1_000_000 }),
}) })
}) })
it('propuesta desactivada → 409 con el mensaje del servidor y sin persistir', async () => { it('propuesta desactivada por el admin ya NO bloquea: el esquema único no usa propuestas', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue( prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }), reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
) )
const { res } = resFake() const { res, status } = resFake()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({ await crearCotizacion(reqFake(bodyBase), res)
status: 409,
message: expect.stringContaining('La propuesta BANCARIO no está disponible'),
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
// Las otras propuestas siguen cotizándose
const { res: res2, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res2)
expect(status).toHaveBeenCalledWith(201) expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalled()
}) })
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => { it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {
@@ -391,3 +516,27 @@ describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario)
expect(prismaMock.actividadLocal.create).not.toHaveBeenCalled() 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,12 +1,16 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import { getUnidadLocal } from '../services/unidades-local.service.js' import { getUnidadLocal } from '../services/unidades-local.service.js'
import { calcularNumMensualidades, obtenerPrecioLista } from '../services/cotizacion.service.js'
import { import {
calcularPropuesta, calcularEsquemaFrasa,
obtenerPrecioLista, MENSUALIDADES_DEFAULT,
propuestasVigentes, MENSUALIDADES_MAX,
TIPO_PROPUESTA_API, MENSUALIDADES_MIN,
} from '../services/cotizacion.service.js' PCT_ENGANCHE_FIRMA,
PCT_ESCRITURACION,
type EsquemaFrasaResultado,
} from '../services/cotizacion-esquema.service.js'
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js' import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
import { import {
procesarAlertasInventario, procesarAlertasInventario,
@@ -27,19 +31,30 @@ const crearCotizacionSchema = z.object({
clienteNombre: z.string().min(1), clienteNombre: z.string().min(1),
clienteEmail: z.string().email().optional(), clienteEmail: z.string().email().optional(),
clienteTelefono: z.string().min(7).optional(), clienteTelefono: z.string().min(7).optional(),
tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'COFINAVIT', 'CONTADO', 'SEMI_CONTADO']), // Esquema único FRASA 2026: mensualidades del diferimiento (elección del
descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%) // vendedor, 1..36; sin ellas va el default de 14) y si la escrituración
// Enganche elegido por el asesor (fracción, mínimo 2.5%); solo aplica a // (5.5%) se difiere con el enganche o se paga a la firma
// esquemas con crédito (bancario/Infonavit). El Excel permite 2.5%–5%. numMensualidades: z
enganchePct: z.number().min(0.025, 'El enganche mínimo es 2.5%').max(1).optional(), .number()
// Mensualidades elegidas por el asesor; sin él se resuelve con la fecha de .int('Las mensualidades deben ser un número entero')
// entrega de la unidad (o el plazo por defecto del desarrollo) .min(MENSUALIDADES_MIN, `Mínimo ${MENSUALIDADES_MIN} mensualidad`)
numMensualidades: z.number().int().min(1).max(120).optional(), .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 // 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) // día manda el id; sin él se busca por teléfono)
oportunidadId: z.string().trim().min(1).optional(), 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) */ /** Unidad cotizable: solo inmuebles DISPONIBLES (apartar/vender no se puede cotizar dos veces) */
function validarUnidadDisponible(unidad: { codigo: string; status: string }): void { function validarUnidadDisponible(unidad: { codigo: string; status: string }): void {
const status = unidad.status.trim().toUpperCase() const status = unidad.status.trim().toUpperCase()
@@ -83,8 +98,9 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
validarUnidadDisponible(unidad) validarUnidadDisponible(unidad)
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con // Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
// respaldo en las constantes históricas. Un desarrollo o una propuesta // respaldo en las constantes históricas. Un desarrollo desactivado se
// desactivados se rechazan con el mensaje que muestra el cotizador. // 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) const reglas = await getReglasDesarrollo(unidad.desarrollo)
if (reglas.activoEnCotizador === false) { if (reglas.activoEnCotizador === false) {
throw new AppError( throw new AppError(
@@ -92,26 +108,33 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`, `El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
) )
} }
const tipoInterno = TIPO_PROPUESTA_API[body.tipoPropuesta]
if (!propuestasVigentes(reglas).some((p) => p.tipo === tipoInterno)) {
throw new AppError(
409,
`La propuesta ${body.tipoPropuesta} no está disponible para el desarrollo ${reglas.id}. Elige otra propuesta.`,
)
}
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT // El API RECALCULA el esquema server-side con el precio de lista de la
const propuesta = calcularPropuesta( // unidad: no se confía en los montos que manda el cliente.
unidad, const apartado = body.apartado ?? APARTADO_DEFAULT
body.tipoPropuesta, // El diferimiento no puede rebasar la entrega estimada de la unidad (captura
body.acabados, // de obra): se acota a los meses que faltan (36 sigue siendo el tope global)
apartado, const topeMensualidades = unidad.fechaEntrega
body.descuento, ? Math.min(MENSUALIDADES_MAX, Math.max(MENSUALIDADES_MIN, calcularNumMensualidades(unidad.fechaEntrega)))
body.enganchePct, : MENSUALIDADES_MAX
body.numMensualidades, const numMensualidades = Math.min(body.numMensualidades ?? MENSUALIDADES_DEFAULT, topeMensualidades)
reglas, const escrituracionDiferida = body.escrituracionDiferida ?? false
)
const precioLista = obtenerPrecioLista(unidad, body.acabados) 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({ const cotizacion = await prisma.cotizacion.create({
data: { data: {
@@ -121,18 +144,30 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
clienteNombre: body.clienteNombre, clienteNombre: body.clienteNombre,
clienteEmail: body.clienteEmail ?? null, clienteEmail: body.clienteEmail ?? null,
clienteTelefono: body.clienteTelefono ?? null, clienteTelefono: body.clienteTelefono ?? null,
tipoPropuesta: body.tipoPropuesta,
acabados: body.acabados, acabados: body.acabados,
apartado, apartado,
descuento: propuesta.descuento,
precioLista, precioLista,
precioFinal: propuesta.precioFinal, // Columnas nuevas del esquema único
enganche: propuesta.enganche, esquema: 'FRASA_2026',
// 0 => no se pudo resolver el plazo (no debería ocurrir: los desarrollos saldoFirma: esquema.saldoFirma,
// tienen plazo por defecto). Si no, el N elegido por el vendedor. pctEngancheFirma: PCT_ENGANCHE_FIRMA,
numMensualidades: propuesta.numMensualidades ?? 0, engancheDiferible: esquema.enganche,
mensualidad: propuesta.mensualidad, pctEscrituracion: PCT_ESCRITURACION,
pagoFinal: propuesta.pagoFinal, 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,
}, },
}) })
@@ -149,8 +184,12 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
mondayItemId = opExistente mondayItemId = opExistente
await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } }) await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } })
try { try {
// sombra:false: sólo se marca la etapa si el id corresponde a una
// oportunidad real. Antes (sombra para ids no locales) elegir un cliente
// del maestro en el buscador del cotizador creaba un LeadCaptura
// fantasma ("Registro previo (CRM anterior)") con pendienteSync.
await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', { await crmLocal.cambiarEstado(opExistente, 'Cotizador', 'OPORTUNIDADES', {
sombra: !esIdLocal(opExistente), sombra: false,
usuarioId: req.usuarioToken?.sub, usuarioId: req.usuarioToken?.sub,
}) })
} catch (err) { } catch (err) {
@@ -159,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) auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'CREAR_COTIZACION', 'cotizacion', cotizacion.id)
} }
@@ -215,7 +254,15 @@ export async function obtenerCotizacionPublica(req: Request, res: Response): Pro
folio: folioCotizacion(cotizacion.id), folio: folioCotizacion(cotizacion.id),
desarrollo: cotizacion.desarrollo, desarrollo: cotizacion.desarrollo,
unidadCodigo: cotizacion.unidadCodigo, unidadCodigo: cotizacion.unidadCodigo,
// Esquema único FRASA 2026 (filas nuevas) + legacy (filas BANCARIO, …)
esquema: cotizacion.esquema,
tipoPropuesta: cotizacion.tipoPropuesta, tipoPropuesta: cotizacion.tipoPropuesta,
precioLista: cotizacion.precioLista,
apartado: cotizacion.apartado,
saldoFirma: cotizacion.saldoFirma,
engancheDiferible: cotizacion.engancheDiferible,
escrituracion: cotizacion.escrituracion,
escrituracionDiferida: cotizacion.escrituracionDiferida,
precioFinal: cotizacion.precioFinal, precioFinal: cotizacion.precioFinal,
enganche: cotizacion.enganche, enganche: cotizacion.enganche,
numMensualidades: cotizacion.numMensualidades, numMensualidades: cotizacion.numMensualidades,

View File

@@ -128,6 +128,16 @@ export async function eliminarUnidadEndpoint(req: Request, res: Response): Promi
auditoria.registrar(usuarioDe(req), 'ELIMINAR_UNIDAD', 'unidad', codigo) 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) ────────────────────────────────────────────── // ── Público (micrositio) ──────────────────────────────────────────────
/** GET /api/publico/desarrollos */ /** GET /api/publico/desarrollos */

View File

@@ -34,6 +34,7 @@ const prismaMock = vi.hoisted(() => ({
findMany: vi.fn(), findMany: vi.fn(),
}, },
automatizacionState: { automatizacionState: {
findFirst: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
}, },
@@ -83,6 +84,7 @@ beforeEach(() => {
prismaMock.usuario.findUnique.mockResolvedValue(null) prismaMock.usuario.findUnique.mockResolvedValue(null)
prismaMock.cliente.findMany.mockResolvedValue([]) prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.findFirst.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({}) prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops)) 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({ reqCon({
body: { body: {
nombre: 'Lead Prueba', nombre: 'Lead Prueba',
telefono: '4771234567',
oportunidadId: 'loc_op_3', oportunidadId: 'loc_op_3',
desarrollo: 'SENDA', desarrollo: 'SENDA',
modelo: 'SOHO', modelo: 'SOHO',
@@ -448,17 +451,30 @@ describe('admin CRUD de leads — local', () => {
expect(json).toHaveBeenCalledWith({ eliminado: true, itemId: 'loc_1' }) 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.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() const { res, json } = resFake()
await asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { usuarioId: 'usr-9' } }), res) await asignarLeadEndpoint(reqCon({ params: { itemId: 'loc_1' }, body: { usuarioId: 'usr-9' } }), res)
expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({ expect(prismaMock.leadCaptura.updateMany).toHaveBeenCalledWith({
where: { leadId: 'loc_1' }, where: { leadId: { in: ['loc_1'] } },
data: { usuarioId: 'usr-9' }, 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 () => { it('asignarLeadEndpoint con usuario inexistente → 404', async () => {

View File

@@ -1,6 +1,6 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import { VOCABULARIO } from '../config/vocabulario.js' import { VOCABULARIO, afluenciaCompleta } from '../config/vocabulario.js'
import { prisma } from '../services/prisma.js' import { prisma } from '../services/prisma.js'
import { crmLocal, esIdLocal } from '../services/crm-local.service.js' import { crmLocal, esIdLocal } from '../services/crm-local.service.js'
import * as auditoria from '../services/auditoria.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 */ /** Alta Afluencia (tablero Oportunidades): captura con perfil completo */
const crearAfluenciaSchema = z.object({ const crearAfluenciaSchema = z.object({
nombre: z.string().trim().min(1, 'El nombre es obligatorio'), 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(), email: z.string().trim().email('Email inválido').optional(),
origen: z.string().trim().min(1).optional(), origen: z.string().trim().min(1).optional(),
desarrollo: 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) 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 * 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) * 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'), 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> { export async function asignarLeadEndpoint(req: Request, res: Response): Promise<void> {
const itemId = String(req.params['itemId']) const itemId = String(req.params['itemId'])
const { usuarioId } = asignarLeadSchema.parse(req.body) const { usuarioId } = asignarLeadSchema.parse(req.body)
@@ -328,13 +351,9 @@ export async function asignarLeadEndpoint(req: Request, res: Response): Promise<
select: { nombre: true }, select: { nombre: true },
}) })
if (!usuario) throw new NotFoundError('Usuario no encontrado') if (!usuario) throw new NotFoundError('Usuario no encontrado')
// La asignación local es la verdad: se actualiza la fila del lead const movidas = await crmLocal.reasignarCadena(itemId, usuarioId)
await prisma.leadCaptura.updateMany({ res.json({ itemId, usuarioId, nombre: usuario.nombre, movidas })
where: { leadId: itemId }, auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, `${usuario.nombre} (${movidas} fila(s))`)
data: { usuarioId },
})
res.json({ itemId, usuarioId, nombre: usuario.nombre })
auditoria.registrar(req.usuarioToken?.sub ?? 'sistema', 'ASIGNAR_LEAD', 'lead', itemId, usuarioId)
} }
const moverLeadSchema = z.object({ 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> { export async function buscarLeadsEndpoint(req: Request, res: Response): Promise<void> {
const q = String(req.query['q'] ?? '').trim() const q = String(req.query['q'] ?? '').trim()
if (q.length < 2) throw new AppError(400, 'La búsqueda requiere al menos 2 caracteres') 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

@@ -155,6 +155,8 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
}) })
it("'Aparto': crea el apartado local (crmLocal.apartar) con la unidad en datosJson 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({ prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op', leadId: 'loc_op',
usuarioId: 'u-1', usuarioId: 'u-1',
@@ -163,7 +165,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
email: null, email: null,
desarrollo: 'ATELIER', desarrollo: 'ATELIER',
modelo: 'SOHO', modelo: 'SOHO',
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }), 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() const { res, json } = resFake()
@@ -179,7 +189,15 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
board: 'APARTADO', board: 'APARTADO',
etapa: 'Pago Apartado', etapa: 'Pago Apartado',
nombreLead: 'Luis Demo', nombreLead: 'Luis Demo',
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }), 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 // La unidad asignada queda APARTADA en el espejo local, sin pisar vendidas
@@ -190,6 +208,26 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
expect(json).toHaveBeenCalledWith({ itemId: 'loc_op', etapa: 'Aparto' }) 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',
nombreLead: 'Luis Demo',
telefono: '6641112233',
email: null,
desarrollo: 'ATELIER',
modelo: 'SOHO',
datosJson: JSON.stringify({ unidadCodigo: 'U-101' }),
})
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 () => { it("'Aparto' sin unidad en la oportunidad: la toma de la última cotización ligada", async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue({ prismaMock.leadCaptura.findUnique.mockResolvedValue({
leadId: 'loc_op', leadId: 'loc_op',
@@ -199,7 +237,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
email: null, email: null,
desarrollo: 'SENDA', desarrollo: 'SENDA',
modelo: 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',
}),
}) })
prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' }) prismaMock.cotizacion.findFirst.mockResolvedValue({ unidadCodigo: 'SENDA-411-1-7' })
const { res } = resFake() const { res } = resFake()
@@ -212,7 +257,17 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
select: { unidadCodigo: true }, select: { unidadCodigo: true },
}) })
expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({ expect(prismaMock.leadCaptura.create).toHaveBeenCalledWith({
data: expect.objectContaining({ datosJson: JSON.stringify({ unidadCodigo: 'SENDA-411-1-7' }) }), 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',
}),
}),
}) })
expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({ expect(prismaMock.unidad.updateMany).toHaveBeenCalledWith({
where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } }, where: { codigo: 'SENDA-411-1-7', status: { notIn: [...STATUS_NO_BLOQUEABLES] } },
@@ -229,7 +284,14 @@ describe('cambiarEtapaOportunidadEndpoint (POST /api/oportunidades/:id/etapa)
email: null, email: null,
desarrollo: null, desarrollo: null,
modelo: 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() const { res } = resFake()

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const svcMock = vi.hoisted(() => ({ const svcMock = vi.hoisted(() => ({
crear: vi.fn(), crear: vi.fn(),
listar: vi.fn(), listar: vi.fn(),
listarPorCliente: vi.fn(),
completar: vi.fn(), completar: vi.fn(),
eliminar: vi.fn(), eliminar: vi.fn(),
proximos: vi.fn(), proximos: vi.fn(),
@@ -72,6 +73,33 @@ describe('recordatorio-vendedor.controller', () => {
expect(res.json).toHaveBeenCalledWith([]) 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 () => { it('completar: llama al service con el id', async () => {
svcMock.completar.mockResolvedValue({ id: 'r1', completado: true }) svcMock.completar.mockResolvedValue({ id: 'r1', completado: true })
const res = resJson() const res = resJson()

View File

@@ -39,6 +39,17 @@ export async function listarRecordatoriosEndpoint(req: Request, res: Response):
const usuarioId = req.usuarioToken?.sub const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida') 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 completadosStr = req.query['completados'] as string | undefined
const opts: { completados?: boolean; desde?: string | null; hasta?: string | null; clienteClave?: string | null } = { const opts: { completados?: boolean; desde?: string | null; hasta?: string | null; clienteClave?: string | null } = {
desde: (req.query['desde'] as 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> => { listar: async (req: Request, res: Response): Promise<void> => {
const usuarioId = req.usuarioToken?.sub const usuarioId = req.usuarioToken?.sub
if (!usuarioId) throw new Error('Auth requerida') 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 completadosStr = req.query['completados'] as string | undefined
const opts: { const opts: {
completados?: boolean completados?: boolean

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 { config } from './config/index.js'
import { automatizacionesService } from './services/automatizaciones.service.js' import { automatizacionesService } from './services/automatizaciones.service.js'
import * as configService from './services/config.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' import { recordatoriosCobranzaService } from './services/recordatorios-cobranza.service.js'
const app = crearApp() const app = crearApp()
@@ -64,6 +65,18 @@ if (config.automatizaciones.enabled) {
}) })
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`Recordatorios de cobranza activos (cron: "${config.recordatoriosCobranza.cron}")`) 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.) // Precarga de configuraciones desde DB (modo_local_forzado, etc.)

View File

@@ -15,12 +15,14 @@ import {
actualizarProgreso, actualizarProgreso,
completarProgreso, completarProgreso,
crearCliente, crearCliente,
importarEnkontrolEndpoint,
iniciarProgreso, iniciarProgreso,
listarDocumentos, listarDocumentos,
obtenerCliente, obtenerCliente,
prefillApartadoEndpoint, prefillApartadoEndpoint,
subirDocumentos, subirDocumentos,
uploadDocumentos, uploadDocumentos,
uploadImportacionEnkontrol,
} from '../controllers/clientes.controller.js' } from '../controllers/clientes.controller.js'
import { import {
eliminarBorrador, eliminarBorrador,
@@ -57,6 +59,7 @@ import { correrRecordatorios, listarRecordatorios } from '../controllers/recorda
import { import {
cambiarEstadoLeadEndpoint, cambiarEstadoLeadEndpoint,
crearAfluenciaEndpoint, crearAfluenciaEndpoint,
obtenerAfluenciaEndpoint,
crearLeadEndpoint, crearLeadEndpoint,
buscarLeadsEndpoint, buscarLeadsEndpoint,
convertirLeadEndpoint, convertirLeadEndpoint,
@@ -93,6 +96,14 @@ import {
subirDocumento, subirDocumento,
} from '../controllers/ai.controller.js' } from '../controllers/ai.controller.js'
import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.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 { resumenReportes, scoringEndpoint, moraEndpoint } from '../controllers/reportes.controller.js'
import { import {
requireRolReportes, requireRolReportes,
@@ -167,6 +178,7 @@ import {
import { import {
actualizarDesarrolloEndpoint, actualizarDesarrolloEndpoint,
actualizarModeloEndpoint, actualizarModeloEndpoint,
actualizarUnidadEndpoint,
agregarModeloEndpoint, agregarModeloEndpoint,
crearDesarrolloEndpoint, crearDesarrolloEndpoint,
crearUnidadesEndpoint, crearUnidadesEndpoint,
@@ -206,6 +218,14 @@ apiRouter.get('/cotizaciones/:id/publico', obtenerCotizacionPublica)
apiRouter.get('/cotizaciones/:id', obtenerCotizacion) apiRouter.get('/cotizaciones/:id', obtenerCotizacion)
apiRouter.post('/clientes', crearCliente) 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" // Prefill del alta desde un apartado existente (botón "Alta Enkontrol"
// de Mis apartados). Va ANTES de /clientes/:id para no colisionar. // de Mis apartados). Va ANTES de /clientes/:id para no colisionar.
apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint) apiRouter.get('/altas/prefill-apartado/:itemId', prefillApartadoEndpoint)
@@ -274,6 +294,15 @@ apiRouter.post('/oportunidades/:mondayItemId/perder', perderOportunidadEndpoint)
apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint) apiRouter.post('/oportunidades/:itemId/etapa', cambiarEtapaOportunidadEndpoint)
// Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día) // Cambio de fase del pipeline Enkontrol desde "Mis apartados" (Mi día)
apiRouter.post('/apartados/:itemId/estado', cambiarEstadoApartadoEndpoint) 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 // 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) // el cliente ligado al apartado (se crea la ficha mínima si aún no existe)
apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado) apiRouter.get('/apartados/:itemId/documentos', listarDocumentosApartado)
@@ -305,6 +334,8 @@ apiRouter.post('/leads/:itemId/mover', requireAdmin, moverLeadEndpoint)
// Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades // Alta Afluencia (forma completa de /client): vive en el tablero Oportunidades
apiRouter.post('/afluencias', crearAfluenciaEndpoint) 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) apiRouter.patch('/usuarios/:id/rol', actualizarRolEndpoint)
@@ -339,9 +370,11 @@ apiRouter.post('/ai/chat/stream', chatIAStream)
apiRouter.get('/ai/conversations', listarConversaciones) apiRouter.get('/ai/conversations', listarConversaciones)
apiRouter.get('/ai/conversations/:id', obtenerConversacion) apiRouter.get('/ai/conversations/:id', obtenerConversacion)
apiRouter.delete('/ai/conversations/:id', eliminarConversacion) apiRouter.delete('/ai/conversations/:id', eliminarConversacion)
apiRouter.post('/ai/documents', subirDocumento) // Mantenimiento de la base de conocimiento: SOLO admin (admin/manager no
apiRouter.get('/ai/documents', listarDocumentosIA) // basta: borrar o inyectar documentos afecta a todos los usuarios del chat).
apiRouter.delete('/ai/documents/:id', eliminarDocumento) apiRouter.post('/ai/documents', requireAdminEstricto, subirDocumento)
apiRouter.get('/ai/documents', requireAdminEstricto, listarDocumentosIA)
apiRouter.delete('/ai/documents/:id', requireAdminEstricto, eliminarDocumento)
// Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso // Mantenimiento del asistente (admin): reindexar knowledge/ y ver uso
apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento) apiRouter.post('/ai/reindexar', requireAdmin, reindexarConocimiento)
apiRouter.get('/ai/estado', requireAdmin, estadoAsistente) apiRouter.get('/ai/estado', requireAdmin, estadoAsistente)
@@ -404,4 +437,5 @@ apiRouter.delete('/desarrollos/:id', requireAdminEstricto, eliminarDesarrolloEnd
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint) apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint) apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint) apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
apiRouter.patch('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, actualizarUnidadEndpoint)
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint) 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 { prisma } from './prisma.js'
import { config } from '../config/index.js' import { config } from '../config/index.js'
import { AppError, NotFoundError } from '../utils/errors.js' import { AppError, NotFoundError } from '../utils/errors.js'
import { esAdminOManager } from '../utils/roles.js'
import { getUnidadesLocal } from './unidades-local.service.js' import { getUnidadesLocal } from './unidades-local.service.js'
import { import {
APARTADO_DEFAULT, APARTADO_DEFAULT,
@@ -1020,10 +1021,12 @@ export function crearEjecutoresHerramientas(
}) })
if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` } if (planes.length === 0) return { error: `No hay planes de cobranza para "${cliente}"` }
const hoy = new Date() const hoy = new Date()
// Medianoche de hoy: lo que vence HOY aún no está vencido
const inicioHoy = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate())
return planes.map((p) => { return planes.map((p) => {
const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0) const pagado = p.pagos.reduce((a, x) => a + x.montoPagado, 0)
const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0) const saldo = p.pagos.reduce((a, x) => a + (x.monto - x.montoPagado), 0)
const vencidos = p.pagos.filter((x) => x.fechaVencimiento < hoy && x.montoPagado < x.monto) const vencidos = p.pagos.filter((x) => x.fechaVencimiento < inicioHoy && x.montoPagado < x.monto)
const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy) const proximo = p.pagos.filter((x) => x.montoPagado < x.monto && x.fechaVencimiento >= hoy)
.sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0] .sort((a, b) => a.fechaVencimiento.getTime() - b.fechaVencimiento.getTime())[0]
const abonos = p.pagos const abonos = p.pagos
@@ -1054,7 +1057,8 @@ export function crearEjecutoresHerramientas(
prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }), prisma.planPago.count({ where: { status: 'ACTIVO', ...alcance } }),
prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }), prisma.planPago.count({ where: { status: 'CANCELADO', ...alcance } }),
prisma.pago.findMany({ prisma.pago.findMany({
where: { fechaVencimiento: { lt: new Date() }, plan: alcance }, // Sólo planes activos: el adeudo de planes cancelados no cuenta
where: { fechaVencimiento: { lt: new Date() }, plan: { ...alcance, status: 'ACTIVO' } },
select: { select: {
monto: true, monto: true,
montoPagado: true, montoPagado: true,
@@ -1505,6 +1509,18 @@ export async function chat(
? await prisma.aIConversacion.findUnique({ where: { id: sessionId } }) ? await prisma.aIConversacion.findUnique({ where: { id: sessionId } })
: null : null
if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada') if (sessionId && !conversacion) throw new NotFoundError('Conversación no encontrada')
// Propiedad: sólo el dueño (o un rol de supervisión) puede reanudar una
// conversación existente. Sin esto, cualquiera con el uuid continuaba el
// chat ajeno con todo su historial.
if (
conversacion &&
opciones.usuarioId &&
conversacion.usuarioId &&
conversacion.usuarioId !== opciones.usuarioId &&
!esAdminOManager(opciones.contexto?.rol)
) {
throw new AppError(403, 'No puedes continuar la conversación de otro usuario')
}
conversacion ??= await prisma.aIConversacion.create({ conversacion ??= await prisma.aIConversacion.create({
data: { usuarioId: opciones.usuarioId ?? null }, data: { usuarioId: opciones.usuarioId ?? null },
}) })

View File

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

View File

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

View File

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

View File

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

View File

@@ -193,7 +193,7 @@ describe('fechas de Obra bloqueadas', () => {
expect(prismaMock.$transaction).not.toHaveBeenCalled() 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([ prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' }, { unidadCodigo: 'U-101', campo: 'entrega_programada_fecha', valor: '2026-10-01' },
]) ])
@@ -201,11 +201,11 @@ describe('fechas de Obra bloqueadas', () => {
guardarCampo( guardarCampo(
'SENDA', 'SENDA',
'U-101', 'U-101',
'ESCRITURACION', 'OBRA',
'entrega_programada_fecha', 'entrega_programada_fecha',
'2026-10-15', '2026-10-15',
'user-1', 'user-1',
'escrituracion', 'obra',
), ),
).rejects.toThrow('solo puede modificarlas un administrador') ).rejects.toThrow('solo puede modificarlas un administrador')
expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled() expect(prismaMock.capturaDepartamento.upsert).not.toHaveBeenCalled()

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({ const prismaMock = vi.hoisted(() => ({
cliente: { findMany: vi.fn() }, cliente: { findMany: vi.fn() },
leadCaptura: { findMany: vi.fn() }, leadCaptura: { findMany: vi.fn() },
usuario: { findMany: vi.fn() },
})) }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -13,12 +14,42 @@ beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
prismaMock.cliente.findMany.mockResolvedValue([]) prismaMock.cliente.findMany.mockResolvedValue([])
prismaMock.leadCaptura.findMany.mockResolvedValue([]) prismaMock.leadCaptura.findMany.mockResolvedValue([])
prismaMock.usuario.findMany.mockResolvedValue([{ id: 'u-1', username: 'alfonso castillo' }])
}) })
describe('exportarCSV', () => { describe('exportarCSV', () => {
it('genera CSV con encabezados cuando no hay contactos', async () => { it('genera CSV con encabezados cuando no hay contactos', async () => {
const csv = await exportarCSV() 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 () => { it('incluye clientes y capturas en el CSV', async () => {

View File

@@ -212,7 +212,8 @@ describe('listarContactosLocal', () => {
}) })
it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => { it('pagina de 100 en 100 con hayMas (la página 2 trae el siguiente bloque)', async () => {
// 101 clientes: la página 1 llena 100 y detecta que hay más // 101 clientes: la paginación se aplica DESPUÉS de fusionar fuentes, así
// que el servicio pide el universo completo y corta en memoria
prismaMock.cliente.findMany.mockResolvedValue( prismaMock.cliente.findMany.mockResolvedValue(
Array.from({ length: 101 }, (_, i) => Array.from({ length: 101 }, (_, i) =>
clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }), clienteFake({ id: `c${i + 1}`, celular: `4770000${String(i).padStart(3, '0')}`, createdAt: new Date(2026, 8, 1, 0, 0, 0, i * 1000) }),
@@ -225,13 +226,12 @@ describe('listarContactosLocal', () => {
expect(pagina1.contactos).toHaveLength(100) expect(pagina1.contactos).toHaveLength(100)
expect(pagina1.hayMas).toBe(true) expect(pagina1.hayMas).toBe(true)
// La página 2 recibe el siguiente bloque (skip=100, take=101) // La página 2 trae la fila más vieja (orden desc por fecha: la 101 va
prismaMock.cliente.findMany.mockResolvedValue([ // primero en la página 1)
clienteFake({ id: 'c101', celular: '4770000100' }),
])
const pagina2 = await listarContactosLocal(undefined, 2) const pagina2 = await listarContactosLocal(undefined, 2)
expect(pagina2.pagina).toBe(2) expect(pagina2.pagina).toBe(2)
expect(pagina2.contactos).toHaveLength(1) expect(pagina2.contactos).toHaveLength(1)
expect(pagina2.contactos[0]?.id).toBe('c1')
expect(pagina2.hayMas).toBe(false) expect(pagina2.hayMas).toBe(false)
}) })
}) })

View File

@@ -92,12 +92,12 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
const porPagina = CONTACTOS_POR_PAGINA const porPagina = CONTACTOS_POR_PAGINA
const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1 const paginaValida = Number.isFinite(pagina) && pagina >= 1 ? Math.floor(pagina) : 1
const skip = (paginaValida - 1) * porPagina const skip = (paginaValida - 1) * porPagina
// +1 por fuente para detectar si queda más (hayMas) sin una consulta extra // La paginación se aplica DESPUÉS de fusionar las dos fuentes: antes se hacía
// skip/take por fuente y el dedupe posterior perdía contactos entre páginas
// y daba hayMas=false antes de tiempo.
const clientes = await prisma.cliente.findMany({ const clientes = await prisma.cliente.findMany({
where: usuarioId ? { capturadoPorId: usuarioId } : {}, where: usuarioId ? { capturadoPorId: usuarioId } : {},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
skip,
take: porPagina + 1,
}) })
const capturas = await prisma.leadCaptura.findMany({ const capturas = await prisma.leadCaptura.findMany({
where: { where: {
@@ -105,8 +105,6 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
...(usuarioId ? { usuarioId } : {}), ...(usuarioId ? { usuarioId } : {}),
}, },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
skip,
take: porPagina + 1,
}) })
const lista: Candidato[] = [] const lista: Candidato[] = []
@@ -207,12 +205,11 @@ export async function listarContactosLocal(usuarioId?: string, pagina = 1): Prom
}) })
} }
const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) const ordenada = lista.map((c) => c.contacto).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
const hayMas = ordenada.length > porPagina
return { return {
contactos: ordenada.slice(0, porPagina), contactos: ordenada.slice(skip, skip + porPagina),
pagina: paginaValida, pagina: paginaValida,
porPagina, porPagina,
hayMas, hayMas: ordenada.length > skip + porPagina,
} }
} }
@@ -450,6 +447,9 @@ export async function exportarCSV(
}, },
orderBy: { updatedAt: 'desc' }, 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 { interface FilaCSV {
Nombre: string Nombre: string
@@ -458,6 +458,7 @@ export async function exportarCSV(
Desarrollo: string Desarrollo: string
Estatus: string Estatus: string
'Última actividad': string 'Última actividad': string
Asesor: string
} }
const filas: FilaCSV[] = [] const filas: FilaCSV[] = []
@@ -479,6 +480,7 @@ export async function exportarCSV(
Desarrollo: c.proyecto, Desarrollo: c.proyecto,
Estatus: 'Cliente', Estatus: 'Cliente',
'Última actividad': c.createdAt.toISOString().slice(0, 10), 'Última actividad': c.createdAt.toISOString().slice(0, 10),
Asesor: c.capturadoPorId ? (nombreAsesor.get(c.capturadoPorId) ?? '') : '',
}) })
} }
@@ -494,6 +496,7 @@ export async function exportarCSV(
Desarrollo: cap.desarrollo ?? '', Desarrollo: cap.desarrollo ?? '',
Estatus: cap.etapa, Estatus: cap.etapa,
'Última actividad': cap.updatedAt.toISOString().slice(0, 10), 'Última actividad': cap.updatedAt.toISOString().slice(0, 10),
Asesor: cap.usuarioId ? (nombreAsesor.get(cap.usuarioId) ?? '') : '',
}) })
} }
@@ -509,10 +512,10 @@ export async function exportarCSV(
} }
// Generar CSV // 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 escape = (s: string) => (s.includes(',') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s)
const lineas = filtradas.map((f) => 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') return [encabezado, ...lineas].join('\n')

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

@@ -16,6 +16,7 @@ const prismaMock = vi.hoisted(() => ({
findUnique: vi.fn(), findUnique: vi.fn(),
}, },
automatizacionState: { automatizacionState: {
findFirst: vi.fn(),
findUnique: vi.fn(), findUnique: vi.fn(),
upsert: vi.fn(), upsert: vi.fn(),
}, },
@@ -43,6 +44,7 @@ beforeEach(() => {
prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data) prismaMock.leadCaptura.update.mockImplementation(async ({ data }) => data)
prismaMock.leadOportunidad.create.mockResolvedValue({}) prismaMock.leadOportunidad.create.mockResolvedValue({})
prismaMock.automatizacionState.findUnique.mockResolvedValue(null) prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.findFirst.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({}) prismaMock.automatizacionState.upsert.mockResolvedValue({})
}) })
@@ -234,9 +236,26 @@ describe('CrmLocalService.apartar', () => {
email: 'luis@correo.com', email: 'luis@correo.com',
desarrollo: 'ATELIER', desarrollo: 'ATELIER',
modelo: 'SOHO', 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 () => { it('pone la oportunidad en Aparto y crea el apartado local (Pago Apartado)', async () => {
prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad) prismaMock.leadCaptura.findUnique.mockResolvedValue(filaOportunidad)
const apartado = await svc.apartar('loc_op', 'u-1') const apartado = await svc.apartar('loc_op', 'u-1')
@@ -311,8 +330,326 @@ describe('CrmLocalService.buscarLocales', () => {
modeloInteres: 'SOHO', modeloInteres: 'SOHO',
}) })
expect(r[1]).toMatchObject({ id: 'cli-9', nombre: 'Ana Cliente', fuente: 'oportunidad', desarrollo: 'ATELIER' }) 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(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 { randomUUID } from 'node:crypto'
import { AppError, NotFoundError } from '../utils/errors.js' import { AppError, NotFoundError } from '../utils/errors.js'
import { afluenciaCompleta } from '../config/vocabulario.js'
import { claveApartadoCreado } from './automatizaciones.service.js' import { claveApartadoCreado } from './automatizaciones.service.js'
import { calcularClienteClave } from './recordatorio-vendedor.service.js' import { calcularClienteClave } from './recordatorio-vendedor.service.js'
import { prisma } from './prisma.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). * teléfono y email entre los leads locales (mismo contrato 409 de siempre).
*/ */
async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> { async crearLead(datos: DatosLeadLocal, usuarioId?: string): Promise<ResultadoCapturaLocal> {
// Dedup por teléfono (normalizado a los últimos 10 dígitos) en CUALQUIER const telNorm =
// tablero: evita que la misma persona quede como lead y como afluencia. datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
if (datos.telefono !== undefined && datos.telefono.trim() !== '') { const emailNorm =
const telLimpio = soloDigitos(datos.telefono).slice(-10) datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
if (telLimpio.length >= 7) { // Serializa la captura por teléfono/email: dos envíos simultáneos no deben
const existente = await buscarPorTelefono(telLimpio) // pasar juntos la validación de duplicado.
if (existente) return { duplicado: { id: existente.leadId, nombre: existente.nombreLead } } 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 // Dedup por email (minúsculas): mismo criterio que el teléfono (cualquier
if (datos.email !== undefined && datos.email.trim() !== '') { // 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({ 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 } } if (existenteEmail) return { duplicado: { id: existenteEmail.leadId, nombre: existenteEmail.nombreLead } }
} }
@@ -100,7 +110,8 @@ export class CrmLocalService {
usuarioId: usuarioId ?? 'desconocido', usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre, nombreLead: datos.nombre,
telefono: datos.telefono ?? null, telefono: datos.telefono ?? null,
email: datos.email ?? null, telefonoNorm: telNorm,
email: emailNorm,
origen: datos.origen ?? null, origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null, desarrollo: datos.desarrollo ?? null,
board: 'LEADS', board: 'LEADS',
@@ -110,6 +121,7 @@ export class CrmLocalService {
}, },
}) })
return { id: fila.leadId, oportunidadId: null } return { id: fila.leadId, oportunidadId: null }
})
} }
/** /**
@@ -123,35 +135,22 @@ export class CrmLocalService {
if (oportunidadId !== undefined) { if (oportunidadId !== undefined) {
const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } }) const existente = await prisma.leadCaptura.findUnique({ where: { leadId: oportunidadId } })
// Sin fila local (id heredado del CRM anterior): se crea la sombra con // Sin fila local: id inválido o liga guardada de una limpieza anterior.
// ese id para que la captura no se pierda en silencio. // Antes se creaba una oportunidad "sombra" fantasma con ese id; ahora se
// rechaza para no sembrar registros huérfanos en Oportunidades.
if (!existente) { if (!existente) {
await prisma.leadCaptura.create({ throw new NotFoundError(`No existe la oportunidad ${oportunidadId}`)
data: {
leadId: oportunidadId,
oportunidadId,
usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre,
telefono: datos.telefono ?? null,
email: datos.email ?? null,
origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null,
board: 'OPORTUNIDADES',
etapa: 'Afluencia',
datosJson: JSON.stringify(datos),
pendienteSync: true,
},
})
return { id: oportunidadId, oportunidadId, actualizada: true }
} }
// Sólo se pisan los campos capturados: lo que no viene en el payload se // Sólo se pisan los campos capturados: lo que no viene en el payload se
// conserva (antes se borraba con null y se perdía la cita agendada). // conserva (antes se borraba con null y se perdía la cita agendada).
const cambio: Record<string, unknown> = { pendienteSync: true } const cambio: Record<string, unknown> = { pendienteSync: true }
if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre if (datos.nombre.trim() !== '') cambio['nombreLead'] = datos.nombre
if (datos.telefono !== undefined) cambio['telefono'] = datos.telefono if (datos.telefono !== undefined) {
if (datos.email !== undefined) cambio['email'] = datos.email 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.origen !== undefined) cambio['origen'] = datos.origen
if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo if (datos.desarrollo !== undefined) cambio['desarrollo'] = datos.desarrollo
if (modelo !== undefined) cambio['modelo'] = modelo if (modelo !== undefined) cambio['modelo'] = modelo
@@ -176,12 +175,16 @@ export class CrmLocalService {
return { id: oportunidadId, oportunidadId, actualizada: true } return { id: oportunidadId, oportunidadId, actualizada: true }
} }
if (datos.telefono !== undefined && datos.telefono.trim() !== '') { const telNorm =
const telLimpio = soloDigitos(datos.telefono).slice(-10) datos.telefono !== undefined && datos.telefono.trim() !== '' ? normalizaTelefono(datos.telefono) : null
if (telLimpio.length >= 7) { const emailNorm =
const duplicado = await buscarPorTelefono(telLimpio) datos.email !== undefined && datos.email.trim() !== '' ? datos.email.trim().toLowerCase() : null
if (duplicado) return { duplicado: { id: duplicado.leadId, nombre: duplicado.nombreLead } } // 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() const id = idLocal()
@@ -193,7 +196,8 @@ export class CrmLocalService {
usuarioId: usuarioId ?? 'desconocido', usuarioId: usuarioId ?? 'desconocido',
nombreLead: datos.nombre, nombreLead: datos.nombre,
telefono: datos.telefono ?? null, telefono: datos.telefono ?? null,
email: datos.email ?? null, telefonoNorm: telNorm,
email: emailNorm,
origen: datos.origen ?? null, origen: datos.origen ?? null,
desarrollo: datos.desarrollo ?? null, desarrollo: datos.desarrollo ?? null,
modelo: modelo ?? null, modelo: modelo ?? null,
@@ -208,6 +212,7 @@ export class CrmLocalService {
prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }), prisma.leadOportunidad.create({ data: { leadId: id, oportunidadId: id } }),
]) ])
return { id, oportunidadId: id } return { id, oportunidadId: id }
})
} }
/** /**
@@ -244,9 +249,10 @@ export class CrmLocalService {
opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {}, opciones: { fecha?: string | undefined; usuarioId?: string | undefined; sombra?: boolean | undefined } = {},
): Promise<void> { ): Promise<void> {
await this.filaLocal(itemId, board, opciones.usuarioId, opciones.sombra ?? false) 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 } 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 } }) const existente = await prisma.leadCaptura.findUnique({ where: { leadId: itemId }, select: { datosJson: true } })
let datos: Record<string, unknown> = {} let datos: Record<string, unknown> = {}
if (existente?.datosJson) { if (existente?.datosJson) {
@@ -317,6 +323,7 @@ export class CrmLocalService {
usuarioId: lead.usuarioId, usuarioId: lead.usuarioId,
nombreLead: lead.nombreLead, nombreLead: lead.nombreLead,
telefono: lead.telefono, telefono: lead.telefono,
telefonoNorm: lead.telefonoNorm,
email: lead.email, email: lead.email,
origen: lead.origen, origen: lead.origen,
desarrollo: lead.desarrollo, desarrollo: lead.desarrollo,
@@ -362,6 +369,11 @@ export class CrmLocalService {
!esIdLocal(oportunidadIdLocal), !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 clave = claveApartadoCreado(oportunidadIdLocal)
const previo = await prisma.automatizacionState.findUnique({ where: { clave } }) const previo = await prisma.automatizacionState.findUnique({ where: { clave } })
if (previo !== null) { if (previo !== null) {
@@ -382,6 +394,7 @@ export class CrmLocalService {
usuarioId: oportunidad.usuarioId, usuarioId: oportunidad.usuarioId,
nombreLead: oportunidad.nombreLead, nombreLead: oportunidad.nombreLead,
telefono: oportunidad.telefono, telefono: oportunidad.telefono,
telefonoNorm: oportunidad.telefonoNorm,
email: oportunidad.email, email: oportunidad.email,
origen: oportunidad.origen, origen: oportunidad.origen,
desarrollo: oportunidad.desarrollo, desarrollo: oportunidad.desarrollo,
@@ -413,20 +426,81 @@ export class CrmLocalService {
/** /**
* Búsqueda local para el picker del alta de clientes (y el buscador del * Búsqueda local para el picker del alta de clientes (y el buscador del
* cotizador): LeadCaptura por nombre/teléfono + Cliente por nombre/celular. * 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({ const capturas = await prisma.leadCaptura.findMany({
where: { OR: [{ nombreLead: { contains: termino } }, { telefono: { contains: termino } }] }, where: { AND: [propioLead, { OR: [...condNombreLead, ...condTelefonoLead] }] },
orderBy: { updatedAt: 'desc' }, orderBy: { updatedAt: 'desc' },
take: 15, take: 50,
}) })
const clientes = await prisma.cliente.findMany({ 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' }, 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 = [ const candidatos = [
...capturas.map((c) => ({ ...capturas
.filter((c) => !pareceTelefono || coincideTelefono(c.telefono) || c.nombreLead.includes(termino))
.map((c) => ({
id: c.leadId, id: c.leadId,
nombre: c.nombreLead, nombre: c.nombreLead,
telefono: c.telefono ?? '', telefono: c.telefono ?? '',
@@ -438,7 +512,9 @@ export class CrmLocalService {
modeloInteres: c.modelo ?? '', modeloInteres: c.modelo ?? '',
orden: c.updatedAt, orden: c.updatedAt,
})), })),
...clientes.map((c) => ({ ...clientes
.filter((c) => !pareceTelefono || coincideTelefono(c.celular) || c.nombreCompleto.includes(termino))
.map((c) => ({
id: c.id, id: c.id,
nombre: c.nombreCompleto, nombre: c.nombreCompleto,
telefono: c.celular ?? '', telefono: c.celular ?? '',
@@ -456,6 +532,62 @@ export class CrmLocalService {
.slice(0, 15) .slice(0, 15)
.map(({ orden: _orden, ...resto }) => resto) .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) */ /** Singleton usado por los controllers (los tests instancian el suyo o mockean prisma) */
@@ -481,6 +613,38 @@ function soloDigitos(telefono: string): string {
return telefono.replace(/\D/g, '') return telefono.replace(/\D/g, '')
} }
/**
* 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.
*/
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 * `datosJson` con la unidad apartada dentro (mezcla, no reemplaza: conserva
* fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo * fechaCita, fechaLimite y el perfilamiento de la captura). Sin unidadCodigo
@@ -501,12 +665,25 @@ function conUnidadCodigo(datosJson: string | null, unidadCodigo?: string): strin
} }
/** /**
* Busca un contacto por teléfono (últimos 10 dígitos) en CUALQUIER tablero. * Etapas que cierran el ciclo de un registro: descartados, perdidos y
* Así el mismo número no se duplica entre Leads, Oportunidades y Apartados. * 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.
*/ */
async function buscarPorTelefono(diezDigitos: string) { 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({ return prisma.leadCaptura.findFirst({
where: { telefono: { contains: diezDigitos } }, where: { telefonoNorm: telefonoNormalizado, etapa: { notIn: ETAPAS_CERRADAS } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
} }

View File

@@ -168,6 +168,24 @@ export const crearUnidadesSchema = z
export type CrearUnidadesBody = z.infer<typeof crearUnidadesSchema> 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 ─────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────
interface ModeloFila { interface ModeloFila {
@@ -635,6 +653,39 @@ export async function eliminarUnidad(desarrolloId: string, codigo: string): Prom
await prisma.unidad.delete({ where: { codigo } }) 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) ──────────────────────────────────── // ── Público (micrositio, sin auth) ────────────────────────────────────
/** /**

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,10 @@ const prismaMock = vi.hoisted(() => ({
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
}, },
actividadLocal: {
create: vi.fn(),
updateMany: vi.fn(),
},
})) }))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock })) vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
@@ -55,6 +59,17 @@ describe('RecordatorioVendedorService', () => {
fechaRecordar: new Date('2026-09-10T10:00:00'), 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', () => { describe('completar', () => {
it('marca como completado un recordatorio existente', async () => { it('marca como completado un recordatorio existente y cierra su tarea del calendario', async () => {
prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1' }) prismaMock.recordatorioVendedor.findUnique.mockResolvedValue({ id: 'r1', leadId: 'loc_1', usuarioId })
prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true }) prismaMock.recordatorioVendedor.update.mockResolvedValue({ id: 'r1', completado: true })
const result = await svc.completar('r1') const result = await svc.completar('r1')
expect(result.completado).toBe(true) 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({ expect(prismaMock.recordatorioVendedor.update).toHaveBeenCalledWith({
where: { id: 'r1' }, where: { id: 'r1' },
data: { completado: true }, data: { completado: true },

View File

@@ -31,7 +31,7 @@ export interface OpcionesListarRecordatorio {
export class RecordatorioVendedorService { export class RecordatorioVendedorService {
async crear(usuarioId: string, data: DatosCrearRecordatorio) { async crear(usuarioId: string, data: DatosCrearRecordatorio) {
return prisma.recordatorioVendedor.create({ const recordatorio = await prisma.recordatorioVendedor.create({
data: { data: {
usuarioId, usuarioId,
titulo: data.titulo.trim(), titulo: data.titulo.trim(),
@@ -41,6 +41,20 @@ export class RecordatorioVendedorService {
fechaRecordar: new Date(data.fechaRecordar), 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 = {}) { 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) { async completar(id: string) {
const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } }) const existente = await prisma.recordatorioVendedor.findUnique({ where: { id } })
if (!existente) throw new NotFoundError('Recordatorio no encontrado') 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({ return prisma.recordatorioVendedor.update({
where: { id }, where: { id },
data: { completado: true }, data: { completado: true },

View File

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

View File

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

View File

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

View File

@@ -122,13 +122,19 @@ export function construirMiTablero(
// -- Conversión (funnel) -- // -- Conversión (funnel) --
const leads = capturas.filter((c) => c.board === 'LEADS').length const leads = capturas.filter((c) => c.board === 'LEADS').length
const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length const oportunidades = capturas.filter((c) => c.board === 'OPORTUNIDADES').length
// Apartados ACTIVOS del funnel: ni cancelados (Cancelo/Cancelacion Apartado)
// ni ya vendidos (Firma Cliente/Escriturado/Titulado pasan a "vendidos")
const apartados = capturas.filter( const apartados = capturas.filter(
(c) => c.board === 'APARTADO' && c.etapa.toUpperCase() !== 'CANCELADO', (c) =>
c.board === 'APARTADO' &&
!['FIRMA CLIENTE', 'CANCELO', 'CANCELACION APARTADO', 'ESCRITURADO', 'TITULADO'].includes(
c.etapa.toUpperCase(),
),
).length ).length
const vendidos = capturas.filter( const vendidos = capturas.filter(
(c) => (c) =>
c.board === 'APARTADO' && c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()), ['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()),
).length ).length
const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length const descartados = capturas.filter((c) => c.etapa.toUpperCase() === 'DESCARTADO').length
@@ -184,7 +190,7 @@ export function construirMiTablero(
for (const c of capturas) { for (const c of capturas) {
if ( if (
c.board === 'APARTADO' && c.board === 'APARTADO' &&
['FIRMA CLIENTE (VENTA)', 'ESCRITURADO', 'FORMALIZADO'].includes(c.etapa.toUpperCase()) && ['FIRMA CLIENTE', 'ESCRITURADO', 'TITULADO'].includes(c.etapa.toUpperCase()) &&
c.desarrollo c.desarrollo
) { ) {
const dev = c.desarrollo.trim() const dev = c.desarrollo.trim()

View File

@@ -89,12 +89,13 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3']) expect(senda.map((u) => u.codigo)).toEqual(['U-2', 'U-3'])
}) })
it('completa fechaEntrega desde la captura: entrega programada > habitabilidad > inicio de obra', async () => { it('completa fechaEntrega desde la captura: reprogramada > programada > habitabilidad > inicio de obra', async () => {
prismaMock.unidad.findMany.mockResolvedValue([ prismaMock.unidad.findMany.mockResolvedValue([
fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }), fila({ codigo: 'U-1', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }), fila({ codigo: 'U-2', desarrollo: 'DIAGONAL' }),
fila({ codigo: 'S-1', desarrollo: 'SENDA' }), fila({ codigo: 'S-1', desarrollo: 'SENDA' }),
fila({ codigo: 'S-2', desarrollo: 'SENDA' }), fila({ codigo: 'S-2', desarrollo: 'SENDA' }),
fila({ codigo: 'S-3', desarrollo: 'SENDA' }),
]) ])
prismaMock.capturaDepartamento.findMany.mockResolvedValue([ prismaMock.capturaDepartamento.findMany.mockResolvedValue([
{ unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' }, { unidadCodigo: 'U-1', campo: 'inicio_obra_fecha', valor: '2026-01-01' },
@@ -104,11 +105,16 @@ describe('getUnidadesLocal / getUnidadLocal', () => {
// Valor vacío / no-fecha: no cuenta como dato // Valor vacío / no-fecha: no cuenta como dato
{ unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' }, { unidadCodigo: 'S-1', campo: 'entrega_programada_fecha', valor: '' },
{ unidadCodigo: 'S-2', campo: 'entrega_programada_fecha', valor: 'pendiente' }, { 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() const todas = await getUnidadesLocal()
expect(todas.map((u) => u.fechaEntrega)).toEqual(['2027-11-20', null, '2027-05-05', null]) expect(todas.map((u) => u.fechaEntrega)).toEqual(['2028-02-15', null, '2027-05-05', null, '2027-07-01'])
expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith( expect(prismaMock.capturaDepartamento.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }), expect.objectContaining({ where: { campo: { in: [...CAMPOS_FECHA_ENTREGA] } } }),
) )

View File

@@ -14,12 +14,15 @@ import type { UnidadCotizable } from './cotizacion.service.js'
/** /**
* Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda, * Campos de `CapturaDepartamento` que dan la fecha de entrega de la vivienda,
* en orden de prioridad: * en orden de prioridad (el primero con fecha válida gana):
* 1. `entrega_programada_fecha` — Fecha Programada Entrega Vivienda (ESCRITURACION) * 1. `entrega_reprogramada_fecha` — Fecha Reprogramada Entrega (la más reciente,
* 2. `habitabilidad` — fecha de habitabilidad (OBRA), la de DIAGONAL * cuando obra movió la entrega es la fecha que manda)
* 3. `inicio_obra_fecha` — último recurso (ESCRITURACION) * 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 = [ export const CAMPOS_FECHA_ENTREGA = [
'entrega_reprogramada_fecha',
'entrega_programada_fecha', 'entrega_programada_fecha',
'habitabilidad', 'habitabilidad',
'inicio_obra_fecha', 'inicio_obra_fecha',

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

View File

@@ -155,7 +155,12 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
nombrePila, nombrePila,
apellidoPaterno, apellidoPaterno,
apellidoMaterno, 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, email: lead.email,
origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen, origen: (ORIGENES_LEAD as readonly string[]).includes(lead.origen) ? lead.origen : prev.origen,
desarrollo, desarrollo,
@@ -173,6 +178,9 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
const nombreCompuesto = nombreCompleto() const nombreCompuesto = nombreCompleto()
if (nombreCompuesto === '') nuevos.nombrePila = 'El nombre es obligatorio' if (nombreCompuesto === '') nuevos.nombrePila = 'El nombre es obligatorio'
if (!form.telefono.trim()) nuevos.telefono = 'El teléfono 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) { if (form.email.trim() && !emailSchema.safeParse(form.email).success) {
nuevos.email = 'Correo electrónico inválido' nuevos.email = 'Correo electrónico inválido'
} }
@@ -423,12 +431,19 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
<input <input
id="afluencia-telefono" id="afluencia-telefono"
type="tel" type="tel"
placeholder="664 123 4567" inputMode="numeric"
maxLength={10}
placeholder="6641234567"
value={form.telefono} 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' : ''}`} className={`${inputClase} ${errores.telefono ? 'border-red-400' : ''}`}
/> />
</div> </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>} {errores.telefono && <p className="mt-1 text-xs text-red-700">{errores.telefono}</p>}
</div> </div>
@@ -582,19 +597,14 @@ export default function AltaAfluenciaForm({ onVolver, onPasarAAltaCliente, valor
className={inputClase} className={inputClase}
> >
<option value="">— Selecciona —</option> <option value="">— Selecciona —</option>
<option value="A-0-10000.00">A-0-10000.00</option> <option value="0-20,000">A. 0 - 20,000</option>
<option value="B-10000.01-15000.00">B-10000.01-15000.00</option> <option value="20,000-40,000">B. 20,000 - 40,000</option>
<option value="C-15000.01-20000.00">C-15000.01-20000.00</option> <option value="40,001-50,000">C. 40,001 - 50,000</option>
<option value="D-17000.01-22000.00">D-17000.01-22000.00</option> <option value="60,001-80,000">D. 60,001 - 80,000</option>
<option value="E-20001.00-25000.00">E-20001.00-25000.00</option> <option value="80,001-100,000">E. 80,001 - 100,000</option>
<option value="F-25001.00-30000.00">F-25001.00-30000.00</option> <option value="120,001-140,000">F. 120,001 - 140,000</option>
<option value="G-30001.00-35000.00">G-30001.00-35000.00</option> <option value="140,001-180,000">G. 140,001 - 180,000</option>
<option value="H-35001.00-40000.00">H-35001.00-40000.00</option> <option value="200,000-500,000">H. 200,000 - 500,000</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>
</select> </select>
</label> </label>

View File

@@ -117,10 +117,17 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
const { watch, setValue, formState: { errors } } = useFormContext() const { watch, setValue, formState: { errors } } = useFormContext()
const valor = ((watch(nombre) as string) ?? '').trim() const valor = ((watch(nombre) as string) ?? '').trim()
const lada = valor.startsWith('+1') ? '+1' : '+52' 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 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 // Vacío se guarda vacío (sin prefijo huérfano); con número, compuesto
setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true }) setValue(nombre, limpio === '' ? '' : `${nuevaLada} ${limpio}`, { shouldValidate: true })
} }
@@ -144,12 +151,15 @@ export function CampoTelefono({ nombre, etiqueta, requerido, className }: CampoB
<input <input
id={nombre} id={nombre}
type="tel" type="tel"
placeholder="664 123 4567" inputMode="numeric"
maxLength={10}
placeholder="6641234567"
value={numero} value={numero}
onChange={(e) => actualizar(lada, e.target.value)} onChange={(e) => actualizar(lada, e.target.value)}
className={claseInput(nombre, errors)} className={claseInput(nombre, errors)}
/> />
</div> </div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">10 dígitos, sin lada</p>
<MensajeError nombre={nombre} /> <MensajeError nombre={nombre} />
</div> </div>
) )

View File

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

View File

@@ -62,8 +62,16 @@ export const datosGeneralesSchema = z
peso: textoOpcional, peso: textoOpcional,
estatura: textoOpcional, estatura: textoOpcional,
email: z.string().trim().email('Correo electrónico inválido'), email: z.string().trim().email('Correo electrónico inválido'),
telCasa: textoOpcional, // Teléfonos: 10 dígitos (con o sin lada compuesta; el campo sólo acepta
celular: z.string().trim().min(8, 'El celular es requerido'), // 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, viviendaAnios: textoOpcional,
tipoVivienda: z.enum(['PRESTADA', 'RENTADA', 'FAMILIAR', 'PROPIA', '']), tipoVivienda: z.enum(['PRESTADA', 'RENTADA', 'FAMILIAR', 'PROPIA', '']),
tipoCompra: z.enum(['INVERSION', 'VIVIR_AHI', '']), tipoCompra: z.enum(['INVERSION', 'VIVIR_AHI', '']),

View File

@@ -19,6 +19,7 @@ import {
eliminarBorrador, eliminarBorrador,
generarLigaRemota, generarLigaRemota,
guardarBorrador, guardarBorrador,
importarInformacionEnkontrol,
iniciarProgreso, iniciarProgreso,
listarBorradores, listarBorradores,
obtenerBorrador, obtenerBorrador,
@@ -27,7 +28,9 @@ import {
subirDocumentos, subirDocumentos,
type BorradorResumen, type BorradorResumen,
type LeadResumen, type LeadResumen,
type ResultadoImportacionEnkontrol,
} from '../services/api' } from '../services/api'
import { getSesion } from '../services/auth'
import { cotizacionAPrellenado } from '../lib/cotizacionAlta' import { cotizacionAPrellenado } from '../lib/cotizacionAlta'
import { apartadoAPrellenado } from '../lib/apartadoAlta' import { apartadoAPrellenado } from '../lib/apartadoAlta'
import { leadAPrellenado } from '../lib/leadAlta' import { leadAPrellenado } from '../lib/leadAlta'
@@ -137,6 +140,29 @@ export default function AltaClientePage() {
const [ligaCopiada, setLigaCopiada] = useState(false) const [ligaCopiada, setLigaCopiada] = useState(false)
const [errorLiga, setErrorLiga] = useState('') 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 // Afluencia prellenada desde Mis oportunidades (Mi día): actualiza la
// oportunidad existente en vez de crear una nueva // oportunidad existente en vez de crear una nueva
const [afluenciaPrevia, setAfluenciaPrevia] = useState<{ const [afluenciaPrevia, setAfluenciaPrevia] = useState<{
@@ -337,8 +363,8 @@ export default function AltaClientePage() {
const res = datos.progresoId const res = datos.progresoId
? await completarProgreso(datos.progresoId, alta) ? await completarProgreso(datos.progresoId, alta)
: await crearAltaCliente(alta) : await crearAltaCliente(alta)
toast.success("Alta de cliente completada")
if (res.ok) { if (res.ok) {
toast.success('Alta de cliente completada')
const { fallidos } = await subirDocumentos(res.id, alta) const { fallidos } = await subirDocumentos(res.id, alta)
setRegistro(res.id) setRegistro(res.id)
setSinConexion(false) setSinConexion(false)
@@ -610,6 +636,51 @@ export default function AltaClientePage() {
</button> </button>
</div> </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"> <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 Con la liga de firma, el cliente firma su expediente y sube documentos desde su
celular (vigencia 72 h, sin usuario ni contraseña). 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 * Registra el alta en el API Gateway (POST /clientes). El backend persiste en
* SQLite. * SQLite.
*/ */export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
export async function crearAltaCliente(payload: AltaCliente): Promise<ResultadoAlta> {
return enviarAlta('/clientes', payload) return enviarAlta('/clientes', payload)
} }
@@ -414,3 +413,30 @@ export async function subirDocumentoRemoto(token: string, tipo: string, archivo:
}) })
manejarErrorLiga(res) 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

@@ -92,7 +92,8 @@ export function crearDocumento(
doc.setFontSize(9) doc.setFontSize(9)
doc.text('_______________________________', 105, y, { align: 'center' }) doc.text('_______________________________', 105, y, { align: 'center' })
y += 5 y += 5
doc.text(`Recibió — ${plan.clienteNombre}`, 105, y, { align: 'center' }) // Quien recibe el pago es FRASA (el cliente es quien paga)
doc.text('Recibió — FRASA DESARROLLOS S.A.P.I. DE C.V.', 105, y, { align: 'center' })
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` } return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
} }

View File

@@ -1,20 +1,22 @@
import toast from "react-hot-toast" import toast from "react-hot-toast"
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { crearPlanAsync } from '../services/repository' import { crearPlanAsync, listarProyectosAsync } from '../services/repository'
import { generarCalendario } from '../lib/cobranza' import { generarCalendario } from '../lib/cobranza'
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters' import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL'] as const // Proyecto libre (con sugerencias del datalist): la cartera real incluye
// desarrollos que un enum hardcodeado no cubría (ZAFIRO, UMBRAL, SOLE,
// DIAGONAL 40, SOLANA I/II...), así que ya no se limita a una lista fija.
const esquema = z.object({ const esquema = z.object({
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'), clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
clienteTelefono: z.string().trim().optional(), clienteTelefono: z.string().trim().optional(),
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'), unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
proyecto: z.enum(PROYECTOS), proyecto: z.string().trim().min(1, 'Proyecto requerido'),
ventaRef: z.string().trim(), ventaRef: z.string().trim(),
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'), fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
engancheTotal: z engancheTotal: z
@@ -46,6 +48,11 @@ export default function NuevoPlanPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [guardando, setGuardando] = useState(false) const [guardando, setGuardando] = useState(false)
const [errorGuardado, setErrorGuardado] = useState<string | null>(null) const [errorGuardado, setErrorGuardado] = useState<string | null>(null)
// Proyectos existentes en la cartera: alimentan el datalist de sugerencias
const [proyectosSugeridos, setProyectosSugeridos] = useState<string[]>([])
useEffect(() => {
void listarProyectosAsync().then(setProyectosSugeridos)
}, [])
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -56,7 +63,7 @@ export default function NuevoPlanPage() {
defaultValues: { defaultValues: {
clienteNombre: '', clienteNombre: '',
unidadCodigo: '', unidadCodigo: '',
proyecto: 'SENDA', proyecto: '',
ventaRef: '', ventaRef: '',
fechaInicio: fechaISOHoy(), fechaInicio: fechaISOHoy(),
numPagos: 6, numPagos: 6,
@@ -122,13 +129,19 @@ export default function NuevoPlanPage() {
</div> </div>
<div> <div>
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label> <label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
<select id="proyecto" className={claseInput} {...register('proyecto')}> <input
{PROYECTOS.map((p) => ( id="proyecto"
<option key={p} value={p}> list="proyectos-cobranza"
{p} className={claseInput}
</option> placeholder="Ej. SENDA, ZAFIRO, DIAGONAL 40…"
{...register('proyecto')}
/>
<datalist id="proyectos-cobranza">
{proyectosSugeridos.map((p) => (
<option key={p} value={p} />
))} ))}
</select> </datalist>
{errors.proyecto && <p className={claseError}>{errors.proyecto.message}</p>}
</div> </div>
<div> <div>
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label> <label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>

View File

@@ -365,3 +365,13 @@ export async function actualizarPlanAsync(
export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean { export function sinPlanesAsignados(resultado: ResultadoPlanes, mios: boolean): boolean {
return mios && resultado.origen === 'api' && resultado.planes.length === 0 return mios && resultado.origen === 'api' && resultado.planes.length === 0
} }
/** GET /planes — proyectos distintos de la cartera (datalist del alta de plan) */
export async function listarProyectosAsync(): Promise<string[]> {
try {
const planes = await request<PlanApi[]>('/planes')
return [...new Set(planes.map((p) => p.proyecto).filter((p) => typeof p === 'string' && p.trim() !== ''))].sort()
} catch {
return []
}
}

View File

@@ -1,158 +0,0 @@
import { useState } from 'react'
import type { PropuestaResultado } from '../lib/calculations'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
precioLista: number
apartado: number
propuestas: PropuestaResultado[]
titulos: string[]
}
interface Fila {
etiqueta: string
obtener: (p: PropuestaResultado, i: number) => string
destacada?: boolean
}
/** Sección colapsable con la tabla comparativa de las 4 propuestas */
export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulos }: Props) {
const [abierto, setAbierto] = useState(false)
const totales = propuestas.map((p) => totalPagado(p, apartado))
// null (plazo desconocido) nunca gana el menor total
const ganador = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
const filas: Fila[] = [
{ etiqueta: 'Precio de lista', obtener: () => formatoMoneda(precioLista) },
{
etiqueta: 'Descuento',
obtener: (p) => `${formatoMoneda(p.montoDescuento)} (${formatoPorcentaje(p.descuento)})`,
},
{ etiqueta: 'Precio final c/desc.', obtener: (p) => formatoMoneda(p.precioFinal) },
{ etiqueta: 'Enganche', obtener: (p) => formatoMoneda(p.enganche) },
{
etiqueta: 'Mensualidad',
obtener: (p) =>
p.notaMensualidades
? `— (${p.notaMensualidades})`
: p.mensualidad === null
? 'N/A'
: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
},
{
etiqueta: 'Total mensualidades',
obtener: (p) =>
p.notaMensualidades
? '—'
: p.mensualidadesTotal === null
? 'N/A'
: formatoMoneda(p.mensualidadesTotal),
},
{ etiqueta: 'Pago final', obtener: (p) => formatoMoneda(p.pagoFinal) },
{
etiqueta: 'Total pagado',
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i])),
destacada: true,
},
{
etiqueta: 'Diferencia vs precio de lista',
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i] - precioLista)),
},
]
/** Borde acento que enmarca la columna con el menor total pagado */
const bordeColumna = (i: number, posicion: 'inicio' | 'media' | 'fin'): string => {
if (i !== ganador) return ''
const laterales = 'border-x-2 border-x-accent'
if (posicion === 'inicio') return `${laterales} border-t-2 border-t-accent`
if (posicion === 'fin') return `${laterales} border-b-2 border-b-accent`
return laterales
}
return (
<section aria-labelledby="comparador-titulo">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Comparar propuestas
</h2>
<button
type="button"
aria-expanded={abierto}
aria-controls="comparador-contenido"
onClick={() => setAbierto((v) => !v)}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{abierto ? 'Ocultar comparación' : 'Comparar propuestas'}
</button>
</div>
{abierto && (
<div id="comparador-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
<th scope="col" className="w-48 p-3 text-left align-bottom">
<span className="sr-only">Concepto</span>
</th>
{propuestas.map((p, i) => (
<th
key={p.tipo}
scope="col"
className={`p-3 text-right align-bottom ${bordeColumna(i, 'inicio')}`}
>
<span className="block text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
{titulos[i] ?? p.tipo}
</span>
{i === ganador && (
<span className="mt-2 inline-block rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
Menor total
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{filas.map((fila, indiceFila) => (
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<th
scope="row"
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
>
{fila.etiqueta}
</th>
{propuestas.map((p, i) => (
<td
key={p.tipo}
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
fila.destacada
? `font-semibold ${i === ganador ? 'text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'}`
: 'text-[#111827] dark:text-[#E2E8F0]'
} ${bordeColumna(
i,
indiceFila === filas.length - 1 ? 'fin' : 'media',
)}`}
>
{fila.obtener(p, i)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
El enganche ya incluye la deducción del apartado. El total pagado suma apartado +
enganche + mensualidades + pago final.
</p>
</div>
)}
</section>
)
}

View File

@@ -1,203 +0,0 @@
import { useMemo, useState } from 'react'
import type { Unidad, TipoPropuesta } from '../types'
import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades'
import { formatoMoneda } from '../lib/formatters'
import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas'
interface Props {
unidades: Unidad[]
/** Tipo de propuesta activa en el cotizador (se aplica a ambas unidades) */
tipo: TipoPropuesta
apartado: number
acabados: boolean
/** Reglas comerciales por desarrollo (vacío => constantes) */
reglas?: ReglasDesarrollo
}
const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`)
interface Fila {
etiqueta: string
obtener: (c: ColumnaComparador) => string
destacada?: boolean
}
/**
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
* con la propuesta activa calculada por el motor con los parámetros de cada
* desarrollo (las reglas del admin con respaldo en las constantes). Solo ofrece
* unidades DISPONIBLES de desarrollos activos en el cotizador: no se compara
* (ni se cotiza) inventario apartado, vendido, bloqueado o desactivado.
*/
export function ComparadorUnidades({ unidades, tipo, apartado, acabados, reglas = SIN_REGLAS }: Props) {
const [abierto, setAbierto] = useState(false)
const [codigoA, setCodigoA] = useState('')
const [codigoB, setCodigoB] = useState('')
const desarrollos = useMemo(() => idsCotizables(reglas), [reglas])
const disponibles = useMemo(
() =>
unidades.filter(
(u) =>
(u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' &&
desarrollos.includes(u.desarrollo ?? 'DIAGONAL'),
),
[unidades, desarrollos],
)
const porCodigo = useMemo(() => {
const m = new Map<string, Unidad>()
for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
return m
}, [disponibles])
const unidadA = porCodigo.get(codigoA) ?? null
const unidadB = porCodigo.get(codigoB) ?? null
const comparacion = useMemo(
() =>
unidadA && unidadB
? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas)
: null,
[unidadA, unidadB, tipo, apartado, acabados, reglas],
)
const filas: Fila[] = [
{ etiqueta: 'Desarrollo', obtener: (c) => c.desarrolloNombre },
{ etiqueta: 'Modelo', obtener: (c) => c.unidad.modelo },
{ etiqueta: 'Estatus', obtener: (c) => c.unidad.status },
{ etiqueta: 'Sup. construcción', obtener: (c) => formatoM2(c.supConstruccion) },
{ etiqueta: 'Precio de lista', obtener: (c) => formatoMoneda(c.precioLista) },
{ etiqueta: 'Precio final c/desc.', obtener: (c) => formatoMoneda(c.propuesta.precioFinal) },
{ etiqueta: 'Enganche', obtener: (c) => formatoMoneda(c.propuesta.enganche) },
{
etiqueta: 'Mensualidad',
obtener: (c) =>
c.propuesta.notaMensualidades
? `— (${c.propuesta.notaMensualidades})`
: c.propuesta.mensualidad === null
? 'N/A'
: `${c.propuesta.numMensualidades} × ${formatoMoneda(c.propuesta.mensualidad)}`,
},
{ etiqueta: 'Pago final', obtener: (c) => formatoMoneda(c.propuesta.pagoFinal) },
{
etiqueta: 'Total pagado',
obtener: (c) => (c.total === null ? '—' : formatoMoneda(c.total)),
destacada: true,
},
]
const selector = (id: string, valor: string, onChange: (v: string) => void, etiqueta: string) => (
<div>
<label htmlFor={id} className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
{etiqueta}
</label>
<select
id={id}
value={valor}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
>
<option value="">Selecciona una unidad…</option>
{desarrollos.map((dev) => {
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
if (delDesarrollo.length === 0) return null
return (
<optgroup key={dev} label={desarrolloEfectivo(reglas, dev).nombre}>
{delDesarrollo.map((u) => (
<option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}>
{u.codigo} — {u.modelo}
</option>
))}
</optgroup>
)
})}
</select>
</div>
)
return (
<section aria-labelledby="comparador-unidades-titulo">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-unidades-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Comparar unidades
</h2>
<button
type="button"
aria-expanded={abierto}
aria-controls="comparador-unidades-contenido"
onClick={() => setAbierto((v) => !v)}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{abierto ? 'Ocultar comparación' : 'Comparar 2 unidades'}
</button>
</div>
{abierto && (
<div id="comparador-unidades-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="grid gap-6 md:grid-cols-2">
{selector('comparador-a', codigoA, setCodigoA, 'Unidad A')}
{selector('comparador-b', codigoB, setCodigoB, 'Unidad B')}
</div>
{!comparacion ? (
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">
Selecciona dos unidades para ver la comparación lado a lado.
</p>
) : (
<div className="mt-6 overflow-x-auto">
<table className="w-full min-w-[480px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
<th scope="col" className="w-48 p-3 text-left">
<span className="sr-only">Concepto</span>
</th>
{[comparacion.a, comparacion.b].map((c) => (
<th
key={`${c.desarrolloId}|${c.unidad.codigo}`}
scope="col"
className="p-3 text-right text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]"
>
{c.unidad.codigo}
</th>
))}
</tr>
</thead>
<tbody>
{filas.map((fila) => (
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<th
scope="row"
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
>
{fila.etiqueta}
</th>
{[comparacion.a, comparacion.b].map((c) => (
<td
key={`${c.desarrolloId}|${c.unidad.codigo}`}
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
fila.destacada ? 'font-semibold text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'
}`}
>
{fila.obtener(c)}
</td>
))}
</tr>
))}
</tbody>
</table>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
</p>
</div>
)}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,217 @@
import {
MENSUALIDADES_MAX,
MENSUALIDADES_MIN,
PCT_ENGANCHE,
PCT_ENGANCHE_FIRMA,
PCT_ENGANCHE_MAXIMO,
PCT_ESCRITURACION,
type EsquemaFrasaResultado,
} from '../lib/esquema-frasa'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
/** Esquema FRASA 2026 calculado (siempre presente: solo se renderiza con unidad) */
esquema: EsquemaFrasaResultado
/** Fecha de entrega estimada de la unidad (captura de obra); alimenta la nota del diferimiento */
fechaEntrega?: string | null
/** Tope de mensualidades: los meses que faltan para la entrega (default 36 sin fecha) */
maxMensualidades?: number
/** Enganche base del esquema (7.5% del precio, neto del excedente del apartado) */
engancheBase: number
/** Enganche total capturado (base + extra); el input vive en el renglón del enganche */
engancheTotal: number
onEngancheTotal: (v: number) => void
/** Máximo de enganche total permitido por el tope del 90% de anticipo */
engancheMax: number
numMensualidades: number
onNumMensualidades: (n: number) => void
escrituracionDiferida: boolean
onEscrituracionDiferida: (v: boolean) => void
}
const pasoBase =
'flex-1 rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#0F172A]'
const pasoTitulo = 'text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]'
const pasoMonto = 'mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]'
/**
* Panel único del esquema FRASA 2026 (reemplaza a las 4 propuestas):
* línea de tiempo Hoy → A la firma → Mensualidades, el detalle de conceptos
* (enganche de firma 2.5%, enganche 7.5%, escrituración 5.5%) y los controles
* del diferimiento: número de mensualidades (1..tope, donde el tope son los
* meses que faltan para la entrega estimada de la unidad) y si la escrituración
* se difiere con el enganche o se paga a la firma.
*/
export function EsquemaPanel({
esquema,
fechaEntrega,
maxMensualidades = MENSUALIDADES_MAX,
engancheBase,
engancheTotal,
onEngancheTotal,
engancheMax,
numMensualidades,
onNumMensualidades,
escrituracionDiferida,
onEscrituracionDiferida,
}: Props) {
// El diferimiento no puede rebasar la entrega de la vivienda: se ofrecen
// sólo las mensualidades dentro del tope (con fecha, meses que faltan)
const tope = Math.max(MENSUALIDADES_MIN, Math.min(MENSUALIDADES_MAX, maxMensualidades))
const mensualidadEfectiva = Math.min(numMensualidades, tope)
return (
<div className="space-y-6">
{/* Línea de tiempo del esquema */}
<div className="flex flex-col gap-4 sm:flex-row">
<div className={pasoBase}>
<p className={pasoTitulo}>Hoy</p>
<p className={pasoMonto}>{formatoMoneda(esquema.apartado)}</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">Apartado (aparta la unidad)</p>
</div>
<div className={pasoBase}>
<p className={pasoTitulo}>A la firma</p>
<p className={pasoMonto}>{formatoMoneda(esquema.pagoUnicoFirma)}</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Saldo de enganche de firma
{!escrituracionDiferida && ' + escrituración'}
</p>
</div>
<div className={pasoBase}>
<p className={pasoTitulo}>Después</p>
<p className={pasoMonto}>
{esquema.numMensualidades} × {formatoMoneda(esquema.mensualidad)}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Total diferible {formatoMoneda(esquema.baseDiferible)}
</p>
</div>
</div>
{/* Detalle de conceptos */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
Esquema FRASA 2026 — conceptos
</h2>
<dl className="divide-y divide-[#E5E7EB] dark:divide-[#334155]">
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Enganche de firma ({formatoPorcentaje(PCT_ENGANCHE_FIRMA)})
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.engancheFirmaTotal)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
− apartado acreditado → saldo a la firma {formatoMoneda(esquema.saldoFirma)}
</span>
</dd>
</div>
{esquema.excedenteApartado > 0 && (
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Apartado que excede el 2.5%
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.excedenteApartado)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
acreditado al enganche
</span>
</dd>
</div>
)}
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Enganche diferible
<span className="mt-0.5 block text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
Mín. {formatoPorcentaje(PCT_ENGANCHE)} del precio; puedes subirlo hasta el tope del{' '}
{formatoPorcentaje(PCT_ENGANCHE_MAXIMO)} de anticipo total
</span>
</dt>
<dd className="flex items-center gap-1.5">
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">$</span>
<input
id="enganche-diferible"
type="number"
min={engancheBase}
max={engancheMax}
step={1000}
value={Math.round(engancheTotal)}
onChange={(e) => onEngancheTotal(e.target.value === '' ? engancheBase : Number(e.target.value))}
className="w-36 rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-right text-sm font-medium text-[#111827] transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
/>
</dd>
</div>
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Gastos de escrituración ({formatoPorcentaje(PCT_ESCRITURACION)})
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.escrituracion)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{escrituracionDiferida ? 'diferidos con el enganche' : 'pagados a la firma'}
</span>
</dd>
</div>
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Total del esquema
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{formatoPorcentaje(esquema.totalPagado / esquema.precio)} del precio
</span>
</dt>
<dd className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.totalPagado)}
</dd>
</div>
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Saldo a financiar
</dt>
<dd className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.saldoAFinanciar)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{formatoPorcentaje(esquema.saldoAFinanciar / esquema.precio)} del precio
</span>
</dd>
</div>
</dl>
</section>
{/* Controles del diferimiento */}
<section className="flex flex-col gap-4 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:flex-row sm:items-end">
<div className="flex-1">
<label htmlFor="mensualidades-esquema" className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Mensualidades del diferimiento
</label>
<select
id="mensualidades-esquema"
value={mensualidadEfectiva}
onChange={(e) => onNumMensualidades(Math.min(Number(e.target.value), tope))}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]"
>
{Array.from({ length: tope - MENSUALIDADES_MIN + 1 }, (_, i) => MENSUALIDADES_MIN + i).map(
(n) => (
<option key={n} value={n}>
{n} mensualidades
</option>
),
)}
</select>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{fechaEntrega
? `Tope: la entrega estimada es el ${new Date(`${fechaEntrega}T00:00:00`).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })} (máx. ${tope} mensualidades); cada una de ${formatoMoneda(esquema.mensualidad)}`
: `De ${MENSUALIDADES_MIN} a ${tope}; cada una de ${formatoMoneda(esquema.mensualidad)}`}
</p>
</div>
<label className="flex cursor-pointer items-center gap-2 pb-2.5 text-sm text-[#374151] dark:text-[#CBD5E1]">
<input
id="escrituracion-diferida"
type="checkbox"
checked={escrituracionDiferida}
onChange={(e) => onEscrituracionDiferida(e.target.checked)}
className="h-4 w-4 rounded border-[#D1D5DB] accent-accent"
/>
Diferir escrituración con el enganche
</label>
</section>
</div>
)
}

View File

@@ -11,6 +11,9 @@ interface Props {
} }
const TITULO_PROPUESTA: Record<string, string> = { const TITULO_PROPUESTA: Record<string, string> = {
// Esquema único FRASA 2026 (cotizaciones nuevas); las viejas muestran su
// tipoPropuesta legacy (BANCARIO, INFONAVIT, CONTADO, SEMI_CONTADO)
FRASA_2026: 'Esquema FRASA',
BANCARIO: 'Crédito Bancario', BANCARIO: 'Crédito Bancario',
INFONAVIT: 'Crédito Infonavit-Cofinavit', INFONAVIT: 'Crédito Infonavit-Cofinavit',
COFINAVIT: 'Crédito Infonavit-Cofinavit', COFINAVIT: 'Crédito Infonavit-Cofinavit',

View File

@@ -0,0 +1,236 @@
import type { AjustesCredito, ResultadoOpcionCredito, TipoCredito } from '../lib/opciones-credito'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
opciones: ResultadoOpcionCredito[]
ajustes: Record<TipoCredito, AjustesCredito>
onCambiarAjustes: (tipo: TipoCredito, ajustes: AjustesCredito) => void
elegida: TipoCredito
onElegir: (tipo: TipoCredito) => void
}
const cardBase =
'flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#1E293B] dark:bg-[#0F172A]'
const etiqueta = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
const inputClase =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]'
const fila = 'flex flex-col gap-1 py-1.5 sm:flex-row sm:items-center sm:justify-between'
const filaEtiqueta = 'text-sm text-[#64748B] dark:text-[#94A3B8]'
const filaMonto = 'text-sm font-medium text-[#111827] dark:text-[#E2E8F0]'
/** Clamp básico de los ajustes al cambiar un input (evita estados inválidos) */
function ajustar(tipo: TipoCredito, previo: AjustesCredito, parcial: Partial<AjustesCredito>): AjustesCredito {
const siguiente = { ...previo, ...parcial }
if (tipo === 'SEMI_CONTADO') {
const pct = Number(siguiente.pctContado ?? 0)
siguiente.pctContado = Math.min(1, Math.max(0, Number.isFinite(pct) ? pct : 0))
}
const tasa = Number(siguiente.tasaAnual)
siguiente.tasaAnual = Math.min(0.5, Math.max(0, Number.isFinite(tasa) ? tasa : 0))
const plazo = Number(siguiente.plazoAnos)
siguiente.plazoAnos = Math.min(35, Math.max(1, Number.isFinite(plazo) ? Math.round(plazo) : 20))
return siguiente
}
function Fila({ titulo, monto, negritas = false }: { titulo: string; monto: string; negritas?: boolean }) {
return (
<div className={fila}>
<dt className={negritas ? `text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]` : filaEtiqueta}>
{titulo}
</dt>
<dd className={negritas ? 'text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]' : filaMonto}>
{monto}
</dd>
</div>
)
}
function TarjetaOpcion({
r,
ajuste,
elegida,
onCambiarAjustes,
onElegir,
}: {
r: ResultadoOpcionCredito
ajuste: AjustesCredito
elegida: boolean
onCambiarAjustes: (tipo: TipoCredito, ajustes: AjustesCredito) => void
onElegir: (tipo: TipoCredito) => void
}) {
const conCredito = r.tipo !== 'CONTADO'
const esSemi = r.tipo === 'SEMI_CONTADO'
return (
<article
className={`${cardBase} ${elegida ? 'ring-2 ring-accent' : ''} ${r.esquemaVigente ? '' : 'opacity-90'}`}
>
<header className="mb-3 flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{r.titulo}</h3>
{r.descuento > 0 && (
<span className="inline-flex shrink-0 items-center rounded-full bg-accent/10 px-2.5 py-1 text-xs font-semibold text-accent">
Descuento {formatoPorcentaje(r.descuento)}
</span>
)}
</header>
{!r.esquemaVigente ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
El apartado supera el 10% del precio final con descuento; revisa el monto de apartado.
</p>
) : (
<>
<dl className="divide-y divide-[#E5E7EB] dark:divide-[#1E293B]">
<Fila titulo="Precio final c/desc." monto={formatoMoneda(r.precioFinal)} />
<Fila
titulo="Esquema FRASA 2026"
monto={`A la firma ${formatoMoneda(r.esquema.pagoUnicoFirma)} · ${r.esquema.numMensualidades} × ${formatoMoneda(r.esquema.mensualidad)} diferible`}
/>
{r.tipo === 'CONTADO' ? (
<>
<div className={fila}>
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Se liquida de contado
</dt>
<dd className="text-sm font-semibold text-accent">{formatoMoneda(r.saldoAFinanciar)}</dd>
</div>
<Fila titulo="Ahorro vs precio de lista" monto={formatoMoneda(r.ahorroVsLista)} />
<Fila titulo="Total a pagar" monto={formatoMoneda(r.totalEstimado)} negritas />
</>
) : (
<Fila titulo="Saldo a financiar (84.5%)" monto={formatoMoneda(r.saldoAFinanciar)} />
)}
{esSemi && (
<Fila titulo="Pago de contado a la firma" monto={formatoMoneda(r.pagoContado)} />
)}
</dl>
{esSemi && (
<div className="mt-3">
<label htmlFor={`pct-contado-${r.tipo}`} className={etiqueta}>
% de contado
</label>
<input
id={`pct-contado-${r.tipo}`}
type="number"
min={0}
max={100}
step={5}
value={Math.round((ajuste.pctContado ?? 0) * 100)}
onChange={(e) => {
const v = Number(e.target.value)
const pct = Number.isFinite(v) ? Math.min(100, Math.max(0, v)) / 100 : 0
onCambiarAjustes(r.tipo, ajustar(r.tipo, ajuste, { pctContado: pct }))
}}
className={inputClase}
/>
</div>
)}
{conCredito && (
<>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label htmlFor={`tasa-${r.tipo}`} className={etiqueta}>
Tasa anual %
</label>
<input
id={`tasa-${r.tipo}`}
type="number"
min={0}
max={50}
step={0.1}
value={Number((ajuste.tasaAnual * 100).toFixed(2))}
onChange={(e) => {
const v = Number(e.target.value)
onCambiarAjustes(
r.tipo,
ajustar(r.tipo, ajuste, { tasaAnual: Number.isFinite(v) ? v / 100 : 0 }),
)
}}
className={inputClase}
/>
</div>
<div>
<label htmlFor={`plazo-${r.tipo}`} className={etiqueta}>
Plazo
</label>
<select
id={`plazo-${r.tipo}`}
value={ajuste.plazoAnos}
onChange={(e) => onCambiarAjustes(r.tipo, ajustar(r.tipo, ajuste, { plazoAnos: Number(e.target.value) }))}
className={inputClase}
>
{Array.from({ length: 31 }, (_, i) => i + 5).map((n) => (
<option key={n} value={n}>
{n} años
</option>
))}
</select>
</div>
</div>
<div className="mt-3 rounded-lg bg-accent/10 px-3 py-2.5">
<p className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">Mensualidad estimada</p>
<p className="text-lg font-semibold text-accent">{formatoMoneda(r.mensualidadEstimada ?? 0)}</p>
</div>
<dl className="mt-3 divide-y divide-[#E5E7EB] dark:divide-[#1E293B]">
<Fila titulo="Monto del crédito" monto={formatoMoneda(r.montoCredito)} />
<Fila titulo="Intereses estimados" monto={formatoMoneda(r.interesesEstimados)} />
<Fila titulo="Total estimado" monto={formatoMoneda(r.totalEstimado)} negritas />
</dl>
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">
Mensualidad estimada con tasa y plazo ajustables; sujeta a aprobación del banco. No
constituye preaprobación.
</p>
</>
)}
</>
)}
<button
type="button"
onClick={() => onElegir(r.tipo)}
aria-pressed={elegida}
className={`mt-4 w-full rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition-all duration-100 ease-out ${
elegida
? 'bg-accent text-white hover:bg-accent-hover'
: 'border border-[#D1D5DB] bg-white text-[#374151] hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] dark:hover:bg-[#1E293B]'
}`}
>
{elegida ? 'Elegida — se incluye en el PDF' : 'Usar en el PDF'}
</button>
</article>
)
}
/**
* 4 opciones de financiamiento del saldo (84.5% del precio final) sobre el
* esquema FRASA 2026: Crédito Bancario, Infonavit-Cofinavit, Semi Contado y
* Contado. Cada tarjeta ajusta su tasa/plazo (y % de contado para la semi),
* estima la mensualidad del crédito y permite elegir la propuesta que sale
* en el PDF.
*/
export function OpcionesCredito({ opciones, ajustes, onCambiarAjustes, elegida, onElegir }: Props) {
return (
<section className="mt-6">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
Opciones de financiamiento del saldo (84.5% del precio final)
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{opciones.map((r) => (
<TarjetaOpcion
key={r.tipo}
r={r}
ajuste={ajustes[r.tipo]}
elegida={r.tipo === elegida}
onCambiarAjustes={onCambiarAjustes}
onElegir={onElegir}
/>
))}
</div>
</section>
)
}

View File

@@ -1,62 +0,0 @@
import type { PropuestaResultado } from '../lib/calculations'
import { formatoMoneda } from '../lib/formatters'
interface Props {
numero: number
titulo: string
propuesta: PropuestaResultado
}
function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; nota?: string }) {
return (
<div className="flex items-baseline justify-between gap-2 py-2">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{etiqueta}
{nota && (
<span className="mt-0.5 block text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{nota}
</span>
)}
</span>
<span className="text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{valor}</span>
</div>
)
}
/** Tarjeta de una propuesta comercial (el descuento ya no es editable: usa el del Excel) */
export function ProposalCard({ numero, titulo, propuesta }: Props) {
return (
<article className="flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-all duration-200 hover:shadow-md">
<header className="mb-6 border-b border-[#E5E7EB] pb-4">
<p className="text-xs font-semibold uppercase tracking-wider text-accent">
Propuesta comercial #{numero}
</p>
<h2 className="mt-1 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{titulo}</h2>
</header>
<div className="divide-y divide-[#E5E7EB]">
{propuesta.descuento > 0 && (
<Renglon etiqueta="Monto de descuento" valor={formatoMoneda(propuesta.montoDescuento)} />
)}
<Renglon etiqueta="Precio final c/desc." valor={formatoMoneda(propuesta.precioFinal)} />
<Renglon
etiqueta="Enganche"
nota="incluye deducción del apartado"
valor={formatoMoneda(propuesta.enganche)}
/>
{propuesta.notaMensualidades ? (
<Renglon etiqueta="Mensualidades" nota={propuesta.notaMensualidades} valor="—" />
) : propuesta.mensualidad !== null && propuesta.mensualidadesTotal !== null ? (
<Renglon
etiqueta="Mensualidades"
nota={`${propuesta.numMensualidades} × ${formatoMoneda(propuesta.mensualidad)}`}
valor={formatoMoneda(propuesta.mensualidadesTotal)}
/>
) : (
<Renglon etiqueta="Mensualidades" valor="N/A" />
)}
<Renglon etiqueta="Pago final" valor={formatoMoneda(propuesta.pagoFinal)} />
</div>
</article>
)
}

View File

@@ -1,31 +1,22 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react' import { act, cleanup, renderHook } from '@testing-library/react'
import { useCotizacion } from './useCotizacion' import { APARTADO_ESQUEMA_DEFAULT, mensualidadesParaUnidad, useCotizacion } from './useCotizacion'
import { reglasDesdeApi, SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas' import { SIN_REGLAS } from '../lib/reglas'
import { DESARROLLOS } from '../data/desarrollos' import { MENSUALIDADES_DEFAULT, calcularEsquemaFrasa } from '../lib/esquema-frasa'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
/** /**
* Reglas comerciales configurables en el cotizador: las propuestas desactivadas * Estado central con el esquema único FRASA 2026: apartado libre (default
* por el admin dejan de ofrecerse y, si la elegida se desactiva, se avisa y se * $15,000), mensualidades del diferimiento (default 14) y escrituración a la
* cae a la primera vigente. * firma o diferida. Ya no hay propuestas: el resultado sale de
* calcularEsquemaFrasa (mismo motor que el API).
*/ */
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')! const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
function reglasDiagonales(activa: (tipo: string) => boolean): ReglasDesarrollo { function montar() {
return reglasDesdeApi({ const hook = renderHook(() => useCotizacion(SIN_REGLAS))
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => ({ ...p, activa: activa(p.tipo) })),
},
})
}
function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
const hook = renderHook(({ reglas }) => useCotizacion(reglas), {
initialProps: { reglas: reglas as ReglasDesarrollo },
})
act(() => { act(() => {
hook.result.current.setUnidad(unidadDiagonal) hook.result.current.setUnidad(unidadDiagonal)
}) })
@@ -34,75 +25,172 @@ function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
afterEach(cleanup) afterEach(cleanup)
describe('useCotizacion y las reglas del admin', () => { describe('useCotizacion — esquema único FRASA 2026', () => {
it('sin reglas ofrece las 4 propuestas de siempre', () => { it('arranca con apartado $15,000, 14 mensualidades y escrituración a la firma', () => {
const { result } = renderHook(() => useCotizacion(SIN_REGLAS))
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
expect(result.current.apartadoEfectivo).toBe(15000)
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
})
it('elegir unidad con fecha de entrega: las mensualidades = meses que faltan para entregarla', () => {
const { result } = renderHook(() => useCotizacion(SIN_REGLAS))
act(() => {
result.current.setUnidad(unidadDiagonal)
})
expect(result.current.numMensualidades).toBe(mensualidadesParaUnidad(unidadDiagonal))
expect(result.current.numMensualidades).not.toBe(MENSUALIDADES_DEFAULT)
})
it('elegir unidad SIN fecha de entrega: cae al default del esquema (14)', () => {
const { result } = renderHook(() => useCotizacion(SIN_REGLAS))
act(() => {
result.current.setUnidad({ ...unidadDiagonal, fechaEntrega: null })
})
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
})
it('mensualidadesParaUnidad: acota a 1..36 y cae al default sin fecha', () => {
const hoy = new Date('2026-09-29T12:00:00')
const base = { ...unidadDiagonal }
expect(mensualidadesParaUnidad({ ...base, fechaEntrega: '2026-10-15' }, hoy)).toBe(1)
expect(mensualidadesParaUnidad({ ...base, fechaEntrega: '2035-12-31' }, hoy)).toBe(36)
expect(mensualidadesParaUnidad({ ...base, fechaEntrega: '2027-09-29' }, hoy)).toBe(12)
expect(mensualidadesParaUnidad(null, hoy)).toBe(MENSUALIDADES_DEFAULT)
})
it('el resultado cuadra con calcularEsquemaFrasa sobre el precio de lista', () => {
const { result } = montar()
const { unidad, acabados } = result.current
const esperado = calcularEsquemaFrasa({
precio: result.current.precioLista,
apartado: 15000,
numMensualidades: mensualidadesParaUnidad(unidadDiagonal),
escrituracionDiferida: false,
})
expect(result.current.resultado).toEqual(esperado)
// El esquema siempre liquida el 15.5% del precio (2.5 + 7.5 + 5.5)
expect(result.current.resultado?.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
expect(unidad).toBe(unidadDiagonal)
expect(acabados).toBe(false)
})
it('sin unidad no hay resultado (panel vacío)', () => {
const { result } = montar()
act(() => {
result.current.setUnidad(null)
})
expect(result.current.resultado).toBeNull()
expect(result.current.precioLista).toBe(0)
})
it('el apartado libre alimenta el saldo a firma; el excedente acredita al enganche', () => {
const { result } = montar()
const excedente = Math.round(result.current.precioLista * 0.025) + 5000
act(() => {
result.current.setApartado(excedente)
})
const r = result.current.resultado!
expect(r.excedenteApartado).toBeGreaterThan(0)
expect(r.saldoFirma).toBe(0)
// El total no cambia: el excedente se mueve de concepto, no se cobra dos veces
expect(r.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
})
it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => {
const { result } = montar()
const n = Math.min(6, mensualidadesParaUnidad(unidadDiagonal))
act(() => {
result.current.setNumMensualidades(n)
})
const r = result.current.resultado!
expect(r.numMensualidades).toBe(n)
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / n, 6)
})
it('el ajuste de mensualidades respeta el tope de la entrega estimada', () => {
const { result } = montar()
const tope = mensualidadesParaUnidad(unidadDiagonal)
act(() => {
result.current.setNumMensualidades(tope + 10)
})
expect(result.current.numMensualidades).toBe(tope)
})
it('escrituración diferida: sube el diferible y baja el pago a la firma', () => {
const { result } = montar() const { result } = montar()
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
])
expect(result.current.avisoPropuestaDesactivada).toBeNull()
})
it('una propuesta desactivada no se ofrece ni se puede elegir', () => {
const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO'))
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
})
it('si el admin desactiva la propuesta elegida, avisa y cae a la primera vigente', () => {
const { result, rerender } = montar()
// El vendedor elige Contado (índice 2)
act(() => { act(() => {
result.current.setPropuestaIndex(2) result.current.setEscrituracionDiferida(true)
}) })
expect(result.current.propuestas[2]?.tipo).toBe('CONTADO') const r = result.current.resultado!
expect(r.escrituracionDiferida).toBe(true)
// El admin la desactiva (llegan reglas nuevas) expect(r.baseDiferible).toBeCloseTo(r.enganche + r.escrituracion, 6)
rerender({ reglas: reglasDiagonales((tipo) => tipo !== 'CONTADO') }) expect(r.pagoUnicoFirma).toBeCloseTo(r.saldoFirma, 6)
expect(result.current.propuestas.map((p) => p.tipo)).not.toContain('CONTADO')
expect(result.current.propuestaIndex).toBe(0)
expect(result.current.avisoPropuestaDesactivada).toContain('Contado')
expect(result.current.avisoPropuestaDesactivada).toContain('Crédito Bancario')
}) })
it('los descuentos por defecto salen de las reglas configuradas', () => { it('restaurar carga una cotización guardada (N acotado a la entrega y escrituración)', () => {
const { result } = montar( const { result } = montar()
reglasDesdeApi({ const tope = mensualidadesParaUnidad(unidadDiagonal)
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.06 } : p,
),
},
}),
)
expect(result.current.descuentos.contado).toBe(0.06) act(() => {
expect(result.current.descuentos.semiContado).toBe(0.01) result.current.restaurar({
desarrollo: 'DIAGONAL',
unidad: unidadDiagonal,
acabados: true,
apartado: 20000,
numMensualidades: 24,
escrituracionDiferida: true,
})
})
expect(result.current.acabados).toBe(true)
expect(result.current.apartadoEfectivo).toBe(20000)
// El diferimiento no puede rebasar la entrega: 24 se acota a las que faltan
expect(result.current.numMensualidades).toBe(tope)
expect(result.current.escrituracionDiferida).toBe(true)
expect(result.current.resultado?.numMensualidades).toBe(tope)
}) })
it('las reglas cambian los números de las propuestas', () => { it('restaurar una cotización legacy (sin N ni escrituración): default acotado a la entrega', () => {
const { result } = montar( const { result } = montar()
reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.05 } : p,
),
},
}),
)
const bancario = result.current.propuestas.find((p) => p.tipo === 'CREDITO_BANCARIO') act(() => {
const precioLista = result.current.precioLista result.current.restaurar({
expect(bancario?.enganche).toBeCloseTo(0.05 * precioLista - result.current.apartadoEfectivo, 2) desarrollo: 'DIAGONAL',
unidad: unidadDiagonal,
acabados: false,
apartado: 20000,
})
})
// Sin N guardado cae al default (14), pero nunca rebasando la entrega
expect(result.current.numMensualidades).toBe(
Math.min(MENSUALIDADES_DEFAULT, mensualidadesParaUnidad(unidadDiagonal)),
)
expect(result.current.escrituracionDiferida).toBe(false)
})
it('nueva reinicia apartado, mensualidades y escrituración, y limpia la unidad', () => {
const { result } = montar()
act(() => {
result.current.setApartado(40000)
result.current.setNumMensualidades(6)
result.current.setEscrituracionDiferida(true)
})
act(() => {
result.current.nueva()
})
expect(result.current.unidad).toBeNull()
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
}) })
}) })

View File

@@ -1,25 +1,41 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useMemo, useState } from 'react'
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types' import type { Unidad, ParametrosDesarrollo } from '../types'
import { import { obtenerPrecioLista, resolverNumMensualidades } from '../lib/calculations'
APARTADO_DEFAULT,
calcularPropuestas,
obtenerPrecioLista,
resolverNumMensualidades,
validarEnganchePct,
type PropuestaResultado,
} from '../lib/calculations'
import { DESARROLLO_DEFAULT } from '../data/desarrollos' import { DESARROLLO_DEFAULT } from '../data/desarrollos'
import { import {
SIN_REGLAS, MENSUALIDADES_DEFAULT,
descuentosDefaultEfectivo, MENSUALIDADES_MAX,
desarrolloEfectivo, MENSUALIDADES_MIN,
propuestasVigentes, PCT_ENGANCHE_MAXIMO,
type ReglasDesarrollo, calcularEsquemaFrasa,
} from '../lib/reglas' type EsquemaFrasaResultado,
} from '../lib/esquema-frasa'
import { SIN_REGLAS, desarrolloEfectivo, type ReglasDesarrollo } from '../lib/reglas'
/** Apartado con el que arranca (y se reinicia) el cotizador: cantidad libre */
export const APARTADO_ESQUEMA_DEFAULT = 15000
const MS_POR_MES = 30.4 * 86400000
/**
* Tope (y default) de mensualidades del diferimiento para una unidad: los
* meses que faltan hasta su fecha de entrega estimada (la que vive en el
* módulo de captura: entrega programada/reprogramada de obra). El diferimiento
* nunca puede rebasar la entrega de la vivienda. Si la unidad no trae fecha,
* cae al default global del esquema (14; tope 36).
*/
export function mensualidadesParaUnidad(u: Unidad | null, hoy: Date = new Date()): number {
const fecha = u?.fechaEntrega
if (!fecha) return MENSUALIDADES_DEFAULT
const entrega = new Date(`${fecha}T00:00:00`).getTime()
if (!Number.isFinite(entrega)) return MENSUALIDADES_DEFAULT
const sugeridas = Math.max(MENSUALIDADES_MIN, Math.round((entrega - hoy.getTime()) / MS_POR_MES))
return Math.min(MENSUALIDADES_MAX, sugeridas)
}
interface UseCotizacionResult { interface UseCotizacionResult {
desarrollo: string desarrollo: string
/** Cambia de desarrollo: limpia la unidad elegida y reinicia los descuentos */ /** Cambia de desarrollo: limpia la unidad elegida */
setDesarrollo: (id: string) => void setDesarrollo: (id: string) => void
params: ParametrosDesarrollo params: ParametrosDesarrollo
unidad: Unidad | null unidad: Unidad | null
@@ -30,25 +46,25 @@ interface UseCotizacionResult {
setApartado: (v: number) => void setApartado: (v: number) => void
/** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */ /** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */
apartadoEfectivo: number apartadoEfectivo: number
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */ /** Número de mensualidades del diferimiento (1..36): al elegir unidad se
enganchePct: number | null * prellena con los meses que faltan para su entrega estimada; el vendedor
setEnganchePct: (v: number | null) => void * puede ajustarla */
/** Mensaje de error del enganche capturado; null si es válido */ numMensualidades: number
errorEnganche: string | null setNumMensualidades: (n: number) => void
descuentos: DescuentosPropuestas /** true = la escrituración (5.5%) se difiere con el enganche; false = se paga a la firma */
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void escrituracionDiferida: boolean
/** Índice de la propuesta elegida (para guardar/compartir) */ setEscrituracionDiferida: (v: boolean) => void
propuestaIndex: number /** Enganche adicional (anticipo extra, opcional): el total de anticipos no
setPropuestaIndex: (i: number) => void * puede superar el 90% del precio (mínimo 10% queda por financiar) */
engancheAdicional: number
setEngancheAdicional: (v: number) => void
/** Máximo de enganche adicional permitido por el tope del 90% (0 sin unidad) */
engancheAdicionalMaximo: number
precioLista: number precioLista: number
/** Número de mensualidades elegido por el vendedor; null = usa el calculado */ /** Esquema FRASA 2026 calculado; null mientras no hay unidad elegida */
numMensualidades: number | null resultado: EsquemaFrasaResultado | null
setNumMensualidades: (n: number | null) => void /** Meses restantes para la entrega de la unidad (ficha técnica); null si no se pueden resolver */
/** Número efectivo de mensualidades (la elegida o la calculada) */
numMeses: number | null numMeses: number | null
propuestas: PropuestaResultado[]
/** Aviso cuando el admin desactivó la propuesta que estaba elegida; null si no aplica */
avisoPropuestaDesactivada: string | null
/** Restaura el estado completo desde una cotización guardada (historial) */ /** Restaura el estado completo desde una cotización guardada (historial) */
restaurar: (estado: EstadoRestaurar) => void restaurar: (estado: EstadoRestaurar) => void
/** Reinicia la cotización (botón "Nueva") */ /** Reinicia la cotización (botón "Nueva") */
@@ -61,60 +77,58 @@ export interface EstadoRestaurar {
unidad: Unidad unidad: Unidad
acabados: boolean acabados: boolean
apartado: number apartado: number
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */ /** Mensualidades del diferimiento guardadas (default 14) */
descuento?: number numMensualidades?: number
propuestaIndex?: number /** Escrituración diferida guardada (default false) */
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */ escrituracionDiferida?: boolean
numMensualidades?: number | null /** Enganche adicional guardado (default 0) */
engancheAdicional?: number
} }
/** /**
* Estado central de la cotización: desarrollo, unidad, acabados, apartado y * Estado central de la cotización con el esquema único FRASA 2026:
* descuentos. Las reglas comerciales llegan del API (con respaldo en las * desarrollo, unidad, acabados, apartado libre, mensualidades del
* constantes): si el admin desactiva una propuesta, deja de ofrecerse y, si era * diferimiento (1..36, default 14) y si la escrituración se difiere o se paga
* la elegida, se avisa y se cae a la primera vigente. * a la firma. El resultado sale de calcularEsquemaFrasa (módulo puro, mismo
* motor que el API). Ya no hay propuestas: descuentos, enganche en % e índice
* de propuesta desaparecieron con las 4 propuestas.
*/ */
export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult { export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult {
const [desarrollo, setDesarrolloState] = useState<string>(DESARROLLO_DEFAULT) const [desarrollo, setDesarrolloState] = useState<string>(DESARROLLO_DEFAULT)
const [unidad, setUnidad] = useState<Unidad | null>(null) const [unidad, setUnidad] = useState<Unidad | null>(null)
const [acabados, setAcabados] = useState(false) const [acabados, setAcabados] = useState(false)
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT) const [apartado, setApartado] = useState<number>(APARTADO_ESQUEMA_DEFAULT)
const [enganchePct, setEnganchePct] = useState<number | null>(null) const [numMensualidades, setNumMensualidades] = useState<number>(MENSUALIDADES_DEFAULT)
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>( const [escrituracionDiferida, setEscrituracionDiferida] = useState(false)
descuentosDefaultEfectivo(reglas, DESARROLLO_DEFAULT), const [engancheAdicional, setEngancheAdicionalState] = useState(0)
)
const [propuestaIndex, setPropuestaIndex] = useState(0)
const [numMensualidades, setNumMensualidades] = useState<number | null>(null)
const [avisoPropuestaDesactivada, setAvisoPropuestaDesactivada] = useState<string | null>(null)
const params = desarrolloEfectivo(reglas, desarrollo) const params = desarrolloEfectivo(reglas, desarrollo)
// Los descuentos por defecto se re-aplican cuando cambian las reglas del
// desarrollo (p. ej. al llegar las del API), sin pisar lo que capturó el
// vendedor mientras las reglas no cambian.
const descuentosAplicados = useRef<string>('')
const defaults = useMemo(
() => descuentosDefaultEfectivo(reglas, desarrollo),
[reglas, desarrollo],
)
useEffect(() => {
const clave = JSON.stringify(defaults)
if (descuentosAplicados.current === clave) return
descuentosAplicados.current = clave
setDescuentos(defaults)
}, [defaults])
const setDesarrollo = (id: string) => { const setDesarrollo = (id: string) => {
setDesarrolloState(id) setDesarrolloState(id)
setUnidad(null) setUnidad(null)
setAcabados(false) setAcabados(false)
setDescuentos(descuentosDefaultEfectivo(reglas, id))
setPropuestaIndex(0)
setNumMensualidades(null)
} }
const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => { /** Elige unidad y, con ella, las mensualidades del diferimiento: los meses
setDescuentos((prev) => ({ ...prev, [clave]: valor })) * que faltan para la entrega estimada de esa vivienda (dato del módulo de
* captura). El vendedor puede ajustarlas después, sin rebasar la entrega. */
const elegirUnidad = (u: Unidad | null) => {
setUnidad(u)
setNumMensualidades(mensualidadesParaUnidad(u))
}
/** Tope de mensualidades: las que faltan para la entrega (36 sin fecha) */
const topeMensualidades = () => mensualidadesParaUnidad(unidad)
const ajustarMensualidades = (n: number) => {
setNumMensualidades(Math.max(MENSUALIDADES_MIN, Math.min(n, topeMensualidades())))
}
/** Enganche adicional: acotado a >= 0; el esquema valida que el anticipo
* total no supere el 90% del precio (mínimo 10% por financiar) */
const setEngancheAdicional = (v: number) => {
setEngancheAdicionalState(Number.isFinite(v) ? Math.max(0, v) : 0)
} }
const restaurar = (estado: EstadoRestaurar) => { const restaurar = (estado: EstadoRestaurar) => {
@@ -122,38 +136,29 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setUnidad(estado.unidad) setUnidad(estado.unidad)
setAcabados(estado.acabados) setAcabados(estado.acabados)
setApartado(estado.apartado) setApartado(estado.apartado)
const base = descuentosDefaultEfectivo(reglas, estado.desarrollo) // El diferimiento no puede rebasar la entrega: una cotización guardada con
const paramsRestaurados = desarrolloEfectivo(reglas, estado.desarrollo) // más mensualidades se acota a las que faltan hoy para esa unidad
const vigentes = propuestasVigentes(paramsRestaurados) setNumMensualidades(
if (estado.descuento !== undefined) { Math.min(estado.numMensualidades ?? MENSUALIDADES_DEFAULT, mensualidadesParaUnidad(estado.unidad)),
const idx = estado.propuestaIndex ?? 0 )
const clave = vigentes[idx]?.claveDescuento setEscrituracionDiferida(estado.escrituracionDiferida ?? false)
if (clave) base[clave] = estado.descuento setEngancheAdicionalState(Math.max(0, estado.engancheAdicional ?? 0))
}
setDescuentos(base)
setPropuestaIndex(estado.propuestaIndex ?? 0)
setNumMensualidades(estado.numMensualidades ?? null)
} }
const nueva = () => { const nueva = () => {
setUnidad(null) setUnidad(null)
setAcabados(false) setAcabados(false)
setApartado(APARTADO_DEFAULT) setApartado(APARTADO_ESQUEMA_DEFAULT)
setEnganchePct(null) setNumMensualidades(MENSUALIDADES_DEFAULT)
setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo)) setEscrituracionDiferida(false)
setPropuestaIndex(0) setEngancheAdicionalState(0)
setNumMensualidades(null)
setAvisoPropuestaDesactivada(null)
} }
const numMesesCalculado = useMemo( const numMeses = useMemo(
() => (unidad ? resolverNumMensualidades(unidad, params) : null), () => (unidad ? resolverNumMensualidades(unidad, params) : null),
[unidad, params], [unidad, params],
) )
/** Número efectivo de mensualidades: la elegida por el vendedor, o la calculada */
const numMeses = numMensualidades ?? numMesesCalculado
const precioLista = useMemo( const precioLista = useMemo(
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0), () => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
[unidad, acabados], [unidad, acabados],
@@ -163,71 +168,58 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
// incluso $0; solo se acota a >= 0 como guarda contra capturas raras // incluso $0; solo se acota a >= 0 como guarda contra capturas raras
const apartadoEfectivo = Math.max(0, apartado) const apartadoEfectivo = Math.max(0, apartado)
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo /**
const errorEnganche = validarEnganchePct(enganchePct) * Esquema FRASA 2026 calculado; null mientras no hay unidad elegida. El
const engancheEfectivo = errorEnganche === null && enganchePct !== null ? enganchePct / 100 : null * enganche adicional se acota al máximo que permite el 90% de anticipo
* (el esquema lanza error si el total lo rebasa; aquí se previene).
const propuestas = useMemo(() => { */
if (!unidad) return [] const resultado = useMemo<EsquemaFrasaResultado | null>(() => {
return calcularPropuestas({ if (!unidad || precioLista <= 0) return null
unidad, const base = calcularEsquemaFrasa({
acabados, precio: precioLista,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
descuentos, numMensualidades,
desarrollo, escrituracionDiferida,
reglas: params,
enganchePct: engancheEfectivo,
numMeses: numMeses ?? undefined,
}) })
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, params, engancheEfectivo, numMeses]) const maxAdicional = Math.max(0, precioLista * PCT_ENGANCHE_MAXIMO - base.totalPagado)
const adicionalEfectivo = Math.min(Math.max(0, engancheAdicional), maxAdicional)
if (adicionalEfectivo === 0) return base
return calcularEsquemaFrasa({
precio: precioLista,
apartado: apartadoEfectivo,
numMensualidades,
escrituracionDiferida,
engancheAdicional: adicionalEfectivo,
})
}, [unidad, precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida, engancheAdicional])
// Propuesta elegida: si el índice se sale de la lista vigente (p. ej. el admin /** Máximo de enganche adicional que permite el 90% de anticipo (0 sin unidad) */
// desactivó una propuesta) se cae a la primera y, si la que estaba elegida ya const engancheAdicionalMaximo = useMemo(() => {
// no existe, se avisa con claridad. if (!resultado) return 0
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] return Math.max(0, precioLista * PCT_ENGANCHE_MAXIMO - (resultado.totalPagado - resultado.engancheAdicional))
const tipoPrevio = useRef<{ tipo: string; titulo: string } | null>(null) }, [resultado, precioLista])
useEffect(() => {
const previo = tipoPrevio.current
const vigentes = propuestasVigentes(params)
if (previo !== null && !vigentes.some((p) => p.tipo === previo.tipo)) {
const sustituto = propuestas[0]?.titulo ?? 'otra propuesta'
setAvisoPropuestaDesactivada(
`El administrador desactivó la propuesta ${previo.titulo} para ${params.nombre}. Se seleccionó ${sustituto}; elige otra si lo prefieres.`,
)
setPropuestaIndex(0)
} else if (propuestaIndex >= propuestas.length) {
setPropuestaIndex(0)
}
tipoPrevio.current = propuestaActiva
? { tipo: propuestaActiva.tipo, titulo: propuestaActiva.titulo }
: null
}, [params, propuestas, propuestaActiva, propuestaIndex])
return { return {
desarrollo, desarrollo,
setDesarrollo, setDesarrollo,
params, params,
unidad, unidad,
setUnidad, setUnidad: elegirUnidad,
acabados, acabados,
setAcabados, setAcabados,
apartado, apartado,
setApartado, setApartado,
apartadoEfectivo, apartadoEfectivo,
enganchePct,
setEnganchePct,
errorEnganche,
descuentos,
setDescuento,
propuestaIndex,
setPropuestaIndex,
precioLista,
numMensualidades, numMensualidades,
setNumMensualidades, setNumMensualidades: ajustarMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
engancheAdicional,
setEngancheAdicional,
engancheAdicionalMaximo,
precioLista,
resultado,
numMeses, numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar, restaurar,
nueva, nueva,
} }

View File

@@ -1,10 +1,11 @@
import type { PropuestaResultado } from './calculations' import type { EsquemaFrasaResultado } from './esquema-frasa'
import { formatoMoneda, formatoFechaLarga } from './formatters' import { formatoMoneda, formatoFechaLarga } from './formatters'
export interface DatosCompartir { export interface DatosCompartir {
unidadCodigo: string unidadCodigo: string
desarrolloNombre: string desarrolloNombre: string
propuesta: PropuestaResultado /** Esquema único FRASA 2026 calculado */
esquema: EsquemaFrasaResultado
apartado: number apartado: number
vigencia: Date vigencia: Date
/** Folio y URL pública solo existen si la cotización ya se guardó en el API */ /** Folio y URL pública solo existen si la cotización ya se guardó en el API */
@@ -13,8 +14,9 @@ export interface DatosCompartir {
} }
/** /**
* Texto resumen de la propuesta, listo para copiar/pegar en WhatsApp. * Texto resumen del esquema FRASA 2026, listo para copiar/pegar en WhatsApp.
* Incluye unidad, desarrollo, propuesta elegida, montos y vigencia. * Incluye unidad, desarrollo, línea de tiempo (hoy / firma / mensualidades)
* y vigencia.
*/ */
export function textoCompartir(d: DatosCompartir): string { export function textoCompartir(d: DatosCompartir): string {
const lineas: string[] = [] const lineas: string[] = []
@@ -23,20 +25,14 @@ export function textoCompartir(d: DatosCompartir): string {
) )
lineas.push(`Desarrollo: ${d.desarrolloNombre}`) lineas.push(`Desarrollo: ${d.desarrolloNombre}`)
lineas.push(`Unidad: ${d.unidadCodigo}`) lineas.push(`Unidad: ${d.unidadCodigo}`)
lineas.push(`Propuesta: ${d.propuesta.titulo}`) lineas.push(`Precio de lista: ${formatoMoneda(d.esquema.precio)}`)
lineas.push(`Precio final c/desc: ${formatoMoneda(d.propuesta.precioFinal)}`) lineas.push(`Apartado (hoy): ${formatoMoneda(d.apartado)}`)
lineas.push(`Apartado: ${formatoMoneda(d.apartado)}`) lineas.push(`Pago a la firma: ${formatoMoneda(d.esquema.pagoUnicoFirma)}`)
lineas.push(`Enganche: ${formatoMoneda(d.propuesta.enganche)}`) lineas.push(
if (d.propuesta.notaMensualidades) { `Mensualidades: ${d.esquema.numMensualidades} × ${formatoMoneda(d.esquema.mensualidad)} (total diferible ${formatoMoneda(d.esquema.baseDiferible)})`,
lineas.push(`Mensualidades: — (${d.propuesta.notaMensualidades})`) )
} else if (d.propuesta.mensualidad !== null) { lineas.push(`Escrituración 5.5%: ${d.esquema.escrituracionDiferida ? 'diferida con el enganche' : `pagada a la firma (${formatoMoneda(d.esquema.escrituracion)})`}`)
lineas.push( lineas.push(`Total del esquema: ${formatoMoneda(d.esquema.totalPagado)}`)
`Mensualidades: ${d.propuesta.numMensualidades} × ${formatoMoneda(d.propuesta.mensualidad)}`,
)
} else {
lineas.push('Mensualidades: N/A')
}
lineas.push(`Pago final: ${formatoMoneda(d.propuesta.pagoFinal)}`)
lineas.push(`Vigencia: hasta el ${formatoFechaLarga(d.vigencia)}`) lineas.push(`Vigencia: hasta el ${formatoFechaLarga(d.vigencia)}`)
if (d.urlPublica) lineas.push(`Consulta: ${d.urlPublica}`) if (d.urlPublica) lineas.push(`Consulta: ${d.urlPublica}`)
return lineas.join('\n') return lineas.join('\n')

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
calcularEsquemaFrasa,
validarEsquemaInput,
PCT_ENGANCHE_FIRMA,
PCT_ENGANCHE,
PCT_ESCRITURACION,
} from './esquema-frasa'
/** 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) + 0, 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)
// y equivale al 15.5% del precio (2.5 + 7.5 + 5.5) salvo excedentes
expect(r.totalPagado).toBeCloseTo(155000, 2)
}
})
})
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', () => {
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) // no se cobra dos veces: 30k + 70k + 55k
})
it('apartado mínimo (sin mínimo fijo): $1 aparta igual', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 1, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(24999)
})
})
describe('calcularEsquemaFrasa — mensualidades', () => {
it('N=1: todo el diferible en una sola mensualidad', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 15000, numMensualidades: 1, escrituracionDiferida: true })
expect(r.mensualidad).toBe(130000)
})
it('validación: rechaza entradas inválidas', () => {
expect(validarEsquemaInput({ precio: 0, apartado: 0, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/precio/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: -5, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/apartado/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 2000000, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/superar/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 0, escrituracionDiferida: false })).toMatch(/mensualidad/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14.5, escrituracionDiferida: false })).toMatch(/entero/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false })).toBeNull()
})
})
describe('calcularEsquemaFrasa — enganche adicional (hasta 90% de anticipo)', () => {
const base = { precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
it('sin adicional: queda en 0 y el saldo a financiar es el 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.engancheAdicional).toBe(400000)
expect(r.totalPagado).toBeCloseTo(155000, 2)
expect(r.saldoAFinanciar).toBeCloseTo(445000, 2)
// El esquema base no cambia: el adicional es aparte
expect(r.enganche).toBeCloseTo(75000, 2)
expect(r.mensualidad).toBeCloseTo(75000 / 14, 2)
})
it('tope exacto: 90% del precio menos el esquema (745,000) es válido', () => {
const r = calcularEsquemaFrasa({ ...base, engancheAdicional: 745000 })
expect(r.saldoAFinanciar).toBeCloseTo(100000, 2)
})
it('rechaza pasar del 90% de enganche total', () => {
expect(() => calcularEsquemaFrasa({ ...base, engancheAdicional: 745001 })).toThrow(/90%/)
expect(() => calcularEsquemaFrasa({ ...base, engancheAdicional: 900000 })).toThrow(/90%/)
})
it('rechaza adicional negativo', () => {
expect(validarEsquemaInput({ ...base, engancheAdicional: -1 })).toMatch(/adicional/i)
})
})

View File

@@ -0,0 +1,123 @@
/**
* 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.
*
* Módulo puro (sin UI, sin API) para probarlo a fondo. 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

@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import { folioCotizacion } from './folio' import { folioCotizacion } from './folio'
import { textoCompartir, telefonoWhatsApp, urlWhatsApp } from './compartir' import { textoCompartir, telefonoWhatsApp, urlWhatsApp } from './compartir'
import { compararUnidades } from './comparadorUnidades' import { compararUnidades } from './comparadorUnidades'
import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations' import { calcularEsquemaFrasa } from './esquema-frasa'
import { APARTADO_DEFAULT, totalPagado } from './calculations'
import { UNIDADES_SEED } from '../data/unidades' import { UNIDADES_SEED } from '../data/unidades'
import { UNIDADES_ATELIER } from '../data/unidades-atelier' import { UNIDADES_ATELIER } from '../data/unidades-atelier'
import { DESARROLLOS } from '../data/desarrollos' import { DESARROLLOS } from '../data/desarrollos'
@@ -16,30 +17,31 @@ describe('folioCotizacion', () => {
}) })
describe('textoCompartir', () => { describe('textoCompartir', () => {
const propuesta = calcularPropuestas({ // Caso dorado del cliente: $1,000,000, apartado $15,000, N=14
unidad: UNIDADES_SEED[0], const esquema = calcularEsquemaFrasa({
acabados: true, precio: 1000000,
apartado: APARTADO_DEFAULT, apartado: 15000,
descuentos: { ...DESCUENTOS_DEFAULT }, numMensualidades: 14,
numMeses: 14, escrituracionDiferida: false,
})[0] })
const base = { const base = {
unidadCodigo: 'U-101', unidadCodigo: 'U-101',
desarrolloNombre: 'Diagonal', desarrolloNombre: 'Diagonal',
propuesta, esquema,
apartado: APARTADO_DEFAULT, apartado: 15000,
vigencia: new Date('2026-08-31T00:00:00'), vigencia: new Date('2026-08-31T00:00:00'),
} }
it('incluye unidad, desarrollo, propuesta, montos y vigencia', () => { it('incluye unidad, desarrollo, línea de tiempo del esquema y vigencia', () => {
const texto = textoCompartir({ ...base, folio: 'COT-3F8A1B', urlPublica: 'https://x/api/cotizaciones/1/publico' }) const texto = textoCompartir({ ...base, folio: 'COT-3F8A1B', urlPublica: 'https://x/api/cotizaciones/1/publico' })
expect(texto).toContain('Cotización COT-3F8A1B') expect(texto).toContain('Cotización COT-3F8A1B')
expect(texto).toContain('Desarrollo: Diagonal') expect(texto).toContain('Desarrollo: Diagonal')
expect(texto).toContain('Unidad: U-101') expect(texto).toContain('Unidad: U-101')
expect(texto).toContain(`Propuesta: ${propuesta.titulo}`) expect(texto).toContain(`Apartado (hoy): ${formatoMoneda(15000)}`)
expect(texto).toContain(`Enganche: ${formatoMoneda(propuesta.enganche)}`) expect(texto).toContain(`Pago a la firma: ${formatoMoneda(65000)}`)
expect(texto).toContain(`Pago final: ${formatoMoneda(propuesta.pagoFinal)}`) expect(texto).toContain(`Mensualidades: 14 × ${formatoMoneda(75000 / 14)}`)
expect(texto).toContain(`Total del esquema: ${formatoMoneda(155000)}`)
expect(texto).toContain('Vigencia: hasta el 31 de agosto de 2026') expect(texto).toContain('Vigencia: hasta el 31 de agosto de 2026')
expect(texto).toContain('Consulta: https://x/api/cotizaciones/1/publico') expect(texto).toContain('Consulta: https://x/api/cotizaciones/1/publico')
}) })
@@ -50,15 +52,16 @@ describe('textoCompartir', () => {
expect(texto).not.toContain('Consulta:') expect(texto).not.toContain('Consulta:')
}) })
it('contado (mensualidad null) muestra Mensualidades: N/A', () => { it('escrituración diferida: lo dice en el texto', () => {
const contado = calcularPropuestas({ const diferido = calcularEsquemaFrasa({
unidad: UNIDADES_SEED[0], precio: 1000000,
acabados: true, apartado: 15000,
apartado: APARTADO_DEFAULT, numMensualidades: 14,
descuentos: { ...DESCUENTOS_DEFAULT }, escrituracionDiferida: true,
numMeses: 14, })
}).find((p) => p.tipo === 'CONTADO')! const texto = textoCompartir({ ...base, esquema: diferido })
expect(textoCompartir({ ...base, propuesta: contado })).toContain('Mensualidades: N/A') expect(texto).toContain('Escrituración 5.5%: diferida con el enganche')
expect(texto).toContain(`Pago a la firma: ${formatoMoneda(10000)}`)
}) })
}) })

View File

@@ -0,0 +1,202 @@
import { describe, expect, it } from 'vitest'
import {
AJUSTES_DEFAULT,
DESCUENTO_POR_TIPO,
TIPOS_ORDEN,
calcularOpcionCredito,
validarAjustes,
type EntradaOpcion,
type TipoCredito,
} from './opciones-credito'
/** Caso dorado del cliente: casa de $1,000,000, apartado $15,000, N=14 */
const CASO = { precioLista: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
function opcion(tipo: TipoCredito, extra: Partial<EntradaOpcion> = {}) {
return calcularOpcionCredito({ ...CASO, tipo, ajustes: AJUSTES_DEFAULT[tipo], ...extra })
}
/** Anualidad con la misma fórmula del módulo (el test calcula su esperado) */
function anualidadEsperada(monto: number, tasaAnual: number, plazoAnos: number): number {
const n = plazoAnos * 12
const i = tasaAnual / 12
return Number(((monto * i) / (1 - Math.pow(1 + i, -n))).toFixed(2))
}
describe('calcularOpcionCredito — Crédito Bancario (caso dorado)', () => {
const r = opcion('CREDITO_BANCARIO')
it('sin descuento: precio final = lista y saldo a financiar $845,000', () => {
expect(DESCUENTO_POR_TIPO.CREDITO_BANCARIO).toBe(0)
expect(r.precioFinal).toBe(1000000)
expect(r.montoDescuento).toBe(0)
expect(r.saldoAFinanciar).toBe(845000) // 1,000,000 − 155,000 del esquema
expect(r.pagoContado).toBe(0)
expect(r.montoCredito).toBe(845000)
})
it('esquema FRASA 2026 anidado: firma única $65,000 y diferible $75,000', () => {
expect(r.esquema.pagoUnicoFirma).toBe(65000) // saldo firma 10,000 + escrituración 55,000
expect(r.esquema.baseDiferible).toBe(75000)
expect(r.esquema.totalPagado).toBeCloseTo(155000, 2)
})
it('mensualidad estimada con la fórmula de anualidad (10.5% a 20 años)', () => {
const esperada = anualidadEsperada(845000, 0.105, 20)
expect(esperada).toBeGreaterThan(8000)
expect(esperada).toBeLessThan(8500) // ~8,436.xx
expect(r.mensualidadEstimada).toBe(esperada)
expect(r.interesesEstimados).toBeGreaterThan(0)
expect(r.interesesEstimados).toBe(
Number((r.mensualidadEstimada! * 12 * 20 - r.montoCredito).toFixed(2)),
)
expect(r.totalEstimado).toBe(Number((r.precioFinal + r.interesesEstimados).toFixed(2)))
})
it('ahorro vs lista 0 y esquema vigente', () => {
expect(r.ahorroVsLista).toBe(0)
expect(r.esquemaVigente).toBe(true)
})
})
describe('calcularOpcionCredito — Contado (2% de descuento)', () => {
const r = opcion('CONTADO')
it('precio final $980,000 y ahorro de $20,000', () => {
expect(r.descuento).toBe(0.02)
expect(r.montoDescuento).toBe(20000)
expect(r.precioFinal).toBe(980000)
expect(r.ahorroVsLista).toBe(20000)
})
it('saldo a financiar = round2(980,000 × 0.845) = $828,100', () => {
expect(r.saldoAFinanciar).toBe(828100)
})
it('esquema sobre el precio final: firma única $63,400', () => {
expect(r.esquema.saldoFirma).toBe(9500) // 24,500 − 15,000 apartado
expect(r.esquema.escrituracion).toBe(53900) // 5.5% de 980,000
expect(r.esquema.pagoUnicoFirma).toBe(63400) // 9,500 + 53,900
})
it('sin credito: mensualidad null, intereses 0 y total estimado = precio final', () => {
expect(r.montoCredito).toBe(0)
expect(r.pagoContado).toBe(0)
expect(r.mensualidadEstimada).toBeNull()
expect(r.interesesEstimados).toBe(0)
expect(r.totalEstimado).toBe(980000)
})
})
describe('calcularOpcionCredito — Semi Contado (1% + 50% de contado)', () => {
const r = opcion('SEMI_CONTADO')
it('precio final $990,000 y saldo a financiar $836,550', () => {
expect(r.montoDescuento).toBe(10000)
expect(r.precioFinal).toBe(990000)
expect(r.saldoAFinanciar).toBe(836550) // round2(990,000 × 0.845)
expect(r.esquemaVigente).toBe(true)
})
it('pago de contado y crédito mitad y mitad', () => {
expect(r.pctContado).toBe(0.5)
expect(r.pagoContado).toBe(418275)
expect(r.montoCredito).toBe(418275)
const esperada = anualidadEsperada(418275, 0.105, 20)
expect(r.mensualidadEstimada).toBe(esperada)
})
})
describe('calcularOpcionCredito — Crédito Infonavit-Cofinavit', () => {
it('sin descuento, plazo default 25 años y esquema vigente', () => {
const r = opcion('CREDITO_INFONAVIT_COFINAVIT')
expect(r.precioFinal).toBe(1000000)
expect(r.saldoAFinanciar).toBe(845000)
expect(r.plazoAnos).toBe(25)
expect(r.esquemaVigente).toBe(true)
const esperada = anualidadEsperada(845000, 0.105, 25)
expect(r.mensualidadEstimada).toBe(esperada)
})
})
describe('calcularOpcionCredito — saldo con los 4 tipos', () => {
it('con apartado <= 2.5% del precio final, saldo = round2(precioFinal × 0.845)', () => {
for (const tipo of TIPOS_ORDEN) {
const r = opcion(tipo)
expect(r.saldoAFinanciar).toBe(Number((r.precioFinal * 0.845).toFixed(2)))
}
})
})
describe('validarAjustes', () => {
it('rechaza tasas fuera de rango (0%..50%)', () => {
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: -0.1, plazoAnos: 20 })).toMatch(/tasa/i)
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.6, plazoAnos: 20 })).toMatch(/tasa/i)
})
it('rechaza plazos inválidos (entero 1..35)', () => {
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 0 })).toMatch(/plazo/i)
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 3.5 })).toMatch(/plazo/i)
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 36 })).toMatch(/plazo/i)
})
it('rechaza pctContado fuera de 0..1 solo para Semi Contado', () => {
expect(validarAjustes('SEMI_CONTADO', { tasaAnual: 0.105, plazoAnos: 20, pctContado: 1.2 })).toMatch(/contado/i)
expect(validarAjustes('SEMI_CONTADO', { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 })).toBeNull()
})
it('acepta ajustes válidos', () => {
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 20 })).toBeNull()
expect(validarAjustes('CONTADO', { tasaAnual: 0, plazoAnos: 1 })).toBeNull()
})
it('calcularOpcionCredito lanza Error cuando la validación falla', () => {
expect(() =>
calcularOpcionCredito({
...CASO,
tipo: 'SEMI_CONTADO',
ajustes: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 1.5 },
}),
).toThrow(/contado/i)
})
})
describe('calcularOpcionCredito — esquema no vigente', () => {
it('apartado que excede el 10% del precio final con descuento: enganche negativo', () => {
// Contado: precio final 980,000 → firma 2.5% = 24,500 y enganche 7.5% = 73,500
// Apartado 200,000: excedente 175,500 > 73,500 → enganche = 73,500 − 175,500 < 0
const r = opcion('CONTADO', { apartado: 200000 })
expect(r.precioFinal).toBe(980000)
expect(r.esquema.excedenteApartado).toBe(175500)
expect(r.esquema.enganche).toBeLessThan(0)
expect(r.esquemaVigente).toBe(false)
})
})
describe('calcularOpcionCredito — tasa cero', () => {
it('mensualidad = monto / n cuando la tasa es 0', () => {
const r = opcion('CREDITO_BANCARIO', { ajustes: { tasaAnual: 0, plazoAnos: 20 } })
const mensualidad = Number((845000 / 240).toFixed(2))
expect(r.mensualidadEstimada).toBe(mensualidad)
// Con tasa 0 los intereses son el redondeo de la mensualidad (±centavos)
expect(r.interesesEstimados).toBe(Number((mensualidad * 240 - 845000).toFixed(2)))
})
})
describe('calcularOpcionCredito — enganche adicional', () => {
it('el saldo a financiar de cada opción toma el adicional (y se acota al 90%)', () => {
const base = {
precioLista: 1000000,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: false,
ajustes: AJUSTES_DEFAULT.CREDITO_BANCARIO,
}
const r = calcularOpcionCredito({ ...base, tipo: 'CREDITO_BANCARIO', engancheAdicional: 400000 })
expect(r.saldoAFinanciar).toBe(445000)
expect(r.montoCredito).toBe(445000)
// Contado: precio final 980,000 → saldo 828,100 − 400,000 = 428,100 de contado
const contado = calcularOpcionCredito({ ...base, tipo: 'CONTADO', ajustes: AJUSTES_DEFAULT.CONTADO, engancheAdicional: 400000 })
expect(contado.saldoAFinanciar).toBe(428100)
})
})

View File

@@ -0,0 +1,194 @@
/**
* Opciones de credito sobre el esquema FRASA 2026 (Excel del cliente, 4
* propuestas): Crédito Bancario, Crédito Infonavit-Cofinavit, Semi Contado
* y Contado. Cada opcion aplica su descuento (0/0/1%/2%) al precio de lista,
* recalcula el esquema FRASA 2026 sobre el precio final y estima la
* mensualidad del credito hipotecario restante (saldo a financiar = 84.5%
* del precio final, una vez saldado el 15.5% del esquema).
*
* Módulo puro (sin UI, sin API) para probarlo a fondo.
*/
import { PCT_ENGANCHE_MAXIMO, calcularEsquemaFrasa, type EsquemaFrasaResultado } from './esquema-frasa'
export type TipoCredito =
| 'CREDITO_BANCARIO'
| 'CREDITO_INFONAVIT_COFINAVIT'
| 'SEMI_CONTADO'
| 'CONTADO'
export interface AjustesCredito {
/** Tasa anual nominal (0.105 = 10.5%) */
tasaAnual: number
/** Plazo en años (entero, 1..35) */
plazoAnos: number
/**
* Solo SEMI_CONTADO: fraccion (0..1) del saldo a financiar que el cliente
* liquida de contado a la firma; el resto va al credito hipotecario.
*/
pctContado?: number
}
/** Descuentos sobre el precio de lista definidos en el Excel del cliente */
export const DESCUENTO_POR_TIPO: Record<TipoCredito, number> = {
CREDITO_BANCARIO: 0,
CREDITO_INFONAVIT_COFINAVIT: 0,
SEMI_CONTADO: 0.01,
CONTADO: 0.02,
}
export const AJUSTES_DEFAULT: Record<TipoCredito, AjustesCredito> = {
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 },
// Valores dummy: el contado no usa credito
CONTADO: { tasaAnual: 0, plazoAnos: 1 },
}
/** Orden de presentacion de las 4 opciones (el del Excel del cliente) */
export const TIPOS_ORDEN: readonly TipoCredito[] = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
'CONTADO',
]
export const TITULO_POR_TIPO: Record<TipoCredito, string> = {
CREDITO_BANCARIO: 'Crédito Bancario',
CREDITO_INFONAVIT_COFINAVIT: 'Crédito Infonavit-Cofinavit',
SEMI_CONTADO: 'Semi Contado',
CONTADO: 'Contado',
}
const round2 = (x: number): number => Number(x.toFixed(2))
export interface EntradaOpcion {
/** Precio de lista de la unidad (sin descuento) */
precioLista: number
apartado: number
numMensualidades: number
escrituracionDiferida: boolean
/** Enganche adicional (monto absoluto, >= 0): se acota al 90% del anticipo */
engancheAdicional?: number
tipo: TipoCredito
ajustes: AjustesCredito
}
export interface ResultadoOpcionCredito {
tipo: TipoCredito
titulo: string
/** Fraccion de descuento sobre el precio de lista (0/0.01/0.02) */
descuento: number
montoDescuento: number
/** precioLista − montoDescuento (base del esquema FRASA 2026) */
precioFinal: number
/** Esquema FRASA 2026 calculado sobre el precio final (sin redondear) */
esquema: EsquemaFrasaResultado
/** precioFinal − esquema.totalPagado (lo que queda para financiar) */
saldoAFinanciar: number
/** SEMI: saldoAFinanciar * pctContado; resto 0 */
pagoContado: number
/** saldoAFinanciar − pagoContado (0 en CONTADO) */
montoCredito: number
tasaAnual: number
plazoAnos: number
pctContado?: number
/** Anualidad del credito; null en CONTADO */
mensualidadEstimada: number | null
/** mensualidad * 12 * plazoAnos − montoCredito (0 en CONTADO) */
interesesEstimados: number
/** precioFinal + interesesEstimados */
totalEstimado: number
/** === montoDescuento: lo que se ahorra vs el precio de lista */
ahorroVsLista: number
/** false si el apartado excede el 10% del precio final (enganche negativo) */
esquemaVigente: boolean
}
/** Error de validación de ajustes; null si son válidos */
export function validarAjustes(tipo: TipoCredito, a: AjustesCredito): string | null {
if (!Number.isFinite(a.tasaAnual) || a.tasaAnual < 0 || a.tasaAnual > 0.5) {
return 'La tasa anual debe estar entre 0% y 50%'
}
if (!Number.isInteger(a.plazoAnos) || a.plazoAnos < 1 || a.plazoAnos > 35) {
return 'El plazo debe ser un número entero de 1 a 35 años'
}
if (tipo === 'SEMI_CONTADO' && a.pctContado !== undefined) {
if (!Number.isFinite(a.pctContado) || a.pctContado < 0 || a.pctContado > 1) {
return 'El porcentaje de contado debe estar entre 0% y 100%'
}
}
return null
}
/** Anualidad (mensualidad) de un crédito: monto * i / (1 − (1+i)^−n) */
function mensualidadCredito(monto: number, tasaAnual: number, plazoAnos: number): number {
const n = plazoAnos * 12
if (tasaAnual === 0) return monto / n
const i = tasaAnual / 12
return (monto * i) / (1 - Math.pow(1 + i, -n))
}
export function calcularOpcionCredito(e: EntradaOpcion): ResultadoOpcionCredito {
const errorAjustes = validarAjustes(e.tipo, e.ajustes)
if (errorAjustes) throw new Error(errorAjustes)
const descuento = DESCUENTO_POR_TIPO[e.tipo]
const montoDescuento = round2(e.precioLista * descuento)
const precioFinal = e.precioLista - montoDescuento
const esquemaBase = calcularEsquemaFrasa({
precio: precioFinal,
apartado: e.apartado,
numMensualidades: e.numMensualidades,
escrituracionDiferida: e.escrituracionDiferida,
})
// El adicional se acota igual que en el hook: el anticipo total no puede
// superar el 90% del precio final de la opción
const adicionalSolicitado = Math.max(0, e.engancheAdicional ?? 0)
const adicionalEfectivo = Math.min(adicionalSolicitado, round2(precioFinal * PCT_ENGANCHE_MAXIMO - esquemaBase.totalPagado))
const esquema =
adicionalEfectivo > 0
? calcularEsquemaFrasa({
precio: precioFinal,
apartado: e.apartado,
numMensualidades: e.numMensualidades,
escrituracionDiferida: e.escrituracionDiferida,
engancheAdicional: adicionalEfectivo,
})
: esquemaBase
const saldoAFinanciar = round2(esquema.saldoAFinanciar)
const esContado = e.tipo === 'CONTADO'
const pctContado = e.tipo === 'SEMI_CONTADO' ? (e.ajustes.pctContado ?? 0) : 0
const pagoContado = esContado ? 0 : round2(saldoAFinanciar * pctContado)
const montoCredito = esContado ? 0 : round2(saldoAFinanciar - pagoContado)
const mensualidadEstimada = esContado
? null
: round2(mensualidadCredito(montoCredito, e.ajustes.tasaAnual, e.ajustes.plazoAnos))
const interesesEstimados = esContado
? 0
: round2(mensualidadEstimada! * 12 * e.ajustes.plazoAnos - montoCredito)
const totalEstimado = round2(precioFinal + interesesEstimados)
return {
tipo: e.tipo,
titulo: TITULO_POR_TIPO[e.tipo],
descuento,
montoDescuento,
precioFinal,
esquema,
saldoAFinanciar,
pagoContado,
montoCredito,
tasaAnual: e.ajustes.tasaAnual,
plazoAnos: e.ajustes.plazoAnos,
...(e.tipo === 'SEMI_CONTADO' ? { pctContado: e.ajustes.pctContado } : {}),
mensualidadEstimada,
interesesEstimados,
totalEstimado,
ahorroVsLista: montoDescuento,
esquemaVigente: esquema.enganche >= 0,
}
}

View File

@@ -1,26 +1,28 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf' import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf'
import { calcularPropuestas, APARTADO_DEFAULT, DESCUENTOS_DEFAULT } from './calculations' import { calcularEsquemaFrasa } from './esquema-frasa'
import { AJUSTES_DEFAULT, calcularOpcionCredito } from './opciones-credito'
import { UNIDADES_SEED } from '../data/unidades' import { UNIDADES_SEED } from '../data/unidades'
import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos' import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters' import { formatoMoneda } from './formatters'
/** Esquema único FRASA 2026: caso dorado $1,000,000 / apartado $15,000 / N=14 */
const esquemaDorado = calcularEsquemaFrasa({
precio: 1000000,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: false,
})
describe('crearDocumentoPdf', () => { describe('crearDocumentoPdf', () => {
it('construye el documento ANEXO A sin errores y con el nombre correcto', () => { it('construye el documento ANEXO A sin errores y con el nombre correcto', () => {
const unidad = UNIDADES_SEED[0] const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})
const { doc, nombreArchivo } = crearDocumentoPdf({ const { doc, nombreArchivo } = crearDocumentoPdf({
unidad, unidad,
acabados: true, acabados: true,
apartado: APARTADO_DEFAULT, apartado: 15000,
precioLista: unidad.precioConAcabados, precioLista: unidad.precioConAcabados,
propuestas, esquema: { ...esquemaDorado, precio: unidad.precioConAcabados },
cliente: { nombre: 'Juan Pérez' }, cliente: { nombre: 'Juan Pérez' },
}) })
expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/) expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/)
@@ -30,24 +32,38 @@ describe('crearDocumentoPdf', () => {
expect(texto.length).toBeGreaterThan(0) expect(texto.length).toBeGreaterThan(0)
}) })
it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => { it('imprime la línea de tiempo del esquema (hoy / firma / mensualidades)', () => {
const unidad = UNIDADES_SEED[0] const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({ const { doc } = crearDocumentoPdf({
unidad, unidad,
acabados: true, acabados: true,
apartado: APARTADO_DEFAULT, apartado: 15000,
descuentos: { ...DESCUENTOS_DEFAULT }, precioLista: unidad.precioConAcabados,
numMeses: 14, esquema: esquemaDorado,
cliente: { nombre: 'Juan Pérez' },
}) })
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
expect(pdf).toContain('ESQUEMA FRASA 2026')
// jspdf escapa los paréntesis en el stream: se valida el texto sin ellos
expect(pdf).toContain('APARTADO')
expect(pdf).toContain('ENGANCHE DE FIRMA')
expect(pdf).toContain('GASTOS DE ESCRITURACIÓN')
expect(pdf).toContain(formatoMoneda(esquemaDorado.pagoUnicoFirma))
expect(pdf).toContain('14 × ')
expect(pdf).toContain(formatoMoneda(esquemaDorado.totalPagado))
})
it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => {
const unidad = UNIDADES_SEED[0]
// PNG 1x1 válido; en la app lo genera la librería qrcode // PNG 1x1 válido; en la app lo genera la librería qrcode
const png1x1 = const png1x1 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
const { doc } = crearDocumentoPdf({ const { doc } = crearDocumentoPdf({
unidad, unidad,
acabados: true, acabados: true,
apartado: APARTADO_DEFAULT, apartado: 15000,
precioLista: unidad.precioConAcabados, precioLista: unidad.precioConAcabados,
propuestas, esquema: esquemaDorado,
cliente: { nombre: 'Juan Pérez' }, cliente: { nombre: 'Juan Pérez' },
folio: 'COT-3F8A1B', folio: 'COT-3F8A1B',
qrDataUrl: png1x1, qrDataUrl: png1x1,
@@ -56,21 +72,70 @@ describe('crearDocumentoPdf', () => {
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1) expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
}) })
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => { it('imprime la sección de financiamiento elegido cuando viene la opción', () => {
const unidad = UNIDADES_SEED[0] const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({ const opcion = calcularOpcionCredito({
unidad, precioLista: 1000000,
acabados: true, apartado: 15000,
apartado: APARTADO_DEFAULT, numMensualidades: 14,
descuentos: { ...DESCUENTOS_DEFAULT }, escrituracionDiferida: false,
numMeses: 14, tipo: 'CONTADO',
ajustes: AJUSTES_DEFAULT.CONTADO,
}) })
const { doc } = crearDocumentoPdf({ const { doc } = crearDocumentoPdf({
unidad, unidad,
acabados: true, acabados: true,
apartado: APARTADO_DEFAULT, apartado: 15000,
precioLista: 1000000,
esquema: esquemaDorado,
opcion,
cliente: { nombre: 'Juan Pérez' },
})
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
expect(pdf).toContain('PROPUESTA DE FINANCIAMIENTO ELEGIDA')
expect(pdf).toContain('CONTADO')
// Precio final con descuento, pago de contado y ahorro vs lista
expect(pdf).toContain(formatoMoneda(opcion.precioFinal))
expect(pdf).toContain(formatoMoneda(opcion.saldoAFinanciar))
expect(pdf).toContain(formatoMoneda(opcion.ahorroVsLista))
expect(pdf).toContain('NO CONSTITUYE PREAPROBACIÓN')
})
it('imprime tasa y plazo cuando la opción elegida usa crédito', () => {
const unidad = UNIDADES_SEED[0]
const opcion = calcularOpcionCredito({
precioLista: 1000000,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: false,
tipo: 'CREDITO_BANCARIO',
ajustes: AJUSTES_DEFAULT.CREDITO_BANCARIO,
})
const { doc } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: 15000,
precioLista: 1000000,
esquema: esquemaDorado,
opcion,
cliente: { nombre: 'Juan Pérez' },
})
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
expect(pdf).toContain('CRÉDITO BANCARIO')
expect(pdf).toContain('MENSUALIDAD ESTIMADA')
expect(pdf).toContain(formatoMoneda(opcion.mensualidadEstimada!))
expect(pdf).toContain('INTERESES ESTIMADOS')
expect(pdf).toContain('10.5% A 20 AÑOS')
})
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
const unidad = UNIDADES_SEED[0]
const { doc } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: 15000,
precioLista: unidad.precioConAcabados, precioLista: unidad.precioConAcabados,
propuestas, esquema: esquemaDorado,
cliente: { nombre: 'Juan Pérez' }, cliente: { nombre: 'Juan Pérez' },
folio: 'CTE-LOCAL-1757568000000', folio: 'CTE-LOCAL-1757568000000',
}) })

View File

@@ -1,7 +1,9 @@
import { jsPDF } from 'jspdf' import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable' import autoTable from 'jspdf-autotable'
import type { Unidad, Cliente, ParametrosDesarrollo } from '../types' import type { Unidad, Cliente, ParametrosDesarrollo } from '../types'
import type { PropuestaResultado } from './calculations' import type { EsquemaFrasaResultado } from './esquema-frasa'
import { PCT_ENGANCHE, PCT_ENGANCHE_FIRMA, PCT_ESCRITURACION } from './esquema-frasa'
import type { ResultadoOpcionCredito } from './opciones-credito'
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters' import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos' import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos'
@@ -10,8 +12,10 @@ export interface PdfInput {
acabados: boolean acabados: boolean
apartado: number apartado: number
precioLista: number precioLista: number
/** Propuestas a imprimir; desde la app llega SOLO la propuesta elegida */ /** Esquema único FRASA 2026 a imprimir */
propuestas: PropuestaResultado[] esquema: EsquemaFrasaResultado
/** Opción de financiamiento elegida por el vendedor (sección de financiamiento) */
opcion?: ResultadoOpcionCredito
cliente: Cliente cliente: Cliente
/** Parámetros del desarrollo; por defecto DIAGONAL */ /** Parámetros del desarrollo; por defecto DIAGONAL */
desarrollo?: ParametrosDesarrollo desarrollo?: ParametrosDesarrollo
@@ -30,7 +34,7 @@ export const textoCuotaMantenimiento = (cuota: number | null): string =>
cuota === null ? 'PENDIENTE' : formatoMoneda(cuota) cuota === null ? 'PENDIENTE' : formatoMoneda(cuota)
const DISCLAIMER = const DISCLAIMER =
'ESTA COTIZACION NO INCLUYE GASTOS DE ESCRITURACION. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL MONTO TOTAL DE ENGANCHE INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.' 'ESTA COTIZACION INCLUYE LOS GASTOS DE ESCRITURACION (5.5% DEL PRECIO), PAGADEROS A LA FIRMA O DIFERIDOS CON EL ENGANCHE SEGUN LO INDICADO. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL ENGANCHE DE FIRMA (2.5%) INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO; SI EL APARTADO LO EXCEDE, EL EXCEDENTE SE ACREDITA AL ENGANCHE.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.'
const CUENTA_DEPOSITO = const CUENTA_DEPOSITO =
'PARA CUENTAS EN MEXICO:\nTITULAR: FRASA DESARROLLOS, S.A.P.I. DE C.V.\n\nBBVA BANCOMER PAGO SOLO EN PESOS\nCUENTA: 0111525921 CLABE: 012028001115259210\n\nBBVA BANCOMER PAGO SOLO EN DOLARES\nCUENTA: 0111571737 CLABE: 012028001115717370\n\nPARA RECIBIR TRANSFERENCIAS DESDE EL EXTRANJERO:\nBANCO DEL BENEFICIARIO: BBVA Bancomer.\nSWIFT/BIC CODE: BCMRMXMMXXX\nBENEFICIARIO: FRASA DESARROLLOS, S.A.P.I. DE C.V.\nCUENTA: 01111571737\n(CLABE 18 DÍGITOS): 012 028 001 115 717 370\nSOLO PAGO EN DOLARES' 'PARA CUENTAS EN MEXICO:\nTITULAR: FRASA DESARROLLOS, S.A.P.I. DE C.V.\n\nBBVA BANCOMER PAGO SOLO EN PESOS\nCUENTA: 0111525921 CLABE: 012028001115259210\n\nBBVA BANCOMER PAGO SOLO EN DOLARES\nCUENTA: 0111571737 CLABE: 012028001115717370\n\nPARA RECIBIR TRANSFERENCIAS DESDE EL EXTRANJERO:\nBANCO DEL BENEFICIARIO: BBVA Bancomer.\nSWIFT/BIC CODE: BCMRMXMMXXX\nBENEFICIARIO: FRASA DESARROLLOS, S.A.P.I. DE C.V.\nCUENTA: 01111571737\n(CLABE 18 DÍGITOS): 012 028 001 115 717 370\nSOLO PAGO EN DOLARES'
@@ -50,7 +54,7 @@ export interface DocumentoPdf {
} }
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */ /** Construye el documento PDF "ANEXO A" (sin descargarlo) */
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf { export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esquema, opcion, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT] const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT]
const doc = new jsPDF() const doc = new jsPDF()
const hoy = new Date() const hoy = new Date()
@@ -128,52 +132,107 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
}) })
y = finalY(doc) + 8 y = finalY(doc) + 8
// 3. Propuesta general — una tabla por propuesta // 3. Esquema único FRASA 2026 — una sola tabla con la línea de tiempo
doc.setFontSize(12) doc.setFontSize(12)
doc.setFont('helvetica', 'bold') doc.setFont('helvetica', 'bold')
doc.text('PROPUESTA GENERAL', MARGEN, y) doc.text('PROPUESTA GENERAL — ESQUEMA FRASA 2026', MARGEN, y)
y += 4 y += 4
propuestas.forEach((p, i) => { autoTable(doc, {
const mensualidades = p.notaMensualidades startY: y,
? { pagos: `— (${p.notaMensualidades})`, total: '—' } margin: { left: MARGEN, right: MARGEN },
: p.mensualidad !== null && p.mensualidadesTotal !== null styles: { fontSize: 8 },
? { headStyles: { fillColor: [37, 99, 235] },
pagos: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`, head: [['CONCEPTO', '%', 'PAGOS MENSUALES', 'TOTAL']],
total: formatoMoneda(p.mensualidadesTotal), body: [
} ['APARTADO (HOY)', '', '', formatoMoneda(apartado)],
: { pagos: 'N/A', total: 'N/A' } [
`ENGANCHE DE FIRMA (${formatoPorcentaje(PCT_ENGANCHE_FIRMA)})`,
formatoPorcentaje(PCT_ENGANCHE_FIRMA),
'',
formatoMoneda(esquema.engancheFirmaTotal),
],
[
`SALDO A LA FIRMA (firma − apartado${esquema.excedenteApartado > 0 ? ' + excedente al enganche' : ''})`,
'',
'',
formatoMoneda(esquema.saldoFirma),
],
[
`GASTOS DE ESCRITURACIÓN (${formatoPorcentaje(PCT_ESCRITURACION)}) — ${esquema.escrituracionDiferida ? 'DIFERIDOS CON EL ENGANCHE' : 'PAGADOS A LA FIRMA'}`,
formatoPorcentaje(PCT_ESCRITURACION),
'',
formatoMoneda(esquema.escrituracion),
],
['PAGO A LA FIRMA', '', '', formatoMoneda(esquema.pagoUnicoFirma)],
[
`ENGANCHE (${formatoPorcentaje(PCT_ENGANCHE)}) DIFERIBLE`,
formatoPorcentaje(PCT_ENGANCHE),
'',
formatoMoneda(esquema.enganche),
],
[
'MENSUALIDADES',
'',
`${esquema.numMensualidades} × ${formatoMoneda(esquema.mensualidad)}`,
formatoMoneda(esquema.baseDiferible),
],
['TOTAL DEL ESQUEMA', '', '', formatoMoneda(esquema.totalPagado)],
...(esquema.engancheAdicional > 0
? [['ENGANCHE ADICIONAL (ANTICIPO EXTRA)', '', '', formatoMoneda(esquema.engancheAdicional)]]
: []),
['SALDO A FINANCIAR', '', '', formatoMoneda(esquema.saldoAFinanciar)],
],
})
y = finalY(doc) + 6
y += 2
// Con una sola propuesta (la elegida por el vendedor) no se numera // 3.1 Propuesta de financiamiento elegida (una de las 4 opciones de crédito)
const encabezado = if (opcion) {
propuestas.length === 1 doc.setFontSize(12)
? `PROPUESTA COMERCIAL — ${p.titulo.toUpperCase()}` doc.setFont('helvetica', 'bold')
: `PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}` doc.text(`PROPUESTA DE FINANCIAMIENTO ELEGIDA — ${opcion.titulo.toUpperCase()}`, MARGEN, y)
y += 4
const renglones: string[][] = [
['PRECIO FINAL C/DESC.', formatoMoneda(opcion.precioFinal)],
...(opcion.montoDescuento > 0
? [[`DESCUENTO (${formatoPorcentaje(opcion.descuento)})`, formatoMoneda(opcion.montoDescuento)]]
: []),
...(opcion.tipo === 'CONTADO'
? [
['PAGO DE CONTADO', formatoMoneda(opcion.saldoAFinanciar)],
['AHORRO VS PRECIO DE LISTA', formatoMoneda(opcion.ahorroVsLista)],
]
: [
...(opcion.tipo === 'SEMI_CONTADO'
? [
['PAGO DE CONTADO A LA FIRMA', formatoMoneda(opcion.pagoContado)],
['SALDO RESTANTE A FINANCIAR', formatoMoneda(opcion.montoCredito)],
]
: [['SALDO A FINANCIAR', formatoMoneda(opcion.saldoAFinanciar)]]),
['MONTO DEL CRÉDITO', formatoMoneda(opcion.montoCredito)],
['TASA Y PLAZO', `${(opcion.tasaAnual * 100).toLocaleString('es-MX')}% A ${opcion.plazoAnos} AÑOS`],
['MENSUALIDAD ESTIMADA', formatoMoneda(opcion.mensualidadEstimada ?? 0)],
['INTERESES ESTIMADOS', formatoMoneda(opcion.interesesEstimados)],
]),
['TOTAL ESTIMADO', formatoMoneda(opcion.totalEstimado)],
]
autoTable(doc, { autoTable(doc, {
startY: y, startY: y,
margin: { left: MARGEN, right: MARGEN }, margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8 }, styles: { fontSize: 8 },
headStyles: { fillColor: [37, 99, 235] }, headStyles: { fillColor: [37, 99, 235] },
head: [[encabezado, '%', 'PAGOS MENSUALES', 'TOTAL']], head: [['CONCEPTO', 'TOTAL']],
body: [ body: renglones,
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
['PRECIO FINAL C/DESC', '', '', formatoMoneda(p.precioFinal)],
['ENGANCHE', '', '', formatoMoneda(p.enganche)],
['MENSUALIDADES', '', mensualidades.pagos, mensualidades.total],
['PAGO FINAL', '', '', formatoMoneda(p.pagoFinal)],
],
}) })
y = finalY(doc) + 6 y = finalY(doc) + 4
parrafo(
// Salto de página antes de la siguiente tabla si no cabe 'Mensualidad estimada sujeta a aprobación del banco; no constituye preaprobación.',
if (y > 240 && i < propuestas.length - 1) { )
doc.addPage() y += 2
y = MARGEN }
}
})
y += 2
// 4. ¿Qué sigue? // 4. ¿Qué sigue?
parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 }) parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 })

View File

@@ -9,7 +9,8 @@ import { INVENTARIO_SEED } from '../data/unidades'
/** /**
* Reglas configurables por el admin en la página del cotizador: * Reglas configurables por el admin en la página del cotizador:
* - un desarrollo con activoEnCotizador: false no se ofrece * - un desarrollo con activoEnCotizador: false no se ofrece
* - una propuesta desactivada no se muestra ni se puede elegir * - una propuesta desactivada ya NO afecta: el esquema único FRASA 2026 no
* usa las 4 propuestas (sigue cotizando igual)
*/ */
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')! const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
@@ -60,7 +61,7 @@ afterEach(() => {
}) })
describe('CotizadorPage con reglas del admin', () => { describe('CotizadorPage con reglas del admin', () => {
it('no ofrece el desarrollo desactivado ni la propuesta desactivada', async () => { it('no ofrece el desarrollo desactivado; una propuesta desactivada ya no bloquea el esquema', async () => {
render( render(
<MemoryRouter> <MemoryRouter>
<CotizadorPage /> <CotizadorPage />
@@ -74,16 +75,18 @@ describe('CotizadorPage con reglas del admin', () => {
expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy() expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy()
expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy() expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy()
// Cargar las 4 propuestas de DIAGONAL: solo las 3 vigentes // Cargar la unidad de DIAGONAL (con la propuesta Contado desactivada por
// el admin): el esquema único se calcula igual, sin propuestas
fireEvent.focus(inputUnidad) fireEvent.focus(inputUnidad)
fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } }) fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } })
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) })) fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) }))
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Crédito Bancario')).toBeTruthy() expect(screen.getByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
}) })
expect(screen.getByText('Crédito Infonavit-Cofinavit')).toBeTruthy() // La línea de tiempo del esquema (Hoy / A la firma / Después)
expect(screen.getByText('Semi Contado')).toBeTruthy() expect(screen.getByText('Hoy')).toBeTruthy()
expect(screen.queryByText('Contado')).toBeNull() expect(screen.getByText('A la firma')).toBeTruthy()
expect(screen.getByText('Después')).toBeTruthy()
}) })
}) })

View File

@@ -4,11 +4,12 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import CotizadorPage from './CotizadorPage' import CotizadorPage from './CotizadorPage'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
import { mensualidadesParaUnidad } from '../hooks/useCotizacion'
/** /**
* Smoke del cierre de la cotización: * Smoke del cierre de la cotización con el esquema único FRASA 2026:
* - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF * - barra sticky con resumen del esquema + Compartir/Guardar/Generar PDF
* - preselección por defecto de la propuesta de menor total * - panel único (línea de tiempo Hoy → Firma → Mensualidades)
* - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-* * - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-*
*/ */
const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')! const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')!
@@ -35,7 +36,7 @@ afterEach(() => {
}) })
describe('CotizadorPage — barra sticky y guardado local', () => { describe('CotizadorPage — barra sticky y guardado local', () => {
it('preselecciona la propuesta más barata y guarda local con folio CTE-LOCAL cuando el API no responde', async () => { it('muestra el esquema único y guarda local con folio CTE-LOCAL cuando el API no responde', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input) const url = String(input)
const metodo = init?.method ?? 'GET' const metodo = init?.method ?? 'GET'
@@ -58,7 +59,7 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy() expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy()
expect(screen.getByText(/Selecciona una unidad para elegir propuesta/)).toBeTruthy() expect(screen.getByText(/Selecciona una unidad para ver el esquema de cotización\./)).toBeTruthy()
// Elegir una unidad del inventario // Elegir una unidad del inventario
const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement
@@ -66,12 +67,12 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
fireEvent.change(inputUnidad, { target: { value: unidad.codigo } }) fireEvent.change(inputUnidad, { target: { value: unidad.codigo } })
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) })) fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) }))
// Preselección por defecto: la propuesta de menor total pagado // Panel único del esquema: línea de tiempo y conceptos
const selectPropuesta = (await screen.findByLabelText( expect(await screen.findByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
'Cambiar propuesta elegida', expect(screen.getByText('Hoy')).toBeTruthy()
)) as HTMLSelectElement expect(screen.getByText('A la firma')).toBeTruthy()
await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata // El tope de mensualidades son los meses que faltan para la entrega
expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy() expect(screen.getByRole('option', { name: `${mensualidadesParaUnidad(unidad)} mensualidades` })).toBeTruthy()
// Guardar con el servidor caído → folio local persistido en el dispositivo // Guardar con el servidor caído → folio local persistido en el dispositivo
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), { fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
@@ -82,10 +83,20 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
await waitFor(() => { await waitFor(() => {
const locales = JSON.parse( const locales = JSON.parse(
almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]', almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]',
) as { folio?: string; clienteNombre?: string }[] ) as {
folio?: string
clienteNombre?: string
esquema?: string
tipoPropuesta?: string
saldoFirma?: number | null
}[]
expect(locales).toHaveLength(1) expect(locales).toHaveLength(1)
expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/) expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/)
expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local') expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local')
// La instantánea local ya vive en el esquema único
expect(locales[0]?.esquema).toBe('FRASA_2026')
expect(locales[0]?.tipoPropuesta).toBe('FRASA_2026')
expect(locales[0]?.saldoFirma).not.toBeNull()
}) })
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy() expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
}) })
@@ -100,12 +111,17 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
clienteNombre: 'Cliente Antiguo', clienteNombre: 'Cliente Antiguo',
clienteTelefono: null, clienteTelefono: null,
clienteEmail: null, clienteEmail: null,
esquema: 'FRASA_2026',
tipoPropuesta: 'BANCARIO', tipoPropuesta: 'BANCARIO',
acabados: false, acabados: false,
apartado: 20000, apartado: 20000,
descuento: 0, descuento: 0,
precioLista: vendida.precioConAcabados, precioLista: vendida.precioConAcabados,
precioFinal: 0, precioFinal: 0,
saldoFirma: null,
engancheDiferible: null,
escrituracion: null,
escrituracionDiferida: false,
enganche: 0, enganche: 0,
numMensualidades: 12, numMensualidades: 12,
mensualidad: 1000, mensualidad: 1000,
@@ -139,6 +155,8 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
const alerta = await screen.findByRole('alert') const alerta = await screen.findByRole('alert')
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`) expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`)
expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy() expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
// Es una cotización del esquema anterior (legacy): lo dice al retomarla
expect(screen.getByText(/Usa el esquema anterior \(BANCARIO\)/)).toBeTruthy()
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) { for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true) expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
} }

View File

@@ -1,18 +1,18 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { useUnidades } from '../hooks/useUnidades' import { useUnidades } from '../hooks/useUnidades'
import { useCotizacion } from '../hooks/useCotizacion' import { useCotizacion, mensualidadesParaUnidad } from '../hooks/useCotizacion'
import { MENSUALIDADES_MAX } from '../lib/esquema-frasa'
import DarkModeToggle from '../components/DarkModeToggle' import DarkModeToggle from '../components/DarkModeToggle'
import { SkeletonList } from "../components/Skeleton" import { SkeletonList } from "../components/Skeleton"
import toast from "react-hot-toast" import toast from "react-hot-toast"
import { UnitSelector } from '../components/UnitSelector' import { UnitSelector } from '../components/UnitSelector'
import { UnitInfoCard } from '../components/UnitInfoCard' import { UnitInfoCard } from '../components/UnitInfoCard'
import { ProposalCard } from '../components/ProposalCard' import { EsquemaPanel } from '../components/EsquemaPanel'
import { ComparadorPropuestas } from '../components/ComparadorPropuestas' import { OpcionesCredito } from '../components/OpcionesCredito'
import { ComparadorUnidades } from '../components/ComparadorUnidades'
import { MisCotizaciones } from '../components/MisCotizaciones' import { MisCotizaciones } from '../components/MisCotizaciones'
import { ModalCliente } from '../components/ModalCliente' import { ModalCliente } from '../components/ModalCliente'
import { BuscadorCliente } from '../components/BuscadorCliente' import { BuscadorCliente } from '../components/BuscadorCliente'
@@ -21,15 +21,20 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia' import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio' import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir' import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations' import {
import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas' AJUSTES_DEFAULT,
TIPOS_ORDEN,
calcularOpcionCredito,
type AjustesCredito,
type TipoCredito,
} from '../lib/opciones-credito'
import { desarrolloEfectivo, idsCotizables } from '../lib/reglas'
import { import {
getMisCotizaciones, getMisCotizaciones,
getCotizacion,
guardarCotizacion, guardarCotizacion,
urlPublicaCotizacion, urlPublicaCotizacion,
ErrorApiCotizacion, ErrorApiCotizacion,
TIPO_A_API,
TIPO_DE_API,
type CotizacionGuardada, type CotizacionGuardada,
} from '../services/api' } from '../services/api'
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db' import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
@@ -38,6 +43,10 @@ import type { Cliente, Unidad } from '../types'
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */ /** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
/** Una cotización guardada ya vive en el esquema único (filas nuevas) */
const esEsquemaNuevo = (c: CotizacionGuardada): boolean =>
c.esquema === 'FRASA_2026' && c.saldoFirma != null
const esquemaCliente = z.object({ const esquemaCliente = z.object({
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'), nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(), email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(),
@@ -69,17 +78,16 @@ export default function CotizadorPage() {
apartado, apartado,
setApartado, setApartado,
apartadoEfectivo, apartadoEfectivo,
enganchePct,
setEnganchePct,
errorEnganche,
propuestaIndex,
setPropuestaIndex,
precioLista,
numMensualidades, numMensualidades,
setNumMensualidades, setNumMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
engancheAdicional,
setEngancheAdicional,
engancheAdicionalMaximo,
precioLista,
resultado,
numMeses, numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar, restaurar,
nueva, nueva,
} = cotizacion } = cotizacion
@@ -103,6 +111,9 @@ export default function CotizadorPage() {
// ligado a la cotización al guardar; la unidad se preselecciona al cargar // ligado a la cotización al guardar; la unidad se preselecciona al cargar
const [oportunidadId, setOportunidadId] = useState<string | null>(null) const [oportunidadId, setOportunidadId] = useState<string | null>(null)
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null) const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
// Retomar una cotización llegada por URL (/quote/?cotizacion=<id>, desde la
// búsqueda global): se aplica cuando termina de cargar inventario+reglas
const [cotizacionPendiente, setCotizacionPendiente] = useState<string | null>(null)
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al // Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
// primero disponible para no dejar el cotizador en un desarrollo muerto. // primero disponible para no dejar el cotizador en un desarrollo muerto.
@@ -117,11 +128,6 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [desarrollos, desarrollo]) }, [desarrollos, desarrollo])
// La propuesta elegida dejó de estar disponible (la desactivó el admin)
useEffect(() => {
if (avisoPropuestaDesactivada !== null) setAviso(avisoPropuestaDesactivada)
}, [avisoPropuestaDesactivada])
useEffect(() => { useEffect(() => {
let vivo = true let vivo = true
/** Combina servidor + locales, sin duplicados, más recientes primero */ /** Combina servidor + locales, sin duplicados, más recientes primero */
@@ -167,6 +173,40 @@ export default function CotizadorPage() {
const nombreCliente = watch('nombre') ?? '' const nombreCliente = watch('nombre') ?? ''
// 4 opciones de crédito sobre el esquema FRASA 2026 (saldo 84.5% a financiar)
const [ajustesCredito, setAjustesCredito] = useState<Record<TipoCredito, AjustesCredito>>(() =>
structuredClone(AJUSTES_DEFAULT),
)
const [opcionElegida, setOpcionElegida] = useState<TipoCredito>('CREDITO_BANCARIO')
const opcionesCredito = useMemo(() => {
// Sin unidad no hay opciones que calcular (la sección no se renderiza)
if (!Number.isFinite(precioLista) || precioLista <= 0) return []
const calcular = (ajustes: Record<TipoCredito, AjustesCredito>) =>
TIPOS_ORDEN.map((tipo) =>
calcularOpcionCredito({
precioLista,
apartado: apartadoEfectivo,
numMensualidades,
escrituracionDiferida,
engancheAdicional: resultado?.engancheAdicional ?? 0,
tipo,
ajustes: ajustes[tipo],
}),
)
try {
return calcular(ajustesCredito)
} catch {
// Ajustes inválidos (estado restaurado corrupto, carrera de inputs):
// se recalcula con los defaults para no romper la página
try {
return calcular(AJUSTES_DEFAULT)
} catch {
return []
}
}
}, [precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida, ajustesCredito, resultado])
// Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto, // Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
// desarrollo, unidad y el id de la oportunidad a vincular al guardar // desarrollo, unidad y el id de la oportunidad a vincular al guardar
useEffect(() => { useEffect(() => {
@@ -189,6 +229,8 @@ export default function CotizadorPage() {
} }
if (unidadParam) setUnidadPendiente(unidadParam) if (unidadParam) setUnidadPendiente(unidadParam)
if (opId) setOportunidadId(opId) if (opId) setOportunidadId(opId)
const cotizacionParam = qs.get('cotizacion')
if (cotizacionParam) setCotizacionPendiente(cotizacionParam)
// Solo al montar: es el punto de entrada desde Mi día // Solo al montar: es el punto de entrada desde Mi día
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
@@ -221,33 +263,23 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [unidades, cargando, unidadPendiente]) }, [unidades, cargando, unidadPendiente])
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo // Retoma la cotización pedida por URL cuando el inventario ya cargó
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))] useEffect(() => {
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer if (cotizacionPendiente === null || cargando) return
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null const id = cotizacionPendiente
setCotizacionPendiente(null)
void getCotizacion(id)
.then((c) => {
if (c) handleRetomar(c)
else setAviso(`No se encontró la cotización entre las tuyas (${id}).`)
})
.catch(() => setAviso('No se pudo cargar la cotización de la liga.'))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cotizacionPendiente, cargando, unidades])
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones // Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
const unidadNoDisponible = unidad !== null && !esDisponible(unidad) const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !guardando
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
// más barata; la elección manual del vendedor se respeta hasta el siguiente
// cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada.
const unidadPrevia = useRef<string | null | undefined>(undefined)
const saltarAuto = useRef(false)
useEffect(() => {
const codigo = unidad?.codigo ?? null
const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo
unidadPrevia.current = codigo
if (!cambioUnidad || saltarAuto.current) {
saltarAuto.current = false
return
}
const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo))
const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
if (indiceMenor >= 0) setPropuestaIndex(indiceMenor)
}, [unidad?.codigo, propuestas, apartadoEfectivo])
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */ /** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-') const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
@@ -255,24 +287,32 @@ export default function CotizadorPage() {
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */ /** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => { const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
const timestamp = Date.now() const timestamp = Date.now()
if (!unidad || !resultado) throw new Error('No hay esquema para guardar localmente')
return { return {
id: `local-${timestamp}`, id: `local-${timestamp}`,
folio: `CTE-LOCAL-${timestamp}`, folio: `CTE-LOCAL-${timestamp}`,
unidadCodigo: unidad!.codigo, unidadCodigo: unidad.codigo,
desarrollo, desarrollo,
clienteNombre: cliente.nombre, clienteNombre: cliente.nombre,
clienteTelefono: cliente.telefono ?? null, clienteTelefono: cliente.telefono ?? null,
clienteEmail: cliente.email ?? null, clienteEmail: cliente.email ?? null,
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo], esquema: 'FRASA_2026',
tipoPropuesta: 'FRASA_2026',
acabados, acabados,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento, descuento: 0,
precioLista, precioLista,
precioFinal: propuestaActiva.precioFinal, precioFinal: precioLista,
enganche: propuestaActiva.enganche, saldoFirma: resultado.saldoFirma,
numMensualidades: propuestaActiva.numMensualidades ?? 0, engancheDiferible: resultado.enganche,
mensualidad: propuestaActiva.mensualidad, escrituracion: resultado.escrituracion,
pagoFinal: propuestaActiva.pagoFinal, escrituracionDiferida,
engancheAdicional: resultado.engancheAdicional,
enganche: resultado.enganche,
numMensualidades,
mensualidad: resultado.mensualidad,
pagoFinal: resultado.pagoUnicoFirma,
opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
createdAt: new Date(timestamp).toISOString(), createdAt: new Date(timestamp).toISOString(),
} }
} }
@@ -285,19 +325,19 @@ export default function CotizadorPage() {
* devuelve null: se avisa y no se guarda nada. * devuelve null: se avisa y no se guarda nada.
*/ */
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => { const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null if (!unidad || !resultado) return null
try { try {
const guardada = await guardarCotizacion({ const guardada = await guardarCotizacion({
unidadCodigo: unidad.codigo, unidadCodigo: unidad.codigo,
clienteNombre: cliente.nombre, clienteNombre: cliente.nombre,
...(cliente.email ? { clienteEmail: cliente.email } : {}), ...(cliente.email ? { clienteEmail: cliente.email } : {}),
...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}), ...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}),
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
acabados, acabados,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento, numMensualidades,
...(numMeses !== null ? { numMensualidades: numMeses } : {}), escrituracionDiferida,
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}), engancheAdicional: resultado.engancheAdicional,
opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
...(oportunidadId !== null ? { oportunidadId } : {}), ...(oportunidadId !== null ? { oportunidadId } : {}),
}) })
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) } const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
@@ -324,7 +364,7 @@ export default function CotizadorPage() {
} }
const generarPdfConCliente = async (cliente: Cliente) => { const generarPdfConCliente = async (cliente: Cliente) => {
if (!unidad) return if (!unidad || !resultado) return
setGuardando(true) setGuardando(true)
try { try {
const guardada = await guardarEnApi(cliente) const guardada = await guardarEnApi(cliente)
@@ -345,8 +385,10 @@ export default function CotizadorPage() {
acabados, acabados,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
precioLista, precioLista,
// El PDF imprime SOLO la propuesta elegida por el vendedor // El PDF imprime SOLO el esquema único FRASA 2026
propuestas: propuestaActiva ? [propuestaActiva] : [], esquema: resultado,
// + la opción de financiamiento elegida (Crédito Bancario por default)
opcion: opcionesCredito.find((o) => o.tipo === opcionElegida),
cliente, cliente,
desarrollo: params, desarrollo: params,
folio, folio,
@@ -412,11 +454,11 @@ export default function CotizadorPage() {
} }
const handleCompartir = async () => { const handleCompartir = async () => {
if (!unidad || !propuestaActiva) return if (!unidad || !resultado) return
const texto = textoCompartir({ const texto = textoCompartir({
unidadCodigo: unidad.codigo, unidadCodigo: unidad.codigo,
desarrolloNombre: params.nombre, desarrolloNombre: params.nombre,
propuesta: propuestaActiva, esquema: resultado,
apartado: apartadoEfectivo, apartado: apartadoEfectivo,
vigencia: calcularVigencia(), vigencia: calcularVigencia(),
...(ultimaGuardada ...(ultimaGuardada
@@ -448,32 +490,66 @@ export default function CotizadorPage() {
setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`) setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`)
return return
} }
const tipo = TIPO_DE_API[c.tipoPropuesta] const nueva = esEsquemaNuevo(c)
const paramsGuardados = desarrolloEfectivo(reglas, c.desarrollo)
const idx = paramsGuardados.propuestas.filter(propuestaVigente).findIndex((p) => p.tipo === tipo)
if (idx < 0) {
setAviso(
`La cotización ${c.folio ?? ''} usa la propuesta ${c.tipoPropuesta}, que ya no está disponible para ${c.desarrollo}. Elige otra propuesta.`,
)
}
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
restaurar({ restaurar({
desarrollo: c.desarrollo, desarrollo: c.desarrollo,
unidad: u, unidad: u,
acabados: c.acabados, acabados: c.acabados,
apartado: c.apartado, apartado: c.apartado,
descuento: c.descuento, // Las cotizaciones del esquema anterior retoman con los defaults del
propuestaIndex: idx >= 0 ? idx : 0, // nuevo esquema (14 mensualidades, escrituración a la firma)
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null, ...(nueva
? {
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : undefined,
escrituracionDiferida: c.escrituracionDiferida,
engancheAdicional: c.engancheAdicional ?? 0,
}
: {}),
}) })
setValue('nombre', c.clienteNombre, { shouldValidate: true }) setValue('nombre', c.clienteNombre, { shouldValidate: true })
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true }) setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
setValue('email', c.clienteEmail ?? '', { shouldValidate: true }) setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
setUltimaGuardada(c) setUltimaGuardada(c)
// Restaura la opción de crédito elegida y sus ajustes (guardados como JSON)
if (typeof c.opcionesCredito === 'string' && c.opcionesCredito.length > 0) {
try {
const guardado = JSON.parse(c.opcionesCredito) as {
elegida?: TipoCredito
ajustes?: Record<TipoCredito, AjustesCredito>
}
if (guardado.elegida && TIPOS_ORDEN.includes(guardado.elegida)) {
setOpcionElegida(guardado.elegida)
}
if (guardado.ajustes && typeof guardado.ajustes === 'object') {
setAjustesCredito((prev) => {
const siguiente = { ...prev }
for (const tipo of TIPOS_ORDEN) {
const a = guardado.ajustes?.[tipo]
// Solo se restauran ajustes con la forma esperada; si no, el default
if (a && typeof a.tasaAnual === 'number' && typeof a.plazoAnos === 'number') {
siguiente[tipo] = {
tasaAnual: a.tasaAnual,
plazoAnos: a.plazoAnos,
...(tipo === 'SEMI_CONTADO' && typeof a.pctContado === 'number'
? { pctContado: a.pctContado }
: {}),
}
}
}
return siguiente
})
}
} catch {
// JSON corrupto: se conservan los ajustes actuales (defaults)
}
}
const avisoEsquema = nueva
? ''
: ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.`
setAviso( setAviso(
esDisponible(u) esDisponible(u)
? `Cotización ${c.folio ?? ''} retomada.` ? `Cotización ${c.folio ?? ''} retomada.${avisoEsquema}`
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`, : `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.${avisoEsquema}`,
) )
} }
@@ -619,67 +695,6 @@ export default function CotizadorPage() {
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p> <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
</div> </div>
<div>
<label htmlFor="enganche" className={etiquetaCampo}>
Enganche (%) — opcional
</label>
<input
id="enganche"
type="number"
min={2.5}
max={ENGANCHE_MAXIMO_PCT}
step={0.5}
placeholder="Según propuesta"
value={enganchePct ?? ''}
onChange={(e) => {
const crudo = e.target.value
if (crudo === '') {
setEnganchePct(null)
return
}
const v = Number(crudo)
setEnganchePct(Number.isFinite(v) ? v : null)
}}
className={errorEnganche ? campoInputError : campoInput}
/>
{errorEnganche ? (
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
) : (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Aplica a crédito bancario e Infonavit/Cofinavit; rango válido 2.5% a{' '}
{ENGANCHE_MAXIMO_PCT}% (lo que permite el Excel)
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
</p>
)}
</div>
<div>
<label htmlFor="mensualidades" className={etiquetaCampo}>
Mensualidades
</label>
<select
id="mensualidades"
value={numMensualidades ?? ''}
onChange={(e) => {
const v = e.target.value
setNumMensualidades(v === '' ? null : Number(v))
}}
className={campoInput}
>
<option value="">Calcular automáticamente</option>
{[6, 12, 14, 18, 24].map((n) => (
<option key={n} value={n}>
{n} mensualidades
</option>
))}
</select>
{numMeses !== null && (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Se usarán {numMeses} mensualidades para el cálculo
</p>
)}
</div>
<BuscadorCliente <BuscadorCliente
onSeleccion={(lead) => { onSeleccion={(lead) => {
setValue('nombre', lead.nombre, { shouldValidate: true }) setValue('nombre', lead.nombre, { shouldValidate: true })
@@ -763,33 +778,43 @@ export default function CotizadorPage() {
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />} {unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
</aside> </aside>
{/* Panel de propuestas */} {/* Panel del esquema único FRASA 2026 */}
<section className="min-w-0"> <section className="min-w-0">
{!unidad ? ( {!unidad || !resultado ? (
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]"> <div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]"> <p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para ver las propuestas comerciales. Selecciona una unidad para ver el esquema de cotización.
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-8"> <>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <EsquemaPanel
{propuestas.map((p, i) => ( esquema={resultado}
<ProposalCard fechaEntrega={unidad?.fechaEntrega ?? null}
key={p.tipo} maxMensualidades={unidad ? mensualidadesParaUnidad(unidad) : MENSUALIDADES_MAX}
numero={i + 1} engancheBase={resultado.enganche}
titulo={p.titulo} engancheTotal={resultado.enganche + engancheAdicional}
propuesta={p} onEngancheTotal={(v) =>
/> setEngancheAdicional(
))} Math.min(Math.max(0, v - resultado.enganche), engancheAdicionalMaximo),
</div> )
<ComparadorPropuestas }
precioLista={precioLista} engancheMax={resultado.enganche + engancheAdicionalMaximo}
apartado={apartadoEfectivo} numMensualidades={numMensualidades}
propuestas={propuestas} onNumMensualidades={setNumMensualidades}
titulos={propuestas.map((p) => p.titulo)} escrituracionDiferida={escrituracionDiferida}
onEscrituracionDiferida={setEscrituracionDiferida}
/> />
</div> <OpcionesCredito
opciones={opcionesCredito}
ajustes={ajustesCredito}
onCambiarAjustes={(tipo, ajustes) =>
setAjustesCredito((prev) => ({ ...prev, [tipo]: ajustes }))
}
elegida={opcionElegida}
onElegir={setOpcionElegida}
/>
</>
)} )}
</section> </section>
</div> </div>
@@ -802,44 +827,24 @@ export default function CotizadorPage() {
error={errorMias} error={errorMias}
onRetomar={handleRetomar} onRetomar={handleRetomar}
/> />
<ComparadorUnidades
unidades={unidades}
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
apartado={apartadoEfectivo}
acabados={acabados}
reglas={reglas}
/>
</div> </div>
</main> </main>
{/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */} {/* Barra fija de cierre: resumen del esquema + acciones, siempre visibles */}
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95"> <div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8"> <div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
{propuestaActiva ? ( {resultado ? (
<> <span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent"> <span className="truncate">
<span className="truncate"> Esquema FRASA 2026 · Hoy {formatoMoneda(resultado.apartado)} · A la firma{' '}
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo} {formatoMoneda(resultado.pagoUnicoFirma)} · {resultado.numMensualidades} ×{' '}
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''} {formatoMoneda(resultado.mensualidad)}
</span>
</span> </span>
<select </span>
aria-label="Cambiar propuesta elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</>
) : ( ) : (
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]"> <p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para elegir propuesta, guardar y generar el PDF. Selecciona una unidad para ver el esquema, guardar y generar el PDF.
</p> </p>
)} )}
{aviso && ( {aviso && (

View File

@@ -1,4 +1,4 @@
import type { Unidad, TipoPropuesta } from '../types' import type { Unidad } from '../types'
import { INVENTARIO_SEED } from '../data/unidades' import { INVENTARIO_SEED } from '../data/unidades'
import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas' import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas'
import { conAuth, manejar401 } from './auth' import { conAuth, manejar401 } from './auth'
@@ -72,22 +72,12 @@ export async function getReglasCotizador(): Promise<ReglasDesarrollo> {
// ---------- Cotizaciones guardadas (historial del vendedor) ---------- // ---------- Cotizaciones guardadas (historial del vendedor) ----------
/** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */ /**
export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO' * Tipo de propuesta tal como viene del API. Las cotizaciones nuevas usan el
* esquema único FRASA_2026; los valores legacy (BANCARIO, …) quedan en las
export const TIPO_A_API: Record<TipoPropuesta, TipoPropuestaApi> = { * filas viejas para no romper reportes ni el historial.
CREDITO_BANCARIO: 'BANCARIO', */
CREDITO_INFONAVIT_COFINAVIT: 'INFONAVIT', export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO' | 'FRASA_2026'
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI_CONTADO',
}
export const TIPO_DE_API: Record<TipoPropuestaApi, TipoPropuesta> = {
BANCARIO: 'CREDITO_BANCARIO',
INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI_CONTADO',
}
export interface CotizacionGuardada { export interface CotizacionGuardada {
id: string id: string
@@ -97,16 +87,30 @@ export interface CotizacionGuardada {
clienteNombre: string clienteNombre: string
clienteTelefono: string | null clienteTelefono: string | null
clienteEmail: string | null clienteEmail: string | null
/** Esquema único FRASA 2026 (las viejas traen su tipoPropuesta legacy) */
esquema?: string
tipoPropuesta: TipoPropuestaApi tipoPropuesta: TipoPropuestaApi
acabados: boolean acabados: boolean
apartado: number apartado: number
descuento: number descuento: number
precioLista: number precioLista: number
precioFinal: number precioFinal: number
/** Lo que se paga a la firma por el enganche de firma (esquema nuevo) */
saldoFirma?: number | null
/** Enganche del 7.5%, neto del excedente del apartado (esquema nuevo) */
engancheDiferible?: number | null
/** Gastos de escrituración 5.5% (esquema nuevo) */
escrituracion?: number | null
/** true = escrituración diferida con el enganche (esquema nuevo) */
escrituracionDiferida?: boolean
/** Enganche adicional (anticipo extra; esquema nuevo, 0 por default) */
engancheAdicional?: number | null
enganche: number enganche: number
numMensualidades: number numMensualidades: number
mensualidad: number | null mensualidad: number | null
pagoFinal: number pagoFinal: number
/** JSON {elegida, ajustes} de las opciones de crédito (filas nuevas) */
opcionesCredito?: string | null
createdAt: string createdAt: string
} }
@@ -115,14 +119,19 @@ export interface GuardarCotizacionPayload {
clienteNombre: string clienteNombre: string
clienteEmail?: string clienteEmail?: string
clienteTelefono?: string clienteTelefono?: string
tipoPropuesta: TipoPropuestaApi
acabados: boolean acabados: boolean
/** Apartado: cantidad libre (sin él, el API usa su default) */
apartado?: number apartado?: number
descuento?: number /** Mensualidades del diferimiento (1..36; sin ellas, el API usa 14) */
/** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */
numMensualidades?: number numMensualidades?: number
/** true = la escrituración (5.5%) se difiere con el enganche */
escrituracionDiferida?: boolean
/** Enganche adicional (anticipo extra >= 0); el total no puede pasar del 90% del precio */
engancheAdicional?: number
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */ /** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string oportunidadId?: string
/** JSON serializado {elegida, ajustes} de las 4 opciones de crédito; el API lo guarda crudo */
opcionesCredito?: string
} }
/** /**
@@ -176,6 +185,15 @@ export async function getMisCotizaciones(): Promise<CotizacionGuardada[]> {
return (await resp.json()) as CotizacionGuardada[] return (await resp.json()) as CotizacionGuardada[]
} }
/** GET /cotizaciones/:id — una cotización del vendedor (retomar desde URL) */
export async function getCotizacion(id: string): Promise<CotizacionGuardada | null> {
const resp = await fetch(`${API_URL}/cotizaciones/${encodeURIComponent(id)}`, conAuth())
manejar401(resp)
if (resp.status === 404) return null
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return (await resp.json()) as CotizacionGuardada
}
// ---------- Búsqueda de clientes para el prellenado del cotizador ---------- // ---------- Búsqueda de clientes para el prellenado del cotizador ----------
export interface LeadResumen { export interface LeadResumen {

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { import {
actualizarUnidad,
crearUnidades, crearUnidades,
eliminarUnidad, eliminarUnidad,
estatusDe, estatusDe,
@@ -147,6 +148,76 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
const modelos = desarrollo.modelos ?? [] const modelos = desarrollo.modelos ?? []
// Edición de unidad existente (precios, m², ubicación, modelo y estatus)
const [editando, setEditando] = useState<UnidadDesarrollo | null>(null)
const [formEdicion, setFormEdicion] = useState({
precioLista: '',
precioAcabados: '',
supConstruccion: '',
supExcedente: '',
supEstacionamiento: '',
manzana: '',
lote: '',
nivel: '',
modeloId: '',
status: '',
})
const abrirEdicion = (unidad: UnidadDesarrollo) => {
setEditando(unidad)
setFormEdicion({
precioLista: String(unidad.precioSinAcabados ?? unidad.precioLista ?? ''),
precioAcabados: String(unidad.precioConAcabados ?? ''),
supConstruccion: String(unidad.supConstruccion ?? ''),
supExcedente: String(unidad.supExcedente ?? ''),
supEstacionamiento: String(unidad.supEstacionamiento ?? ''),
manzana: (unidad.extras?.['manzana'] as string | undefined) ?? '',
lote: (unidad.extras?.['lote'] as string | undefined) ?? '',
nivel: unidad.nivel ?? '',
modeloId: modelos.find((m) => m.nombre === unidad.modelo)?.id ?? '',
status: estatusDe(unidad) ?? '',
})
setError('')
setExito('')
}
const cambiarEdicion = (campo: keyof typeof formEdicion) => (valor: string) =>
setFormEdicion((prev) => ({ ...prev, [campo]: valor }))
const guardarEdicion = async () => {
if (!editando) return
setError('')
setExito('')
const lista = aNumero(formEdicion.precioLista)
if (lista === null) {
setError('El precio de lista es obligatorio.')
return
}
setEnviando(true)
try {
await actualizarUnidad(desarrollo.id, editando.codigo, {
precioLista: lista,
...(aNumero(formEdicion.precioAcabados) !== null ? { precioAcabados: aNumero(formEdicion.precioAcabados)! } : {}),
...(aNumero(formEdicion.supConstruccion) !== null ? { supConstruccion: aNumero(formEdicion.supConstruccion)! } : {}),
...(aNumero(formEdicion.supExcedente) !== null ? { supExcedente: aNumero(formEdicion.supExcedente)! } : {}),
...(aNumero(formEdicion.supEstacionamiento) !== null ? { supEstacionamiento: aNumero(formEdicion.supEstacionamiento)! } : {}),
manzana: formEdicion.manzana.trim(),
lote: formEdicion.lote.trim(),
nivel: formEdicion.nivel.trim(),
...(formEdicion.modeloId !== '' ? { modeloId: formEdicion.modeloId } : {}),
...(formEdicion.status !== '' ? { status: formEdicion.status } : {}),
})
setExito(`Unidad ${editando.codigo} actualizada.`)
setEditando(null)
await cargarUnidades()
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudo actualizar la unidad.')
} finally {
setEnviando(false)
}
}
const guardarGenerador = async () => { const guardarGenerador = async () => {
setError('') setError('')
setExito('') setExito('')
@@ -711,6 +782,14 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<BadgeEstatus estatus={estatusDe(unidad)} /> <BadgeEstatus estatus={estatusDe(unidad)} />
<button
type="button"
onClick={() => abrirEdicion(unidad)}
aria-label={`Editar unidad ${unidad.codigo}`}
className={BOTON_SECUNDARIO}
>
Editar
</button>
<button <button
type="button" type="button"
onClick={() => void borrarUnidad(unidad)} onClick={() => void borrarUnidad(unidad)}
@@ -756,6 +835,113 @@ export function TabInventario({ desarrollo, onRefrescar }: Props) {
</div> </div>
)} )}
</section> </section>
{/* Modal de edición de unidad */}
{editando && (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/50 px-4" role="dialog" aria-modal="true">
<div className="w-full max-w-lg rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-xl dark:border-[#1E293B] dark:bg-[#1E293B]">
<h3 className="text-base font-semibold text-[#111827] dark:text-[#E2E8F0]">
Editar unidad <span className="font-mono">{editando.codigo}</span>
</h3>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Precio de lista *</span>
<input
type="number"
min="0"
step="1000"
value={formEdicion.precioLista}
onChange={(e) => cambiarEdicion('precioLista')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Precio con acabados</span>
<input
type="number"
min="0"
step="1000"
value={formEdicion.precioAcabados}
onChange={(e) => cambiarEdicion('precioAcabados')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. construcción (m²)</span>
<input
type="number"
min="0"
step="0.01"
value={formEdicion.supConstruccion}
onChange={(e) => cambiarEdicion('supConstruccion')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. excedente (m²)</span>
<input
type="number"
min="0"
step="0.01"
value={formEdicion.supExcedente}
onChange={(e) => cambiarEdicion('supExcedente')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Sup. estacionamiento (m²)</span>
<input
type="number"
min="0"
step="0.01"
value={formEdicion.supEstacionamiento}
onChange={(e) => cambiarEdicion('supEstacionamiento')(e.target.value)}
className={INPUT}
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Modelo</span>
<select value={formEdicion.modeloId} onChange={(e) => cambiarEdicion('modeloId')(e.target.value)} className={INPUT}>
<option value="">Sin modelo</option>
{modelos.map((m) => (
<option key={m.id} value={m.id}>{m.nombre}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Manzana</span>
<input value={formEdicion.manzana} onChange={(e) => cambiarEdicion('manzana')(e.target.value)} className={INPUT} />
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Lote</span>
<input value={formEdicion.lote} onChange={(e) => cambiarEdicion('lote')(e.target.value)} className={INPUT} />
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Nivel</span>
<input value={formEdicion.nivel} onChange={(e) => cambiarEdicion('nivel')(e.target.value)} className={INPUT} />
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">Estatus</span>
<select value={formEdicion.status} onChange={(e) => cambiarEdicion('status')(e.target.value)} className={INPUT}>
<option value="">Sin cambio</option>
{ESTATUS_UNIDAD.map((estatus) => (
<option key={estatus} value={estatus}>{estatus}</option>
))}
</select>
</label>
</div>
{error && <div className="mt-3"><MensajeError>{error}</MensajeError></div>}
<div className="mt-5 flex justify-end gap-2">
<button type="button" onClick={() => setEditando(null)} disabled={enviando} className={BOTON_SECUNDARIO}>
Cancelar
</button>
<button type="button" onClick={() => void guardarEdicion()} disabled={enviando} className={BOTON_PRIMARIO}>
{enviando ? 'Guardando…' : 'Guardar cambios'}
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -64,13 +64,16 @@ export const ETIQUETA_FUENTE_N = {
porUnidad: 'Capturado por unidad', porUnidad: 'Capturado por unidad',
} as const } as const
/** Estatus posibles del inventario (los mismos que maneja el cotizador) */ /**
* Estatus posibles del inventario (los mismos que maneja el cotizador).
* Un par canónico APARTADO/VENDIDO: las variantes femeninas APARTADA/VENDIDA
* quedaron normalizadas en la base de datos; CLASE_ESTATUS las conserva para
* pintar bien cualquier fila histórica que aún las traiga.
*/
export const ESTATUS_UNIDAD = [ export const ESTATUS_UNIDAD = [
'DISPONIBLE', 'DISPONIBLE',
'APARTADO', 'APARTADO',
'APARTADA',
'VENDIDO', 'VENDIDO',
'VENDIDA',
'ESCRITURADO', 'ESCRITURADO',
'BLOQUEADO', 'BLOQUEADO',
] as const ] as const

View File

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

View File

@@ -1,197 +1,18 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react' /**
import { * Módulo de Soporte: acceso directo al formulario oficial de solicitudes
DEPARTAMENTOS_SOPORTE, * internas (Monday WorkForms). Ya no se registra nada en este sistema: el
ESTADOS_SOPORTE, * botón abre el formulario oficial en una pestaña nueva.
MAX_ADJUNTOS_SOPORTE, */
PRIORIDADES_SOPORTE,
cambiarEstadoSolicitud, const URL_FORMULARIO_SOPORTE = 'https://wkf.ms/3T5NyTY'
crearSolicitudSoporte,
descargarArchivoSolicitud,
listarSolicitudesSoporte,
nombreArchivoSoporte,
validarAdjuntoSoporte,
type EstadoSoporte,
type SolicitudSoporte,
} from '../services/soporte'
import { getSesion } from '../services/auth'
interface Props { interface Props {
onVolver: () => void onVolver: () => void
} }
const inputClase =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
const labelClase = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
const ETIQUETA_ESTADO: Record<string, string> = {
ABIERTA: 'Abierta',
EN_PROCESO: 'En proceso',
CERRADA: 'Cerrada',
}
const OPCIONES_ESTADO = [
{ valor: '', etiqueta: 'Todos los estatus' },
...ESTADOS_SOPORTE.map((e) => ({ valor: e, etiqueta: ETIQUETA_ESTADO[e] ?? e })),
]
const OPCIONES_DEPARTAMENTO = [
{ valor: '', etiqueta: 'Todos los departamentos' },
...DEPARTAMENTOS_SOPORTE.map((d) => ({ valor: d, etiqueta: d })),
]
const CLASE_ESTADO: Record<string, string> = {
ABIERTA: 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300',
EN_PROCESO: 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-300',
CERRADA: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300',
}
/** Fecha de hoy en formato YYYY-MM-DD (hora local) */
function hoyISO(): string {
const hoy = new Date()
const mes = String(hoy.getMonth() + 1).padStart(2, '0')
const dia = String(hoy.getDate()).padStart(2, '0')
return `${hoy.getFullYear()}-${mes}-${dia}`
}
export function SoportePage({ onVolver }: Props) { export function SoportePage({ onVolver }: Props) {
const sesion = getSesion()
const puedeGestionar = sesion?.usuario.rol === 'admin' || sesion?.usuario.rol === 'manager'
const [titulo, setTitulo] = useState('')
const [descripcion, setDescripcion] = useState('')
const [departamento, setDepartamento] = useState<string>(DEPARTAMENTOS_SOPORTE[0])
const [prioridad, setPrioridad] = useState<string>('Media')
const [responsable, setResponsable] = useState('')
const [fechaSolicitud, setFechaSolicitud] = useState(hoyISO())
const [fechaLimite, setFechaLimite] = useState('')
const [notas, setNotas] = useState('')
const [email, setEmail] = useState('')
const [archivos, setArchivos] = useState<File[]>([])
const [errorArchivos, setErrorArchivos] = useState('')
const [enviando, setEnviando] = useState(false)
const [aviso, setAviso] = useState('')
const [error, setError] = useState('')
const [solicitudes, setSolicitudes] = useState<SolicitudSoporte[]>([])
const [cargando, setCargando] = useState(true)
const [filtroEstado, setFiltroEstado] = useState('')
const [filtroDepartamento, setFiltroDepartamento] = useState('')
const [ocupado, setOcupado] = useState('')
const cargar = useCallback(async () => {
setCargando(true)
try {
setSolicitudes(
await listarSolicitudesSoporte({
...(filtroEstado ? { estado: filtroEstado } : {}),
...(filtroDepartamento ? { departamento: filtroDepartamento } : {}),
}),
)
} catch {
// La lista es secundaria: el form sigue usable si el listado falla
} finally {
setCargando(false)
}
}, [filtroEstado, filtroDepartamento])
useEffect(() => {
void cargar()
}, [cargar])
const agregarArchivos = (lista: FileList | null) => {
if (lista === null) return
const nuevos: File[] = []
const rechazados: string[] = []
for (const archivo of Array.from(lista)) {
const problema = validarAdjuntoSoporte(archivo)
if (problema) rechazados.push(problema)
else nuevos.push(archivo)
}
const combinados = [...archivos, ...nuevos]
if (combinados.length > MAX_ADJUNTOS_SOPORTE) {
rechazados.push(`Máximo ${MAX_ADJUNTOS_SOPORTE} archivos por solicitud.`)
}
setArchivos(combinados.slice(0, MAX_ADJUNTOS_SOPORTE))
setErrorArchivos(rechazados.join(' '))
}
const enviar = async (e: FormEvent) => {
e.preventDefault()
setAviso('')
setError('')
if (!titulo.trim() || !descripcion.trim()) {
setError('El título y la descripción son obligatorios.')
return
}
setEnviando(true)
try {
const creada = await crearSolicitudSoporte({
titulo: titulo.trim(),
descripcion: descripcion.trim(),
departamento: departamento as (typeof DEPARTAMENTOS_SOPORTE)[number],
prioridad: prioridad as (typeof PRIORIDADES_SOPORTE)[number],
...(responsable.trim() ? { responsable: responsable.trim() } : {}),
fechaSolicitud,
...(fechaLimite ? { fechaLimite } : {}),
...(notas.trim() ? { notas: notas.trim() } : {}),
...(sesion?.usuario.nombre ? { solicitante: sesion.usuario.nombre } : {}),
...(email.trim() ? { email: email.trim() } : {}),
...(archivos.length > 0 ? { archivos } : {}),
})
setAviso(`Solicitud registrada (${ETIQUETA_ESTADO[creada.estado] ?? creada.estado}).`)
setTitulo('')
setDescripcion('')
setResponsable('')
setNotas('')
setArchivos([])
setErrorArchivos('')
setFechaLimite('')
await cargar()
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo registrar la solicitud.')
} finally {
setEnviando(false)
}
}
const descargar = async (solicitud: SolicitudSoporte, indice: number) => {
const ruta = solicitud.archivos[indice] ?? ''
setError('')
setOcupado(`${solicitud.id}:${indice}`)
try {
const blob = await descargarArchivoSolicitud(solicitud.id, indice)
const url = URL.createObjectURL(blob)
const enlace = document.createElement('a')
enlace.href = url
enlace.download = nombreArchivoSoporte(ruta)
document.body.appendChild(enlace)
enlace.click()
enlace.remove()
URL.revokeObjectURL(url)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo descargar el archivo.')
} finally {
setOcupado('')
}
}
const cambiarEstado = async (solicitud: SolicitudSoporte, estado: string) => {
setAviso('')
setError('')
setOcupado(solicitud.id)
try {
const actualizada = await cambiarEstadoSolicitud(solicitud.id, estado as EstadoSoporte)
setSolicitudes((prev) => prev.map((s) => (s.id === actualizada.id ? actualizada : s)))
setAviso(`"${actualizada.titulo}" ahora está ${ETIQUETA_ESTADO[actualizada.estado] ?? actualizada.estado}.`)
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo cambiar el estado.')
} finally {
setOcupado('')
}
}
return ( return (
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16"> <div className="mx-auto max-w-2xl px-4 py-10">
{/* Header */}
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<button <button
type="button" type="button"
@@ -204,281 +25,31 @@ export function SoportePage({ onVolver }: Props) {
Soporte Soporte
</h1> </h1>
</div> </div>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
Registra una solicitud para los departamentos internos y dale seguimiento desde esta misma
pantalla.
</p>
{/* Formulario */} <div className="mt-8 rounded-2xl border border-[#E5E7EB] bg-white p-8 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<form onSubmit={(e) => void enviar(e)} noValidate className="mt-8 max-w-3xl"> <p className="text-4xl" aria-hidden="true">
<div className="grid gap-5 sm:grid-cols-2"> 🛠️
<label htmlFor="soporte-titulo" className="block sm:col-span-2"> </p>
<span className={labelClase}>Título<span className="text-red-700"> *</span></span> <h2 className="mt-4 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
<input Solicitudes internas
id="soporte-titulo"
type="text"
value={titulo}
onChange={(e) => setTitulo(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-descripcion" className="block sm:col-span-2">
<span className={labelClase}>Descripción<span className="text-red-700"> *</span></span>
<textarea
id="soporte-descripcion"
rows={4}
value={descripcion}
onChange={(e) => setDescripcion(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-departamento" className="block">
<span className={labelClase}>Departamento</span>
<select
id="soporte-departamento"
value={departamento}
onChange={(e) => setDepartamento(e.target.value)}
className={inputClase}
>
{DEPARTAMENTOS_SOPORTE.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
</label>
<label htmlFor="soporte-prioridad" className="block">
<span className={labelClase}>Prioridad</span>
<select
id="soporte-prioridad"
value={prioridad}
onChange={(e) => setPrioridad(e.target.value)}
className={inputClase}
>
{PRIORIDADES_SOPORTE.map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</label>
<label htmlFor="soporte-responsable" className="block">
<span className={labelClase}>Responsable</span>
<input
id="soporte-responsable"
type="text"
value={responsable}
onChange={(e) => setResponsable(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-fecha-solicitud" className="block">
<span className={labelClase}>Fecha de solicitud</span>
<input
id="soporte-fecha-solicitud"
type="date"
value={fechaSolicitud}
onChange={(e) => setFechaSolicitud(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-email" className="block">
<span className={labelClase}>Correo del solicitante</span>
<input
id="soporte-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="nombre@frasa.mx"
className={inputClase}
/>
</label>
<label htmlFor="soporte-fecha-limite" className="block">
<span className={labelClase}>Fecha límite</span>
<input
id="soporte-fecha-limite"
type="date"
value={fechaLimite}
onChange={(e) => setFechaLimite(e.target.value)}
className={inputClase}
/>
</label>
<label htmlFor="soporte-archivos" className="block">
<span className={labelClase}>Archivos (PDF, JPG o PNG · máx 10 MB)</span>
<input
id="soporte-archivos"
type="file"
multiple
accept="application/pdf,image/jpeg,image/png"
onChange={(e) => agregarArchivos(e.target.files)}
className={`${inputClase} file:mr-3 file:rounded-md file:border-0 file:bg-[#F1F5F9] file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-[#374151]`}
/>
</label>
<label htmlFor="soporte-notas" className="block sm:col-span-2">
<span className={labelClase}>Notas</span>
<textarea
id="soporte-notas"
rows={2}
value={notas}
onChange={(e) => setNotas(e.target.value)}
className={inputClase}
/>
</label>
</div>
{archivos.length > 0 && (
<ul className="mt-3 flex flex-wrap gap-2">
{archivos.map((archivo, i) => (
<li
key={`${archivo.name}-${i}`}
className="flex items-center gap-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-1.5 text-xs text-[#374151] dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#CBD5E1]"
>
{archivo.name}
<button
type="button"
aria-label={`Quitar ${archivo.name}`}
onClick={() => setArchivos((prev) => prev.filter((_, j) => j !== i))}
className="text-[#64748B] hover:text-red-600 dark:text-[#94A3B8]"
>
✕
</button>
</li>
))}
</ul>
)}
{errorArchivos && (
<p role="alert" className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
{errorArchivos}
</p>
)}
{error && (
<p role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700">
{error}
</p>
)}
{aviso && (
<p role="status" className="mt-4 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-xs font-medium text-green-700">
{aviso}
</p>
)}
<button
type="submit"
disabled={enviando}
className="mt-6 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{enviando ? 'Registrando…' : 'Enviar solicitud'}
</button>
</form>
{/* Listado de solicitudes */}
<section aria-label={puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'} className="mt-12 max-w-3xl">
<h2 className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">
{puedeGestionar ? 'Todas las solicitudes' : 'Mis solicitudes'}
</h2> </h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
{/* Filtros del listado */} Las solicitudes de soporte y los reportes a otros departamentos ahora se registran en el
<div className="mt-4 grid gap-3 sm:grid-cols-2"> formulario oficial. Al abrirlo podrás describir tu solicitud, adjuntar archivos y elegir
<label htmlFor="soporte-filtro-estado" className="block"> el departamento responsable.
<span className={labelClase}>Filtrar por estatus</span> </p>
<select <a
id="soporte-filtro-estado" href={URL_FORMULARIO_SOPORTE}
value={filtroEstado} target="_blank"
onChange={(e) => setFiltroEstado(e.target.value)} rel="noopener noreferrer"
className={inputClase} className="mt-6 inline-block rounded-lg bg-accent px-6 py-3 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
> >
{OPCIONES_ESTADO.map((o) => ( Abrir formulario de soporte ↗
<option key={o.valor} value={o.valor}>{o.etiqueta}</option> </a>
))} <p className="mt-4 text-xs text-[#94A3B8]">
</select> Se abre en una pestaña nueva: {URL_FORMULARIO_SOPORTE}
</label> </p>
<label htmlFor="soporte-filtro-departamento" className="block"> </div>
<span className={labelClase}>Filtrar por departamento</span> </div>
<select
id="soporte-filtro-departamento"
value={filtroDepartamento}
onChange={(e) => setFiltroDepartamento(e.target.value)}
className={inputClase}
>
{OPCIONES_DEPARTAMENTO.map((o) => (
<option key={o.valor} value={o.valor}>{o.etiqueta}</option>
))}
</select>
</label>
</div>
{cargando ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando…</p>
) : solicitudes.length === 0 ? (
<p className="mt-3 text-sm text-[#64748B] dark:text-[#94A3B8]">
{filtroEstado || filtroDepartamento
? 'Ninguna solicitud coincide con los filtros.'
: 'Aún no hay solicitudes registradas.'}
</p>
) : (
<ul className="mt-4 space-y-3">
{solicitudes.map((s) => (
<li
key={s.id}
className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium text-[#111827] dark:text-[#E2E8F0]">{s.titulo}</p>
<div className="flex items-center gap-2">
<span
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[s.estado] ?? 'border-[#E5E7EB] text-[#64748B] dark:border-[#334155] dark:text-[#94A3B8]'}`}
>
{ETIQUETA_ESTADO[s.estado] ?? s.estado}
</span>
{puedeGestionar && (
<select
aria-label={`Cambiar estatus de ${s.titulo}`}
value={s.estado}
disabled={ocupado === s.id}
onChange={(e) => void cambiarEstado(s, e.target.value)}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1 text-xs text-[#374151] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
{ESTADOS_SOPORTE.map((e) => (
<option key={e} value={e}>{ETIQUETA_ESTADO[e] ?? e}</option>
))}
</select>
)}
</div>
</div>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{s.departamento} · {s.prioridad} · Solicitó {s.solicitante || '—'}
{s.solicitanteEmail ? ` (${s.solicitanteEmail})` : ''} ·{' '}
{new Date(s.fechaSolicitud).toLocaleDateString('es-MX')}
</p>
<p className="mt-2 text-sm text-[#374151] dark:text-[#CBD5E1]">{s.descripcion}</p>
{s.archivos.length > 0 && (
<ul className="mt-2 flex flex-wrap gap-2">
{s.archivos.map((ruta, i) => (
<li key={`${ruta}-${i}`}>
<button
type="button"
onClick={() => void descargar(s, i)}
disabled={ocupado === `${s.id}:${i}`}
className="rounded-lg border border-[#D1D5DB] bg-[#F9FAFB] px-2.5 py-1 text-xs text-[#374151] transition-colors hover:bg-[#F1F5F9] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
⬇ {nombreArchivoSoporte(ruta)}
</button>
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</section>
</main>
) )
} }

View File

@@ -40,6 +40,15 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<DepartamentoId, CampoDef[]> = {
{ campo: 'listas_avaluo', tipo: 'boolean', label: 'Listas para Avaluo', conFecha: 'listas_avaluo_fecha' }, { 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: 'listas_avaluo_fecha', tipo: 'date', label: 'Fecha Listas para Avaluo' },
{ campo: 'habitabilidad', tipo: 'date', label: 'Habitabilidad a partir de' }, { 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' }, { campo: 'comentarios_obra', tipo: 'text', label: 'Comentarios' },
], ],
VENTAS: [ VENTAS: [
@@ -55,13 +64,6 @@ export const CAMPOS_POR_DEPARTAMENTO: Record<DepartamentoId, CampoDef[]> = {
{ campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' }, { campo: 'precio_venta', tipo: 'number', label: 'Precio Venta' },
], ],
ESCRITURACION: [ 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: 'fecha_firma', tipo: 'date', label: 'Fecha Firma' },
{ campo: 'notaria', tipo: 'text', label: 'Notaría' }, { campo: 'notaria', tipo: 'text', label: 'Notaría' },
{ campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' }, { campo: 'fecha_pago_banco', tipo: 'date', label: 'Fecha Pago Banco' },

View File

@@ -188,7 +188,7 @@ export async function getAuditoria(filtros?: {
}): Promise<AuditoriaItem[]> { }): Promise<AuditoriaItem[]> {
const params = new URLSearchParams() const params = new URLSearchParams()
if (filtros?.entidad) params.set('entidad', filtros.entidad) if (filtros?.entidad) params.set('entidad', filtros.entidad)
if (filtros?.usuario) params.set('usuario', filtros.usuario) if (filtros?.usuario) params.set('usuarioId', filtros.usuario)
if (filtros?.desde) params.set('desde', filtros.desde) if (filtros?.desde) params.set('desde', filtros.desde)
if (filtros?.hasta) params.set('hasta', filtros.hasta) if (filtros?.hasta) params.set('hasta', filtros.hasta)
const qs = params.toString() const qs = params.toString()

View File

@@ -382,3 +382,29 @@ export async function eliminarUnidad(desarrolloId: string, codigo: string): Prom
method: 'DELETE', method: 'DELETE',
}) })
} }
/** Cambios editables de una unidad (PATCH /desarrollos/:id/unidades/:codigo) */
export interface CambiosUnidad {
manzana?: string
lote?: string
nivel?: string
modeloId?: string
precioLista?: number
precioAcabados?: number
supConstruccion?: number
supExcedente?: number
supEstacionamiento?: number
status?: string
}
/** PATCH /api/desarrollos/:id/unidades/:codigo — edición de precios, m², modelo y estatus */
export async function actualizarUnidad(
desarrolloId: string,
codigo: string,
cambios: CambiosUnidad,
): Promise<UnidadDesarrollo> {
return pedir<UnidadDesarrollo>(
`${rutaDesarrollo(desarrolloId)}/unidades/${encodeURIComponent(codigo)}`,
initJson('PATCH', cambios),
)
}

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { X, User, Calendar, CheckSquare, FileText, Users, Search } from 'lucide-react' import { X, User, Calendar, CheckSquare, FileText, Users, Search, ClipboardList } from 'lucide-react'
import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia } from '../lib/mi-dia' import type { LeadMiDia, OportunidadMiDia, ApartadoMiDia, AfluenciaPerfil } from '../lib/mi-dia'
import { formatoFechaCorta, urlWhatsApp } from '../lib/mi-dia' import { formatoFechaCorta, formatoFechaHora, urlWhatsApp } from '../lib/mi-dia'
import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios' import { listarRecordatorios, crearRecordatorio, completarRecordatorio, type Recordatorio } from '../services/recordatorios'
import { fetchAfluencia } from '../services/mi-dia'
import { getSesion } from '../services/auth' import { getSesion } from '../services/auth'
type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia type LeadItem = LeadMiDia | OportunidadMiDia | ApartadoMiDia
@@ -21,7 +22,33 @@ function esOportunidad(item: LeadItem): item is OportunidadMiDia {
return 'modeloInteres' in item return 'modeloInteres' in item
} }
type TabId = 'info' | 'cita' | 'tareas' | 'notas' | 'miembros' type TabId = 'info' | 'cita' | 'afluencia' | 'tareas' | 'notas' | 'miembros'
/** Etiquetas legibles de los campos del perfil de afluencia (bitácora) */
const ETIQUETAS_AFLUENCIA: Record<string, string> = {
nombre: 'Nombre',
telefono: 'Teléfono',
email: 'Email',
origen: 'Origen',
desarrollo: 'Desarrollo',
modelo: 'Modelo',
medioContacto: 'Medio de contacto',
numDependientes: 'No. de dependientes',
edad: 'Edad',
estadoCivil: 'Estado civil',
tipoEmpleo: 'Tipo de empleo',
ocupacion: 'Ocupación',
rangoSalarial: 'Rango salarial',
credito: 'Crédito',
tipoEconomia: 'Tipo de economía',
califica: 'Califica',
loQuierePara: 'Lo quiere para',
notas: 'Notas',
unidadCodigo: 'Unidad',
}
/** Claves internas que no se muestran como campo del perfil */
const CLAVES_AFLUENCIA_OCULTAS = ['fechaCita', 'oportunidadId']
const CLASE_ACCION_RAPIDA = const CLASE_ACCION_RAPIDA =
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10' 'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-blue-50 dark:border-accent dark:bg-[#0F172A] dark:text-accent dark:hover:bg-accent/10'
@@ -29,6 +56,7 @@ const CLASE_ACCION_RAPIDA =
const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [ const TABS: { id: TabId; titulo: string; icono: typeof User }[] = [
{ id: 'info', titulo: 'Información', icono: User }, { id: 'info', titulo: 'Información', icono: User },
{ id: 'cita', titulo: 'Cita', icono: Calendar }, { id: 'cita', titulo: 'Cita', icono: Calendar },
{ id: 'afluencia', titulo: 'Afluencia', icono: ClipboardList },
{ id: 'tareas', titulo: 'Tareas', icono: CheckSquare }, { id: 'tareas', titulo: 'Tareas', icono: CheckSquare },
{ id: 'notas', titulo: 'Notas', icono: FileText }, { id: 'notas', titulo: 'Notas', icono: FileText },
{ id: 'miembros', titulo: 'Miembros', icono: Users }, { id: 'miembros', titulo: 'Miembros', icono: Users },
@@ -63,6 +91,20 @@ export default function DetalleLeadModal({
// --- Cita --- // --- Cita ---
const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : '')) const [fechaCita, setFechaCita] = useState(() => ('fechaCita' in item ? (item.fechaCita ?? '') : ''))
const [guardandoCita, setGuardandoCita] = useState(false) const [guardandoCita, setGuardandoCita] = useState(false)
// --- Afluencia (pestaña de la bitácora) ---
const [afluencia, setAfluencia] = useState<AfluenciaPerfil | null>(null)
const [cargandoAfluencia, setCargandoAfluencia] = useState(false)
useEffect(() => {
if (tab !== 'afluencia') return
let vivo = true
setCargandoAfluencia(true)
fetchAfluencia(item.id)
.then((a) => { if (vivo) setAfluencia(a) })
.catch(() => { if (vivo) setAfluencia(null) })
.finally(() => { if (vivo) setCargandoAfluencia(false) })
return () => { vivo = false }
}, [tab, item.id])
/** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */ /** Guarda la cita: el cambio de etapa lo ejecuta la página (onGuardarCita) */
async function guardarCita(e: FormEvent) { async function guardarCita(e: FormEvent) {
@@ -81,8 +123,11 @@ export default function DetalleLeadModal({
const cargarNotas = useCallback(async () => { const cargarNotas = useCallback(async () => {
setCargandoNotas(true) setCargandoNotas(true)
try { try {
const todas = await listarRecordatorios()
const clave = calcularClienteClave(item.nombre, (item as any).telefono) const clave = calcularClienteClave(item.nombre, (item as any).telefono)
// Notas de la bitácora: se piden por lead/cliente al API, que devuelve
// las notas de TODOS los usuarios (el seguimiento del cliente es
// compartido y cualquier usuario debe verlo completo).
const todas = await listarRecordatorios({ leadId: item.id, clienteClave: clave })
setNotas(todas.filter((r) => r.leadId === item.id || (r.clienteClave && r.clienteClave === clave))) setNotas(todas.filter((r) => r.leadId === item.id || (r.clienteClave && r.clienteClave === clave)))
} catch { setNotaError('Error al cargar notas.') } } catch { setNotaError('Error al cargar notas.') }
finally { setCargandoNotas(false) } finally { setCargandoNotas(false) }
@@ -164,6 +209,9 @@ export default function DetalleLeadModal({
{esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />} {esOp && item.unidadCodigo && <InfoCampo etiqueta="Unidad" valor={item.unidadCodigo} />}
{esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />} {esLd && item.origen && <InfoCampo etiqueta="Origen" valor={item.origen} />}
<InfoCampo etiqueta="Estado" valor={esOp ? item.etapa : item.estado || '—'} /> <InfoCampo etiqueta="Estado" valor={esOp ? item.etapa : item.estado || '—'} />
{'creadoEn' in item && item.creadoEn ? (
<InfoCampo etiqueta="Fecha de alta" valor={formatoFechaHora(item.creadoEn).slice(0, 10)} />
) : null}
{esOp && item.fechaCita && <InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />} {esOp && item.fechaCita && <InfoCampo etiqueta="Cita" valor={item.fechaCita.replace('T', ' ')} />}
{!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && <InfoCampo etiqueta="Unidad" valor={(item as ApartadoMiDia).unidadCodigo} />} {!esLd && !esOp && (item as ApartadoMiDia).unidadCodigo && <InfoCampo etiqueta="Unidad" valor={(item as ApartadoMiDia).unidadCodigo} />}
{!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && <InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />} {!esLd && !esOp && (item as ApartadoMiDia).fechaLimite && <InfoCampo etiqueta="Fecha límite" valor={formatoFechaCorta((item as ApartadoMiDia).fechaLimite)} />}
@@ -228,6 +276,35 @@ export default function DetalleLeadModal({
</div> </div>
)} )}
{tab === 'afluencia' && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Afluencia</h3>
{afluencia?.afluenciaCompleta && (
<span className="rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-300">
Afluencia completa
</span>
)}
</div>
{cargandoAfluencia ? (
<p className="text-sm text-[#94A3B8]">Cargando afluencia…</p>
) : !afluencia || Object.keys(afluencia.perfil).length === 0 ? (
<p className="text-sm text-[#94A3B8]">
Aún no se ha capturado la afluencia de este cliente. Se llena desde el botón
"Afluencia →" de Mis oportunidades.
</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{Object.entries(afluencia.perfil)
.filter(([clave]) => !CLAVES_AFLUENCIA_OCULTAS.includes(clave))
.map(([clave, valor]) => (
<InfoCampo key={clave} etiqueta={ETIQUETAS_AFLUENCIA[clave] ?? clave} valor={valor} />
))}
</div>
)}
</div>
)}
{tab === 'tareas' && ( {tab === 'tareas' && (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3> <h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Tareas pendientes</h3>
@@ -248,7 +325,7 @@ export default function DetalleLeadModal({
onChange={() => void completarTarea(n.id)} onChange={() => void completarTarea(n.id)}
/> />
<span className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</span> <span className="text-sm text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</span>
<span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</span> <span className="ml-auto text-[10px] text-[#94A3B8]">{formatoFechaHora(n.createdAt)}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -308,7 +385,7 @@ export default function DetalleLeadModal({
<li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] px-4 py-3"> <li key={n.id} className="rounded-lg border border-[#E5E7EB] dark:border-[#334155] bg-[#F8FAFC] dark:bg-[#1E293B] px-4 py-3">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p> <p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{n.titulo}</p>
{n.descripcion && <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p>} {n.descripcion && <p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{n.descripcion}</p>}
<p className="mt-1.5 text-[10px] text-[#94A3B8]">{formatoFechaCorta(n.createdAt.slice(0, 10))}</p> <p className="mt-1.5 text-[10px] text-[#94A3B8]">{formatoFechaHora(n.createdAt)}</p>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -141,14 +141,15 @@ export function aplicarFiltros(
if (!valor.includes(busqueda)) return false if (!valor.includes(busqueda)) return false
} }
// Desarrollo // Desarrollo (comparación normalizada: el catálogo y los datos pueden
// variar en mayúsculas/espacios — era la causa principal de filtros rotos)
if (filtros.desarrollo !== '' && accessor.desarrollo) { if (filtros.desarrollo !== '' && accessor.desarrollo) {
if (accessor.desarrollo(item) !== filtros.desarrollo) return false if (accessor.desarrollo(item).trim().toUpperCase() !== filtros.desarrollo.trim().toUpperCase()) return false
} }
// Origen // Origen (misma normalización que desarrollo)
if (filtros.origen !== '' && accessor.origen) { if (filtros.origen !== '' && accessor.origen) {
if (accessor.origen(item) !== filtros.origen) return false if (accessor.origen(item).trim().toUpperCase() !== filtros.origen.trim().toUpperCase()) return false
} }
// Estado (comparación case-insensitive: los labels pueden variar en mayúsculas) // Estado (comparación case-insensitive: los labels pueden variar en mayúsculas)

View File

@@ -13,6 +13,8 @@ interface KanbanViewProps<T extends { id: string; nombre: string; estado?: strin
obtenerColumna: (item: T) => string obtenerColumna: (item: T) => string
onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void onCambiarEstado: (itemId: string, nuevoEstado: string, fuente?: string) => void
renderCard?: (item: T) => React.ReactNode renderCard?: (item: T) => React.ReactNode
/** Click en la tarjeta: abre la bitácora/detalle del registro */
onVerItem?: (item: T) => void
} }
export default function KanbanView<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }>({ export default function KanbanView<T extends { id: string; nombre: string; estado?: string; telefono?: string; email?: string; unidadCodigo?: string; fuente?: string; pendienteSync?: boolean }>({
@@ -21,6 +23,7 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
obtenerColumna, obtenerColumna,
onCambiarEstado, onCambiarEstado,
renderCard, renderCard,
onVerItem,
}: KanbanViewProps<T>) { }: KanbanViewProps<T>) {
const [arrastrando, setArrastrando] = useState<string | null>(null) const [arrastrando, setArrastrando] = useState<string | null>(null)
@@ -70,7 +73,11 @@ export default function KanbanView<T extends { id: string; nombre: string; estad
key={item.id} key={item.id}
draggable draggable
onDragStart={(e) => void onDragStart(e, item.id)} onDragStart={(e) => void onDragStart(e, item.id)}
className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''}`} onClick={onVerItem ? () => onVerItem(item) : undefined}
role={onVerItem ? 'button' : undefined}
tabIndex={onVerItem ? 0 : undefined}
onKeyDown={onVerItem ? (e) => { if (e.key === 'Enter') onVerItem(item) } : undefined}
className={`rounded-lg border border-[#E5E7EB] bg-white p-2.5 shadow-sm transition-opacity dark:border-[#334155] dark:bg-[#1E293B] ${arrastrando === item.id ? 'opacity-50' : ''} ${onVerItem ? 'cursor-pointer hover:border-accent' : ''}`}
> >
{renderCard ? renderCard(item) : ( {renderCard ? renderCard(item) : (
<> <>

View File

@@ -34,6 +34,7 @@ const apartadoFake: ApartadoMiDia = {
unidadCodigo: 'D-101', unidadCodigo: 'D-101',
estado: 'En proceso', estado: 'En proceso',
fechaLimite: '2026-09-15', fechaLimite: '2026-09-15',
creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null, ultimoCambio: null,
} }

View File

@@ -80,9 +80,12 @@ describe('urlCotizadorParaOportunidad', () => {
etapa: 'Afluencia', etapa: 'Afluencia',
desarrollo: 'SENDA', desarrollo: 'SENDA',
modeloInteres: 'SOHO', modeloInteres: 'SOHO',
afluenciaCompleta: true,
vendedor: 'Martin Zavala', vendedor: 'Martin Zavala',
unidadCodigo: 'U-9', unidadCodigo: 'U-9',
origen: 'FACEBOOK',
updatedAt: '2026-09-04T10:00:00Z', updatedAt: '2026-09-04T10:00:00Z',
creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null, ultimoCambio: null,
} }
@@ -119,9 +122,12 @@ describe('urlAfluenciaParaOportunidad', () => {
etapa: 'Agendo Cita', etapa: 'Agendo Cita',
desarrollo: 'SENDA', desarrollo: 'SENDA',
modeloInteres: 'SOHO', modeloInteres: 'SOHO',
afluenciaCompleta: false,
vendedor: 'Martin Zavala', vendedor: 'Martin Zavala',
unidadCodigo: '', unidadCodigo: '',
origen: '',
updatedAt: '2026-09-04T10:00:00Z', updatedAt: '2026-09-04T10:00:00Z',
creadoEn: '2026-09-01T09:00:00Z',
ultimoCambio: null, ultimoCambio: null,
} }

View File

@@ -11,6 +11,8 @@ export interface LeadMiDia {
/** Vendedor asignado (nombre); '' si no tiene */ /** Vendedor asignado (nombre); '' si no tiene */
vendedor: string vendedor: string
updatedAt: string updatedAt: string
/** Fecha de alta (ISO) */
creadoEn: string
/** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */ /** Tablero del item: 'lead' (captura ligera) u 'oportunidad' (afluencia) */
fuente: 'lead' | 'oportunidad' fuente: 'lead' | 'oportunidad'
/** Último cambio conocido; null si no hay */ /** Último cambio conocido; null si no hay */
@@ -23,6 +25,8 @@ export interface ActividadMiDia {
tipo: string tipo: string
fecha: string // 'YYYY-MM-DD' fecha: string // 'YYYY-MM-DD'
estado: string estado: string
/** Captura (lead/oportunidad/apartado) ligada a la actividad; null si no aplica */
leadCapturaId: string | null
} }
export interface ApartadoPorVencer { export interface ApartadoPorVencer {
@@ -66,10 +70,16 @@ export interface OportunidadMiDia {
/** Vendedor asignado (nombre); '' si no tiene */ /** Vendedor asignado (nombre); '' si no tiene */
vendedor: string vendedor: string
unidadCodigo: string unidadCodigo: string
/** Canal/origen de donde llegó el lead (PASE, FACEBOOK, REFERIDO…) */
origen: string
updatedAt: string updatedAt: string
/** Fecha de alta (ISO) */
creadoEn: string
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null
/** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */ /** Fecha y hora de la cita agendada (ISO local, ej. '2026-09-09T14:30') */
fechaCita?: string fechaCita?: string
/** ¿Afluencia con perfil completo? Sin esto NO puede avanzar a 'Aparto' */
afluenciaCompleta: boolean
} }
/** Apartado asignado al vendedor (tab "Mis apartados") */ /** Apartado asignado al vendedor (tab "Mis apartados") */
@@ -85,9 +95,31 @@ export interface ApartadoMiDia {
unidadCodigo: string unidadCodigo: string
estado: string estado: string
fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene) fechaLimite: string // 'YYYY-MM-DD' ('' si no tiene)
/** Fecha de alta (ISO) */
creadoEn: string
ultimoCambio: { fechaISO: string; usuario: string | null; detalle: string } | null 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 { export interface ResumenMiDia {
generadoEn: string generadoEn: string
misLeads: LeadMiDia[] misLeads: LeadMiDia[]
@@ -100,6 +132,8 @@ export interface ResumenMiDia {
misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo misOportunidades: { porEtapa: Record<string, number>; items: OportunidadMiDia[] } // conteo + lista de trabajo
misApartados: ApartadoMiDia[] misApartados: ApartadoMiDia[]
todosApartados: ApartadoMiDia[] todosApartados: ApartadoMiDia[]
/** Descartados/abandonados (leads, oportunidades y apartados). Tab solo admin */
descartados: DescartadoMiDia[]
conteos: { conteos: {
leadsNuevosSemana: number leadsNuevosSemana: number
actividadesAbiertas: number actividadesAbiertas: number
@@ -125,7 +159,7 @@ export interface ResumenMiDia {
export const VOCABULARIO = { export const VOCABULARIO = {
// Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del // Estados de la columna status del board Leads (ESTADOS_LEAD_PERMITIDOS del
// API + NUEVO/DESCARTADO) // API + NUEVO/DESCARTADO)
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 del board; el API las valida tal cual) // Etapas del funnel de Oportunidades (labels REALES del board; el API las valida tal cual)
FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'], FASES_OPORTUNIDAD: ['Agendo Cita', 'Afluencia', 'Cotizador', 'Aparto', 'No Asistio', 'Abandono'],
// Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board) // Fases del pipeline Enkontrol del tablero Apartado (labels REALES del board)
@@ -271,6 +305,17 @@ export function urlCotizadorParaOportunidad(op: OportunidadMiDia): string {
return `/quote/?${params.toString()}` return `/quote/?${params.toString()}`
} }
/**
* Perfil de afluencia capturado de un lead/oportunidad/apartado
* (GET /api/afluencias/:leadId). Solo valores de texto no vacíos.
*/
export interface AfluenciaPerfil {
leadId: string
nombre: string
perfil: Record<string, string>
afluenciaCompleta: boolean
}
/** /**
* URL del formulario de Alta Afluencia (/client/) prellenado con los datos de * URL del formulario de Alta Afluencia (/client/) prellenado con los datos de
* la oportunidad. Al enviarlo, el API ACTUALIZA esa oportunidad (perfil * la oportunidad. Al enviarlo, el API ACTUALIZA esa oportunidad (perfil
@@ -301,6 +346,18 @@ export function formatoFechaCorta(fecha: string): string {
return `${d}/${m}` return `${d}/${m}`
} }
/** ISO → 'dd/mm/aaaa hh:mm' (hora local): bitácoras y notas con hora exacta */
export function formatoFechaHora(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const dd = String(d.getDate()).padStart(2, '0')
const m = String(d.getMonth() + 1).padStart(2, '0')
const y = d.getFullYear()
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${dd}/${m}/${y} ${hh}:${mm}`
}
/** Texto de urgencia de un apartado según los días que faltan */ /** Texto de urgencia de un apartado según los días que faltan */
export function textoDiasRestantes(dias: number): string { export function textoDiasRestantes(dias: number): string {
if (dias <= 0) return 'Vence hoy' if (dias <= 0) return 'Vence hoy'

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