Guía técnica/operativa para quien administra Octron (mantenedores, alertas, configuración). Documento vivo — se actualiza cada vez que se agrega o cambia una funcionalidad administrativa.
Última actualización: 14-oct-2026.
No incluido en el Manual de Operación (contenido exclusivo de administración).
Corre automático todos los días, feriados y fines de semana incluidos (6:00 hora de Chile): ingesta → Capa 1 (cierre de vencidas, catálogo y palabras) → Capa 2 (LLM) → digest → resumen ejecutivo. Ingesta y scoring corren siempre; el digest y el resumen ejecutivo solo se envían en día hábil (ver 1.1). Desde el 24-sep-2026.
api/src/jobs/ingest_mp.js/ingest_wherex.js) — Banco Central y CODELCO están documentados en la tabla fuente (con notas detalladas de cómo scrapear cada uno) pero el conector nunca se construyó, por eso están activa=false hoy y sin keywords cargadas (ver fuente.conector_creado y sección 2). Mercado Público lista una ventana de los últimos 7 días y trae solo el detalle de lo que falta (ver 1.2); un scan de ~4.000 licitaciones "únicas" en la ventana y ~450 nuevas por día es normal. Wherex se ingesta por su API JSON (ver 5.1).licitacion.score_actual).
excluida=true, motivo score_negativo: ...) y nunca llega al LLM.evaluacion.score, no decide nada más.clasificacion (Blue Ocean / Mega / Core / Stand / Descartado) según sus características, y una prioridad propia de 0 a 100 (el número que se ve en el digest), independiente del puntaje de Capa 1. Puede vetar duro (es_octopuss=false) sin importar nada de lo anterior.prioridad según catálogo de categorías (UNSPSC/Wherex) del segmento — corre antes del LLM desde el rediseño (Fase B), no después.v_licitaciones_aptas ya no filtra por score_actual (ese piso se eliminó en la Fase C, generaba falsos negativos) — el criterio real es clasificacion_id <> 5 (no descartada) + evaluada = 2 (ya pasó por Capa 2) + estado publicado + no excluida.ADMIN_NOTIFICACIONES_EMAIL, ver sección 2.Cada licitación se procesa una sola vez por capa, controlado por licitacion.evaluada (smallint, estados 0→1→2): 0 = recién ingestada, 1 = ya pasó Capa 1 (tiene score_actual), 2 = ya pasó Capa 2/LLM (tiene fila en evaluacion con motor='llm'). La ingesta sí refresca datos crudos de todo lo que ve cada día (fechas, documentos, estado — por eso hay "actualizadas" además de "nuevas"), pero eso no reinicia evaluada ni dispara un re-análisis. Si Capa 2 falla o agota reintentos (ej. por rate-limit), evaluada no avanza a 2 — la licitación queda pendiente y se reintenta en la próxima corrida, sin duplicar trabajo ni perder lo ya evaluado.
Si "llegan pocas licitaciones" en el digest y el pipeline no muestra errores (sin alertas en alerta_pipeline, [llm] finalizado — errores:0 en el log): antes de sospechar de un bug, comparar cuántas licitaciones el LLM clasificó como core_business/Mega/Core ese día contra el promedio — es normal que varíe día a día según lo que realmente se publicó en los portales. Consulta rápida:
SELECT date_trunc('day', created_at) AS dia, categoria, count(*), round(avg(prioridad)) AS prioridad_prom
FROM octopuss.evaluacion WHERE motor='llm' AND created_at > now() - interval '7 days'
GROUP BY 1,2 ORDER BY 1 DESC;
Si el volumen de ingesta ([MP] scan OK — vistos:N) también está bajo ese día, probablemente sea una baja real de licitaciones publicadas, no un problema del pipeline.
Si Groq agota su cupo (rate-limit o cuota diaria): desde el 29-jul-2026 el sistema lee el tiempo de espera real que manda Groq (antes usaba un cálculo fijo que podía estar mal — causa raíz de un incidente real el 28-jul donde el pipeline quedó ~5.5h esperando de más). Si el pipeline se ve lento o pausado sin error visible, es esperable mientras se libera cupo — revisar logs de octopuss-api buscando "en cooldown hasta" para ver cuánto falta.
pipeline_diario corre los 7 días de la semana (ejecucion_programada.dias_semana = 1-7). Antes corría solo lun-vie y consultaba un único día de Mercado Público, el que quedaba a 4 días hábiles atrás; una licitación tardaba ~6 días en llegar al digest y un feriado dejaba ese día casi vacío (el 24-sep el digest salió con 0 licitaciones por haber consultado el 18-sep, feriado).feriado (la misma del módulo RRHH → Feriados; el borrado es lógico, eliminado_en). En fin de semana o feriado el log dice [pipeline] hoy (AAAA-MM-DD) no es día hábil (…) — no se envía digest ni resumen ejecutivo. Lo evaluado queda en v_pendientes_digest (no tiene ventana de fechas) y sale completo en el siguiente día hábil; cada licitación se notifica una sola vez por destinatario (notificacion_envio).esDiaHabil() en api/src/lib/dias_habiles.js, usada por runPipeline (api/index.js).feriado: si no cubre el año en curso, esDiaHabil deja una alerta feriados_sin_cargar (una por semana) en el resumen ejecutivo — un feriado sin cargar se tomaría como día hábil y el digest saldría igual. 2026 y 2027 están cargados.POST /pipeline con {"forzarDigest": true} (ver 11.2). El botón "Ejecutar ahora" del mantenedor no lo fuerza.fecha_cierre > now()) al momento de armarse.ingestMPVentana() (api/src/jobs/ingest_mp.js):
MP_VENTANA_DIAS cambia la cantidad de días.evaluada=2 y no "descartada" (clasificacion_id<>5), o monitoreado o en HubSpot (unas ~130). No refresca lo cerrado, excluido ni aún sin evaluar.parcial (detalle en scan_ejecucion.mensaje_error). Línea de log: [MP] ventana: N únicas, M ya en BD, K nuevas por traer, R abiertas relevantes a refrescar.Costo medido el 24-sep: ventana de 7 días = 4.075 únicas, 2.780 ya en la BD, 1.295 por traer (~3,2 s cada una, unos 75 min, solo esa vez). Régimen normal: ~450 nuevas + refresco ≈ 30 min por corrida (antes 48-115 min). Pedir varios listados seguidos sin pausa da 429.
scoringCapa1() (api/src/jobs/scoring_capa1.js) empieza con dos UPDATE, antes de elegir candidatas:
evaluada 0 o 1, no excluida y fecha_cierre <= now() → excluida=true, motivo_exclusion='cerrada antes de analisis'. La ingesta ya marca así lo que llega cerrado, pero una licitación que cierra después sin cambiar de contenido quedaba sin marcar y sin evaluar para siempre (el 24-sep había 645). Sin fecha de cierre (NULL) no se toca.evaluada=2, clasificacion_id<>5, cerrada, no monitoreada ni en HubSpot y sin registro en notificacion_envio → 'cerrada antes de notificar' (nunca fue enviable).Log: [capa1] marcadas como cerradas antes de análisis/notificar: N. Además, las selecciones de capa 1 y del LLM filtran fecha_cierre > now(), así que una vencida no se evalúa. Para revertir un caso: UPDATE licitacion SET excluida=false, motivo_exclusion=NULL WHERE id=….
[pipeline] hoy … no es día hábil) y SELECT * FROM octopuss.feriado WHERE fecha = CURRENT_DATE.sudo journalctl -u octopuss-api --since today | grep "\[MP\]" → línea [MP] ventana: y scan … OK — únicas:… nuevos:….registros_vistos de sus escaneos en scan_ejecucion; varios días seguidos en 0 es una falla (ver 5.1).[llm] … en cooldown, todos los modelos agotados (ver "Si Groq agota su cupo", arriba).SELECT count(*) FROM octopuss.licitacion WHERE evaluada=1 AND NOT excluida AND (fecha_cierre IS NULL OR fecha_cierre>now()); · pendientes del digest SELECT plataforma, count(*) FROM octopuss.v_pendientes_digest GROUP BY 1;.es_octopuss=false) no se guardan en evaluacion ni imprimen la línea de progreso; medir con licitacion.evaluada (1 = pendiente de LLM, 2 = evaluada). Para volumen diario usar first_seen_at, no updated_at (los refrescos lo mueven).Correr cada etapa como proceso node independiente de octopuss-api (no lo corta un reinicio del servicio), con nohup y log en /srv/data/logs/:
ingestMPVentana(7) y ingestWherex().scoringCapa1().scoringLLM() (consume cupo de Groq).POST /pipeline con forzarDigest).Cada script debe cargar dotenv y invocar cargarSecretos() (sin eso no hay claves). Verificar antes que no haya un pipeline corriendo (pgrep -af pipeline). Si un proceso largo sigue vivo a las 06:00, cortarlo antes para que no se solape con el pipeline (comprobar /proc/<pid>/cmdline antes de kill); lo pendiente lo retoma el pipeline.
Cupo de Groq: una recuperación grande (~1.000 licitaciones) supera el cupo diario de gpt-oss-120b (queda en cooldown hasta ~23:12); el sistema baja por la cadena editable de modelos y espera los límites por minuto. La capa 1 deja pasar ~80 % y el LLM veta ~95 % de esas, por lo que el cupo se gasta sobre todo en descartar. El 24-sep se resolvieron 312 pendientes con 5 subagentes Claude Haiku en lotes de 54-65 (~505.000 tokens del plan de Claude Code): funcionó, pero hay que revisar la salida (un lote salió mal por respuestas genéricas y se re-evaluó) y no es un mecanismo del pipeline. Por ahora no se usa la API de Anthropic (decisión de Alexi).
El Resumen ejecutivo (correo aparte, mismo horario que el digest) es el panel de control diario del pipeline. Incluye:
alerta_pipeline (ingesta, scoring, envío).wherex_terminos_pendientes (Wherex exige aceptar términos y condiciones actualizados; una persona con autoridad debe entrar a system.wherex.com y aceptarlos, nunca desde código) y feriados_sin_cargar (falta cargar los feriados del año). El resumen ejecutivo sigue la misma regla de días hábiles que el digest: no llega en fin de semana ni feriado.fuente.conector_creado):
activa=false) por otro motivo (login vencido, incidencia puntual, etc.). Reactivarlo desde el Mantenedor → Segmentos sí vuelve a correrlo.Cada digest lleva un marcador oculto en el HTML (OCTRON-DIGEST-7F3A91-NO-REENVIAR-FUERA-DE-OCTOPUSS) que alimenta una regla de flujo de correo en Exchange Online, creada por Alexi el 28-jul-2026 (fuera del alcance de Octron — es una regla de Exchange, no de la app, así que no se mueve con el código ni con backups del repo).
Regla: "Alerta envío Digest fuera de @octopuss.cl"
NotInOrganization Y "El asunto o el cuerpo incluye cualquiera de estas palabras" = OCTRON-DIGEST-7F3A91-NO-REENVIAR-FUERA-DE-OCTOPUSS.alexi.zapata@octopuss.cl con Sender, Recipients, Subject, Cc, Bcc, Severity, RuleDetections, FalsePositive y AttachOriginalMail (llega el correo original completo adjunto, para ver exactamente qué se reenvió y a quién)."Este correo es el Digest diario de Octron, de uso interno de Octopuss y con información comercial confidencial. Su envío a destinatarios fuera de la empresa ha sido bloqueado. Ante cualquier duda, contáctate con alexi.zapata@octopuss.cl."
Efecto neto: si alguien reenvía el digest completo (con el HTML original, marcador incluido) a un dominio externo, el envío se bloquea y tanto el remitente como Alexi quedan notificados. No cubre reenvíos donde se borra el marcador a mano o se copia/pega solo el texto visible sin el HTML — sigue siendo una capa de protección, no un DLP infalible.
| Mantenedor | Qué configura |
|---|---|
| Palabras | Lista de keywords por fuente (positivas/penalidad), con peso. Solo 2 tipos, normalización de tildes/singular al guardar. |
| Categorías | Catálogo de categorías (UNSPSC/Wherex) usado en el bono de Capa 3. |
| Prompts | Cadena de modelos LLM (Groq/NVIDIA) y prompt de sistema — separado para scoring y para análisis de bases, editable sin deploy. Muestra modalidad de cada modelo, marca los ya no vigentes, permite cambiar de origen (ver Manual de Operación §Ventas para el detalle de uso). |
| Segmentos | Unidad central de configuración por portal: fuente, lista de palabras, catálogo, prompt, destinatarios del digest (emails_to), origen de oportunidad en HubSpot. |
| Parámetros | Tipos de ausencia y color de feriado (módulo RRHH). |
Restricción Vacaciones (/rrhh/restriccion-vacaciones) |
Grupos/departamentos que no debieran coincidir de ausencia + configuración del SOP de vacaciones (anticipación mínima, bloqueo/recordatorio de temporada de verano, mensaje recordatorio) — ver sección 15. |
| Administrador de usuarios | Roles (admin_roles, licitaciones_admin, ventas_admin, ventas_ver, novedades_admin, metabase_acceso) — Entra ID solo autentica, los roles viven en tablas propias. Los roles de una sesión se "congelan" al login. |
| Ejecuciones | Horarios de todos los jobs programados del sistema (pipeline diario, sync HubSpot, sync Entra ID, sugerir keywords, etc.) — ver sección 11. |
Strety-HubSpot (/admin/strety-hubspot) |
Sincroniza Cola/link/equipo de tareas HubSpot con origen Strety — ver sección 16. |
Ficha de Trabajadores (/rrhh/trabajadores) |
RUT, nombre legal, fecha de ingreso, saldo inicial de vacaciones, organigrama de cargos propio, override de aprobador y suplente por persona — ver sección 17. |
Job que detecta licitaciones donde Capa 1 no encontró nada pero Capa 2 (LLM) sí la consideró relevante, y le pide a un LLM que sugiera frases específicas (nunca palabras sueltas ambiguas) para agregar a la lista, con un peso calibrado.
hubspot_property_option.sugerencia_keyword, nunca se agregan solas — hay que revisarlas y agregarlas manualmente en el mantenedor de Palabras.POST /scoring/sugerir-keywords (API interna).tipo IN ('bases_administrativas','bases_tecnicas') — el resto (otro, anexo, boilerplate como código de ética o formularios de proveedor) se excluye a propósito, no aporta.Desde el 10-sep-2026 Wherex reemplazó el listado (purchase-bids/all) por una página nueva hecha con React (purchase-bids/all-react): el scraper HTML veía 0 registros sin error durante 13 días. api/src/connectors/wherex.js ahora usa la API JSON de esa página (solo GET, lectura):
GET /secured/supplier_user/supplier-core/invitations?status=published&page=&limit=<=100&sort= (pestaña "Nueva"). Ítems: GET …/invitations/requests/batch?ids=<ids de invitación>.listar(), obtenerDetalle()), así que ingest_wherex.js casi no cambia. Trae más datos: título real (si Wherex pone el genérico "Licitación N°… de Bienes/Servicios" se usa el primer ítem), fechas ISO con zona horaria, categoría oficial de Wherex por ítem (licitacion_item.codigo_categoria/categoria), marca y modelo, y en raw.detalle.api el RUT/sector/país del comprador, monto estimado (sin moneda, no se guarda en monto_estimado), plazo original, etapa, express/dirigida/rfi, puntaje de recomendación de Wherex e id de invitación. Se pierden las condiciones de pago (la API no las trae).listar() usa el listado antiguo con ?legacy=1, que Wherex avisa que estará disponible solo hasta el 2-oct-2026; después de esa fecha una falla de la API dejará de tener respaldo./terms_and_conditions (alerta wherex_terminos_pendientes), a /all-react o a cualquier página que no sea el listado; login vencido → alerta wherex_login_fallido. Un login desde cero puede pedir aceptar los términos; el conector con sesión guardada (api/.wherex-state.json) no lo pide. Nunca se aceptan términos ni se pulsa "Quiero participar"/"No me interesa" desde código./secured/api/1/purchasebids/{id}/all/files) responde 401.…/invitations?status=<x>&scope=supplier): accepted = Adjudicada, closed = En evaluación, rejected = ofertas enviadas no adjudicadas, cancelled, discarded (no interesó), además de published/in_progress/sent; un valor inválido devuelve HTTP 500. offer.status y offer.awarded indican si Octopuss ofertó y si ganó.registros_vistos de los escaneos de Wherex en scan_ejecucion debe ser > 0 casi todos los días hábiles; varios días seguidos en 0 es una falla.El catálogo Wherex (catalogo_categoria.id=4, 69 categorías) está asignado a los segmentos Wherex - Productos y Wherex - Servicios (segmento_digest.catalogo_categoria_id). Sus codigo son los node.id de la API de Wherex, los mismos de licitacion_item.codigo_categoria. La capa 1 suma al puntaje el mejor peso entre las categorías de los ítems (+20 por cada ítem adicional que calce, tope 300) y resta las exclusiones.
| Peso | Categorías |
|---|---|
| 200 | Cámaras de vigilancia (344), instalación y mantenimiento de equipos de vigilancia (780), citofonía y alarmas (887) |
| 100 | 19: cámaras, seguridad y vigilancia, cables y conectores, patch, switch y routers, almacenamiento, UPS, servicios TI y de comunicaciones, PC/notebooks, monitores, antenas, generadores, balizas y sirenas |
| 50 | 17: hardware TI genérico, sensores, software, periféricos con actividad real (parlantes, teclados, mouse, repuestos PC, celulares), radios, CPU |
| 25 | 21: adyacentes (fuentes de poder, suministro eléctrico, componentes electrónicos, ingeniería…) |
| 0 | 8: PLC, controladores, calculadoras, CD/DVD, presentadores, servicios en planta de procesos |
| −500 | Sistemas contra incendios (944) — exclusión de negocio |
Los pesos salen de la experiencia comercial y del historial real de ofertas de Octopuss en Wherex (24-sep-2026: 5 adjudicadas, 3 en evaluación, 360 ofertas no adjudicadas). Como Wherex solo invita a las categorías suscritas, el catálogo ordena más que filtra: el gate de capa 1 solo descarta totales negativos. Los ítems cuyo código no calza quedan en licitacion.catalogo_codigos_sin_match para el flujo de sugerencias. Los pesos se editan en el mantenedor de categorías (tabla categoria); los anteriores (todas en +50) están respaldados en /srv/data/logs/wherex_catalogo_pesos_previos_24sep.txt.
hubspot_* en base propia (hubspot, no octopuss_ia) para Metabase.hubspot_pipeline, hubspot_deal_stage, hubspot_owner, hubspot_property_option) resuelven códigos a nombres reales — se mantienen solas vía la reconciliación diaria.v_negocios en la base hubspot: un negocio por fila, con todos los códigos ya resueltos a texto — usar esto para reportes nuevos en vez de la tabla cruda hubspot_deal.Desde el 29-jul-2026, casi ninguna API key ni contraseña vive en /srv/app/.env — están cifradas (AES-256-GCM) en la tabla octopuss.credencial, cargadas en memoria al arrancar cada servicio. .env solo guarda los 2 secretos mínimos de arranque (DATABASE_URL, CREDENCIALES_ENC_KEY) más la config no-secreta y lo que usa el backup (Restic/NAS, fuera de la app).
Para rotar una API key (Groq, NVIDIA, HubSpot, etc.): cifrarla y actualizar la fila correspondiente en octopuss.credencial, después reiniciar el servicio (octopuss-api o octopuss-portal según cuál la use).
API_INTERNAL_KEY (11-ago-2026) sigue el mismo mecanismo — protege los triggers manuales de api/ (/pipeline, /scoring/*, /digest, etc.) contra acceso sin autorización. Ver §11.2 para usarla en el respaldo manual de un job.
Dump SQL diario + Restic (NAS, 192.168.1.8) a las 02:00. Incluye /srv/claude-cache (memoria de Claude). Regla permanente: toda nueva implementación debe quedar contemplada en BACKUP_DIRS.
| Servicio | Qué hace |
|---|---|
octopuss-api |
API principal (puerto 3000) — pipeline, digest, HubSpot, scoring. |
octopuss-portal |
Portal Octron (puerto 3100 real, no 3000). |
octopuss-ejecuciones-scheduler |
Checker del Mantenedor de Ejecuciones (corre cada minuto) — ver §11. |
octopuss-metabase, octopuss-html2pdf |
BI, generación de PDF. |
octopuss-portal-clientes |
Portal de Clientes externo (puerto 3200, ver §12) — único servicio de la flota que NO corre como root: usuario de sistema propio svc-portal-clientes, sin privilegios. |
octopuss-n8n (contenedor Docker, 15-ago-2026) |
Ya NO es un servicio systemd — n8n corre como contenedor Docker (docker compose -f /srv/app/n8n-docker/docker-compose.yml), misma versión (2.30.4), mismo Postgres. El .service viejo sigue instalado pero deshabilitado, solo como camino de reversión — ver §13. |
octopuss-wiki (contenedor Docker, 15-ago-2026) |
Módulo Wiki (Wiki.js), puerto 3500 local, wiki.octopuss.cl — ver §14. |
Reiniciar cualquiera de estos siempre requiere confirmación explícita antes de ejecutar — regla del equipo, sin excepción.
Nota (11-ago-2026): octopuss-pipeline, octopuss-bases-resumen, octopuss-sugerir-keywords y octopuss-wherex-bases (timers + services) ya no existen — se borraron del servidor. Habían quedado deshabilitados desde que el Mantenedor de Ejecuciones los reemplazó (§11) y hacían curl directo a api/ sin ningún header de auth, lo que dejó de funcionar cuando se agregó auth real a api/index.js (ver §7). El procedimiento manual de respaldo si el Mantenedor falla está en §11.2.
Desde el 30-jul, la BD interna de Metabase (dashboards, preguntas, usuarios, grupos, permisos) vive en Postgres (base metabase, rol metabase_app), no en el archivo H2 original. Motivo: H2 es un archivo único sin WAL robusto, vulnerable a los cortes de luz del edificio.
/srv/app/metabase/.env (MB_DB_TYPE=postgres, MB_DB_CONNECTION_URI=...) — excepción deliberada a la regla de secretos en octopuss.credencial: Metabase lee esta URI al arrancar la JVM, antes de que exista código nuestro que pueda descifrarla desde la BD./srv/app/metabase/metabase.db.mv.db): revertir las MB_DB_* del .env y reiniciar — el H2 original queda intacto como respaldo. Un respaldo adicional pre-migración quedó en /srv/data/backup/metabase.db.mv.db.pre_migracion.metabase está sumada al dump diario (backup_bd.sh / restic_backup.sh) igual que octopuss_ia, hubspot, dashboard_ventas y octopuss_portal.Un usuario con el rol metabase_acceso hace clic en "Informes" en Octron y entra a Metabase ya logueado, sin formulario ni contraseña. Mecanismo (ver portal/src/routes/metabase_sso.js + portal/src/lib/metabaseSync.js):
/auth/metabase/ir (requiere sesión + rol metabase_acceso) genera un token de un solo uso (60s, guardado hasheado en metabase_handoff) y redirige a https://metabase.ia.octopuss.cl/octron-sso?t=....location = /octron-sso en el vhost de metabase.ia.octopuss.cl) reenvía al portal, bajo el hostname de Metabase — así las cookies de sesión quedan host-only de Metabase, sin filtrarse a otros subdominios.mantenedor_auditoria (tabla metabase_handoff).Consecuencia: si el portal está caído, nadie entra a Metabase vía este mecanismo (ver break-glass, 10.4).
/admin/usuarios)Al asignar el rol metabase_acceso a alguien, el mantenedor muestra un combo con los grupos reales de Metabase (GET /api/permissions/group, sin ids hardcodeados) para elegir a cuál queda asignado, además de "All Users" (automático, nadie puede salir de ahí). Cambiar el grupo más adelante no requiere sacar y volver a poner el rol — el combo detecta el cambio solo.
Si se borra en Metabase un grupo que tenía gente: Metabase no avisa nada (204 silencioso), la persona queda solo con "All Users". El mantenedor lo detecta comparando el metabase_grupo_id guardado contra la lista viva y muestra un badge rojo "grupo eliminado en Metabase — reasignar". Mientras tanto la persona igual puede entrar (con acceso mínimo), no queda bloqueada.
🔒 Por qué "All Users" con acceso mínimo importa: el 30-jul se encontró que "All Users" tenía acceso irrestricto (view-data: unrestricted, write en todas las colecciones) — cualquier cuenta veía y podía editar todo, sin importar su grupo. Se corrigió a view-data: legacy-no-self-service (sin descarga) + read en colecciones — el tope de free-tier de Metabase OSS, que no tiene el "blocked" total (es función premium). Si se crea una base o colección nueva en Metabase, revisar que "All Users" no quede con acceso amplio por defecto.
Metabase no tiene SMTP configurado (decisión deliberada — activarlo habilitaría "olvidé mi contraseña" para cualquiera, no solo para Alexi, y permitiría saltarse el portal). Tres capas, de más a menos disponible:
/admin/usuarios, protegido por admin_roles): "Restablecer mi contraseña de Metabase" — genera una password nueva y la muestra una sola vez. Camino diario si el handoff falla pero el portal sigue arriba.java -jar /srv/app/metabase/metabase.jar reset-password alexi.zapata@octopuss.cl
Imprime un token de un solo uso; navegar a /auth/reset_password/<token>. Funciona sin SMTP y sin el portal. Ya no requiere detener el servicio primero (esa restricción era propia de H2, resuelta con la migración a Postgres del 10.1).MAX_SESSION_AGE: se dejó en el default (14 días)Decisión explícita de Alexi (30-jul-2026): no acortar MAX_SESSION_AGE pese a que el plan original lo sugería (12-24h). Motivo: hay personas que no apagan su equipo por semanas, y si hace falta bloquear a alguien no depende de esto — desactivar el usuario (DELETE /api/user/:id) mata su sesión activa al instante (verificado en vivo). No es un pendiente, es una decisión tomada.
Consolida todos los jobs que antes eran timers systemd sueltos en una tabla (ejecucion_programada) + un checker único que corre cada minuto (octopuss-ejecuciones-scheduler.timer) y dispara cada job en el horario configurado ahí — cambiar un horario ya no requiere tocar un archivo systemd ni reiniciar nada, se edita directo en el mantenedor.
claude_agent, miércoles 03:00 — aplica correcciones y reinicia servicios sola, ver bitácora 11-ago-2026).pipeline_diario (el único con efecto masivo real: manda el digest a todo el equipo comercial) hay además un botón separado "🧪 Test (digest solo a mí)", que corre el pipeline real pero manda el digest solo a quien lo aprieta y no lo cuenta como envío real — usar ese para probar, no "Ejecutar ahora", que sí manda el digest real a todos..timer/cron nuevo por fuera del mantenedor.Los jobs corren en paralelo, no en fila. Hasta el 24-ago-2026 el checker esperaba a que un job terminara antes de poder revisar/disparar el siguiente — un job largo (ej. el pipeline diario, ~90-120 min) tapaba a cualquier otro que le tocara mientras tanto, incluido el chequeo horario de la credencial de Codelco/Ariba (se saltaba su turno de las 06:20 todos los días, sin que nadie se enterara). Decisión de Alexi: el servidor tiene CPU de sobra, así que ahora cada job se dispara en un proceso propio e independiente — el checker de cada minuto solo reclama y lanza, no espera, y el minuto siguiente puede lanzar otro job aunque el anterior siga corriendo. Si en algún momento el uso de CPU/memoria se vuelve un problema real (varios jobs pesados de verdad al mismo tiempo, no solo cruzándose unos minutos), la vía es agregar más vCPU a la VM en OctopusVM (XCP-ng), no volver a serializar.
Aviso de solapamiento (informativo, no bloquea nada): el propio Mantenedor de Ejecuciones (/admin/ejecuciones) muestra un banner arriba de la tabla cuando 2 o más jobs probablemente van a estar corriendo al mismo tiempo — calculado con la duración promedio real de cada uno (la misma columna "Promedio" que ya se ve en la tabla), no una suposición. No impide que corran ni cambia ningún horario — es solo para que quede a la vista si vale la pena mover algún horario o vigilar el uso de recursos ese rato.
Qué pasa si el servidor estuvo apagado: el checker no solo corre cada minuto en punto — también dispara 2 minutos después de que el servidor arranca, para ponerse al día rápido. Busca el job más atrasado (no el que "tocaba justo ahora") y lo va disparando de a uno por minuto hasta ponerse al día. Cada job tiene una ventana de atraso (hoy 4 horas para todos) — si el servidor estuvo apagado más tiempo que eso después de la hora programada de un job, ese turno puntual se da por perdido (no se corre atrasado indefinidamente) y espera al próximo turno normal. Si un turno se pierde así, llega el mismo correo de aviso que cuando un job falla.
Solo para cuando el Mantenedor mismo está inaccesible (octopuss-portal caído, o el checker octopuss-ejecuciones-scheduler no dispara) pero octopuss-api sigue arriba y hace falta correr el job igual. Con el Mantenedor sano, siempre usar el botón "Ejecutar ahora" — esto es solo el respaldo manual.
Hasta el 11-ago-2026 existían 4 servicios systemd (octopuss-pipeline, octopuss-bases-resumen, octopuss-sugerir-keywords, octopuss-wherex-bases) que hacían justo esto con un curl directo, ya deshabilitados desde la migración al Mantenedor. Se borraron ese día porque además de obsoletos, ya no funcionaban: api/index.js ahora exige el header x-internal-key con el valor de API_INTERNAL_KEY (cifrado en octopuss.credencial) en estas rutas, y esos servicios nunca lo mandaban.
Procedimiento manual actual (por SSH, como alzaque o con sudo): api/scripts/disparar_job_manual.js — hace la petición HTTP desde dentro del propio proceso node, con la clave descifrada en memoria del proceso. A propósito NO se usa un curl con la clave pasada por variable de shell: se probó esa forma primero y falla — el banner que imprime dotenv al cargar se mete en la captura y corrompe el valor (produce un header inválido, 400 Bad Request). El script evita el problema por diseño: la clave nunca sale del proceso ni pasa por una variable de shell.
cd /srv/app/api
# Pipeline diario completo (ingesta → capa1 → capa2 LLM → digest real a todo el equipo;
# en fin de semana o feriado el digest no sale, salvo que se fuerce)
node scripts/disparar_job_manual.js /pipeline
node scripts/disparar_job_manual.js /pipeline '{"forzarDigest":true}' # envía el digest aunque no sea día hábil
# Análisis de bases con IA (Wherex, resumen "por qué ir/por qué no ir")
node scripts/disparar_job_manual.js /bases/resumen '{"limite":10}'
# Sugerencias de keywords con IA
node scripts/disparar_job_manual.js /scoring/sugerir-keywords '{"limite":200}'
# Descarga+conversión de bases pendientes de Wherex
node scripts/disparar_job_manual.js /wherex/procesar-bases
Antes un job tipo_horario='horario' corría una vez por hora, en el minuto que se le configurara (ej. minuto 15 de cada hora), sin poder acotarlo a un rango del día ni a días específicos. Ahora cada job horario tiene:
cotizador_leer_correos corre cada 5 minutos en vez de cada hora. El minuto original del job sigue marcando la fase (a qué minuto exacto de la hora cae el primer disparo), así que un job que ya corría no cambia sus horarios reales al no tocarle nada.Servicio nuevo (12-ago-2026), separado de Octron: cada cliente ve en vivo (sin
caché, directo contra la API de HubSpot) los negocios de su empresa y puede
crear/consultar tickets de soporte. Detalle técnico completo en la memoria
portal-clientes-arquitectura.
Solo desde Octron: Administración → Portal de Clientes (rol
admin_roles o clientes_portal_admin, /admin/clientes-acceso). Buscar la
empresa por nombre (autocompleta contra HubSpot en vivo — nunca se escribe el ID
a mano), completar el email de la persona y guardar.
clientes.octopuss.cl/login — no es un magic link (esos se generan soloclientes.octopuss.cl, escribe su email y recibeclientes.octopuss.cl ya está en producción: registro A en BanaHosting (Alexi)
→ NAT pfSense (ya cubierto, es a nivel de puerto) → vhost nginx +
certificado Let's Encrypt real (certbot --nginx, renovación automática,
vence 10-nov-2026) → octopuss-portal-clientes puerto 3200. Mismo patrón que
el resto de subdominios del proyecto — no vía túnel de Cloudflare
(octopuss.cl no existe como zona en esa cuenta, ver
[[infrastructure-red-real]]). Segunda capa de rate-limit agregada a nivel de
nginx (/etc/nginx/conf.d/portal-clientes-rate-limit.conf, limit_req en
/login y /acceso), además de la propia de la aplicación. Probado en vivo
de punta a punta contra el dominio real (login completo, cookie de sesión
correcta).
RESEND_API_KEY cifrada en credencial (mismo mecanismo que el restotickets en el Private App "Octron" (HubSpot → Configuración →tickets-read +tickets-write): sin esto, crear/consultar tickets falla con un 403 realoctopuss-portal-clientes — a diferencia del resto de la flota (que corre como
root), este corre como el usuario de sistema svc-portal-clientes (sin
login, sin privilegios, creado 12-ago-2026): es el único proceso del proyecto que
recibe tráfico público sin autenticación previa, así que si se ve comprometido
no tiene privilegios de sistema. Su propio .env (/srv/app/portal-clientes/.env,
chmod 640, dueño alzaque grupo svc-portal-clientes) es un archivo separado
del /srv/app/.env compartido — nunca lo carga, a propósito (ver §7 y la memoria
de arquitectura para el detalle de por qué).
Imprime HTTP <código> + el cuerpo de la respuesta (nunca la clave). Probado en vivo el 11-ago-2026 con limite:0 (no-op real) contra /wherex/procesar-bases: HTTP 200, journalctl limpio.
Importante: el pipeline manda el digest real a todo el equipo comercial — no correrlo dos veces sin necesidad (revisar primero journalctl -u octopuss-api por si ya corrió).
api/index.js, 14/15-ago-2026)Hallazgo de la auditoría cyber-neo del 12-ago: la API no tenía ningún límite de
requests por IP. Implementado con express-rate-limit
(api/src/middleware/rateLimit.js) — umbrales amplios a propósito (decisión
de Alexi: la prioridad es enterarse de cuándo se supera un límite, no bloquear
uso legítimo).
| Limiter | Umbral | Dónde aplica | Se cuenta por |
|---|---|---|---|
limiterGlobal |
300 req / 15 min | Toda la API (app.use global, antes de cualquier ruta) |
IP |
limiterPublico |
60 req / 15 min | /hubspot/*, /ejecuciones/ejecutar-confirm, /ejecuciones/ignorar, /ausencias/tomo-conocimiento, /ausencias/objetar-vacaciones — los endpoints sin sesión ni API key, protegidos solo por un token de un solo uso en la URL (clic desde un correo) |
IP — presupuesto compartido entre los 3 routers, no uno por ruta (misma instancia de limiter reusada en hubspot.js, ejecuciones_confirm.js y ausencias_gerencia_confirm.js) |
limiterPixel |
100 req / 15 min | /pixel/:token.gif (tracking de apertura del digest) |
Token del pixel, no IP — Outlook Safe Links y proxies corporativos precargan la imagen desde IPs de Microsoft compartidas por todo el tenant, no la IP real de quien abrió el correo; limitar por IP ahí habría bloqueado aperturas legítimas del equipo comercial |
limiterGlobal es defensa en profundidad incluso sobre las rutas con
requireInternalKey (§7) — si esa clave se filtrara, sigue habiendo un techo.
Detalle de enrutamiento (importante si se agrega una ruta nueva):
ejecuciones_confirm.js, ausencias_gerencia_confirm.js y pixel.js se montan
en index.js con app.use('/', router) (prefijo /, no uno propio) — poner
router.use(limiterX) en esos archivos aplicaría el límite a cualquier
request que pase por la cadena, no solo a las rutas de ese archivo. Por eso el
limiter va pegado a cada router.get/post(...) individualmente en esos 3
archivos. hubspot.js sí está montado en su propio prefijo (app.use('/hubspot', hubspotRouter)), así que ahí router.use(limiterPublico) es seguro.
Dos capas, nunca en silencio:
journalctl -u octopuss-api | grep "rate-limit" — muestra tipo, IP y rutarate_limit_global / rate_limit_publicorate_limit_pixel) se inserta una fila en alerta_pipeline — mismo(tipo, día) — si el mismo tipo se repite muchas veces el mismo día, noNo hay correo/alerta en el momento — se entera al revisar el Resumen ejecutivo
del día siguiente, o antes si alguien mira journalctl. Si algún día hace falta
enterarse en el momento (ej. sospecha de ataque activo), agregar un envío
inmediato en handlerConAlerta() (rateLimit.js) es un cambio chico.
Con la IP y la ruta de journalctl:
infrastructure-red-real en memoria)./pixel → esperable (variosEditar los objetos en api/src/middleware/rateLimit.js (windowMs/limit de
cada limiter) y reiniciar octopuss-api (con confirmación, §9). No hace falta
tocar la BD ni el schema — alerta_pipeline ya es una tabla genérica que no
necesita cambios. Si se agrega una ruta pública por token nueva, agregar
limiterPublico como middleware de esa ruta específica (nunca router.use() si
el router está montado en /, ver §13.1).
Reemplaza los 2 archivos .md sueltos (docs/manuales/manual_administrador.md/
manual_operacion.md) como forma de mantener la documentación de Octron — ahora
vive en una wiki real, con historial de versiones, búsqueda y permisos, en
https://wiki.octopuss.cl. Decisión de Alexi (15-ago-2026): adoptar Wiki.js
en vez de construir un motor de wiki propio — no tenía sentido reinventar
editor/permisos/búsqueda cuando hay proyectos maduros que ya lo resuelven bien.
Investigado también Notion y NotebookLM como alternativas — descartados como
núcleo por costo por asiento y por no tener API pública estable para este uso
respectivamente (detalle en la memoria del proyecto).
ghcr.io/requarks/wiki:2, pin a la versión, nolatest), /srv/app/wiki-docker/ (docker-compose.yml + .env + config.yml
data/), network_mode: host (mismo patrón que n8n — evita configurar127.0.0.1 (no el default 3000 de Wiki.js — eseoctopuss-api). El puerto no se cambia por variable deconfig.yml (port:/bindIP:), que acá$(DB_HOST) etc. vía plantilla).wiki en el mismo Postgres, rol wiki_app — mismo patrón quemetabase/hubspot/dashboard_ventas (BD separada, no un schema dentro deoctopuss_ia). Connection string cifrada en credencial (WIKI_DATABASE_URL).wiki.octopuss.cl — mismo patrón A record + NAT + nginx + certbot --nginxWIKI_API_TOKEN en credencial, generado por Alexi desdehttp://127.0.0.1:3500/graphql, headerAuthorization: Bearer <token>).Los 2 manuales viejos se cargaron como las primeras páginas reales
(manual-administrador, manual-operacion) — el proceso de mantenerlos
actualizados sigue igual (ver [[octron-manuales-documentacion]]), pero ahora se
edita en la wiki, no en los .md del repo. Los .md originales se conservan en
docs/manuales/ como respaldo/histórico, no se borran.
Octopuss es partner Genetec (confirmado por Alexi) — copia documentación
técnica pública de techdocs.genetec.com a la wiki, de uso interno y
autenticado.
/sitemap/unstructured/1.xml,/sitemap/structured/1.xml — permitidos explícitamente en su robots.txt)/wiki/genetec-temas, rolwiki_admin/admin_roles) — tabla wiki_genetec_tema, editable sin tocartechdocs.genetec.com/v/u/...)/api/khub/documents/{id}/content, que sí es unmarker/marker_single que las bases de licitacioneslimite en el body del trigger).api/src/jobs/wiki_ingest_genetec.js, endpointPOST /wiki/ingest-genetec (requireInternalKey), programado en elwiki_ingest_genetec (lunes a viernes 04:00,wiki_genetec_sincronizado — evita reprocesar lo yahash_contenido para detectar si Genetec actualizó el PDF, ypendiente/ok/error) visible en el propio mantenedor.Las páginas del conector Genetec entran en su idioma original (inglés en la
mayoría de los casos) — un job aparte las traduce al español de a poco, sin
bloquear la carga.
prompt_scoring_grupo): tabla prompt_wiki_traduccion_grupo/wiki/genetec-temas, rol wiki_admin/admin_roles).wiki_traduccion_cola — se arma comparando las páginas enlocale=en de Wiki.js contra lo ya traducido; visible con sus estadosapi/src/jobs/wiki_traducir.js, endpoint POST /wiki/traducirrequireInternalKey, body {"presupuestoMinutos":25}). Traduce páginas## si el documento eswiki_traducir (lunespresupuestoMinutos: 25).locale=es (Wiki.js permite verPROMPT_SISTEMA en el job.Mismo mecanismo que Metabase (§10.2), adaptado a que Wiki.js devuelve el JWT
directo en la respuesta GraphQL del login (no hay Set-Cookie que reenviar
como en Metabase):
GET /auth/wiki/irportal/src/routes/wiki_sso.js, gateado solo con requireAuth enportal/index.js).wiki_handoff (mismo shape que metabase_handoff), y se redirige ahttps://wiki.octopuss.cl/octron-sso?t=<token>.location = /octron-sso en el vhost de wiki.octopuss.clhttp://127.0.0.1:3100/auth/wiki/consumir — el truco dejwt que se arma queda host-only ahí, no en octron.octopuss.cl./auth/wiki/consumir (sin requireAuth a propósito — el token de unemail/nombre/si el usuario tiene wiki_admin o admin_roles, yportal/src/lib/wikiSync.js:
asegurarUsuarioWiki() — crea el usuario en Wiki.js si no existe, y enadmin_roles/wiki_admin → grupomanage:system); cualquier otrocrearSesionWiki() — acuña una password aleatoria, la aplica alauthentication.login,strategy:"local") y devuelve el JWT. La password nunca se persiste nijwt en la respuesta (secure, sameSite:lax,https://wiki.octopuss.cl/ ya logueado.Grupos de Wiki.js — no hay mantenedor de grupos como en Metabase (esa
UI no se construyó a propósito, el diseño es binario: admin o no). Si en el
futuro hace falta un grupo intermedio, crearlo a mano en el panel admin de
Wiki.js y agregar su lógica en asegurarUsuarioWiki().
Servidor MCP real (para que Claude u otra IA consulte la wiki con los
privilegios reales de cada usuario, vía OAuth contra el mismo Entra ID del
portal — hoy la carga de contenido es solo vía script/job con el token de API
de Wiki.js, no hay servidor MCP corriendo todavía). Plan completo en
/home/alzaque/.claude/plans/ethereal-hopping-mountain.md.
Implementa el procedimiento oficial de RR.HH. "SOP SOLICITUD DE VACACIONES" (código
51) encima del módulo Solicitud de Ausencias existente — solo aplica a
tipo_ausencia.codigo = 'vacaciones', el resto de los tipos de ausencia no se
tocaron. Plan completo:
docs/superpowers/plans/2026-08-28-solicitud-vacaciones-sop.md.
Antes, jefatura y RR.HH. aprobaban una solicitud de vacaciones en paralelo. Ahora
RR.HH. revisa primero — si RR.HH. rechaza, el tramo queda rechazado sin que
jefatura llegue a verlo ni a recibir correo. Recién cuando RR.HH. aprueba, la fila
de jefatura se "habilita" (columna ausencia_tramo_aprobacion.habilitada) y le
llega el correo de aprobación. Al quedar el tramo resuelto (aprobado o rechazado),
se avisa por correo a solicitante + jefatura + RR.HH., con el detalle de quién
resolvió qué.
Quién es "RR.HH." — Titular explícito (13-sep-2026): antes era implícito (el
rrhh_admin activo con menor id). Ahora se elige a propósito en el panel "Titular
RRHH" dentro de Ficha de Trabajadores (parametro.rrhh_principal_usuario_id),
con su propio selector de Suplente ahí mismo — si el titular tiene una ausencia
aprobada vigente ese día, se sigue automáticamente a su Suplente, mismo mecanismo que
ya usa un jefe (usuario_suplente/estaAusente). Si el parámetro no está configurado
o el usuario elegido ya no está activo, cae de vuelta al criterio implícito viejo como
respaldo. El panel avisa en rojo si el titular actual no tiene Suplente activo. Ver
sección 17 para el resto del mantenedor Ficha de Trabajadores (organigrama de cargos,
override de aprobador, etc.).
Suplencia de jefatura (13-oct-2026). Caso real que lo motivó: la solicitud 4 quedó
con Tali Haviv estando de viaje (registrado en el Registro de Ausencias manual desde
antes). Tenía 3 causas, las tres corregidas (portal/src/lib/aprobadorAusencia.js):
estaAusente no leía el Registro manual. Ahora considera ausente a quien tenga unausencia_cuenta (por email) que cubra la fecha.tipo_ausencia.redirige_a_suplente_si_ausente = truevacaciones, también en schema_Octron.sql; antes estaba en false a propósitoreasignarJefaturaAusenteaprobador_reasignado en el historial de la solicitud y marcaresolverAsignadoJefe define a quién va: la jefatura cuenta como ausente si lo está
hoy o, para tramos futuros, el día de inicio del tramo. Si está ausente y el tipo
redirige: queda con quien vuelva primero entre la jefatura y su suplente
(fechaRegreso encadena ausencias contiguas; empate → jefatura); sin suplente → RR.HH.
principal. No encadena a un tercero.
Un correo por decisión. avisarHabilitacionJefatura(solicitudId, items) envía un solo
correo por jefatura con todos los periodos habilitados en la misma decisión del aprobador
anterior (resolver-lote); decisiones separadas → correos separados. La tabla "Ya resuelto"
agrega una columna Periodo cuando agrupa varios.
/rrhh/restriccion-vacaciones)Renombrado desde "Grupos de restricción" (la URL cambió, el API interno
/api/grupos-restriccion no). Tres secciones nuevas, además de los grupos
existentes:
ausencias_verano_recordatorios en el Mantenedor de Ejecuciones (portal/scripts/ ausencias_verano_recordatorios.js, diario 07:30) — envía los recordatorios de
verano configurados que coincidan con la fecha de hoy, y el día hábil anterior al
30-nov manda un aviso de cierre a quienes tengan rol admin_roles/rrhh_admin
(una sola vez por año, con link directo al mantenedor).
portal/src/lib/alertasProcedimiento.js::calcularAlertasProcedimiento() — una
sola función, usada tanto por el correo de aprobación como por el detalle de la
solicitud en el portal, para no duplicar la lógica. Detecta: anticipación no
cumplida (modo Alerta), verano fuera de plazo (modo Alerta), saldo de vacaciones
insuficiente, incompatibilidad de grupo de restricción, abono de licencia médica
sobre vacaciones ya aprobadas, y jefatura ausente sin redirigir (hoy solo si el tipo no redirige o si la jefatura vuelve antes que su suplente, ver §15.1). La alerta de saldo se omite para quien no tiene saldo (sin control de vacaciones o ficha sin fecha de ingreso, §17.5). Se muestran juntas
en un recuadro (amarillo en el correo), cada tipo con su propio ícono.
avisarAprobador (portal/src/lib/correosAusencias.js)Rediseñado junto con Alexi — fondo único en todo el correo (nunca partir el fondo
en dos colores), botón azul con padding en el <td> + mso-padding-alt (el
motor Word de Outlook de escritorio ignora padding/margin puestos directo en
<a>/<h3>/tablas — el fix es una celda espaciadora con height+line-height
exactos, función espaciador(), reutilizable en cualquier correo nuevo del
proyecto). Orden del contenido: saludo → Trabajador/Solicitado → Resumen (solo
vacaciones) → Periodos → Alertas (si hay) → botón.
Desde el 13-oct-2026 el bloque Resumen omite la fila "Saldo si se aprueba" cuando no hay saldo
(saldoHabilesProyectado == null), igual que el Comprobante de Feriado omite su fila de
saldo. Antes mostraban un negativo inventado para quien no tenía saldo calculable.
Documento formal por periodo aprobado de vacaciones (uno por tramo, los
rechazados no generan comprobante) — distinto del comprobante genérico de la
solicitud completa. Generador: portal/src/lib/comprobanteFeriadoPdf.js
(Puppeteer headless). No existe un formato oficial obligatorio de la Dirección
del Trabajo — es práctica probatoria (ORD. N°2022/25 2013, ORD. N°3161/64 2008
autoriza firma electrónica); el diseño se comparó contra Talana/Buk/firma.cl.
Solo jefatura aprueba un tramo de tipo Permiso (RR.HH. no es aprobador acá,
a diferencia de vacaciones). RR.HH. queda informado por correo solo si se
aprueba (no si se rechaza).
Además, el día 1 de cada mes el job permiso_resumen_mensual (Mantenedor de
Ejecuciones — corre diario pero se autodescarta si no es día 1, mismo patrón que
ausencias_verano_recordatorios) manda al Titular RRHH vigente ese día un
desglose por trabajador de las horas de Permiso sin goce de sueldo aprobadas
en el mes recién cerrado. Si no hay Titular RRHH configurado (ver 15.1), el job
no envía nada — no falla en silencio, queda en el log del job.
Sincroniza 3 propiedades de las tareas (tasks) de HubSpot cuyo origen es Strety: la
Cola (hs_queue_membership_ids = "Strety"), el link directo al To-Do de Strety
(strety_url) y el equipo (strety_teams, picklist con auto-alta de opciones
nuevas cuando aparece un equipo que todavía no está en la lista). El sync es
unidireccional (Strety → HubSpot, nunca al revés).
Workflow PEuGG9aAr1lvHZmU ("HubSpot") — al crearse una tarea en HubSpot con origen
Strety (webhook object.creation), resuelve el To-Do correspondiente por título dentro
de una ventana de 24h (con fallback a 30 días si no hay match) y setea las 3
propiedades automáticamente. No requiere intervención manual en el caso normal.
/admin/strety-hubspot)Rol strety_hubspot_admin o admin_roles. Cubre lo que el webhook en tiempo real no
puede ver — por ejemplo una vinculación hecha a mano en Strety después de que la
tarea espejo ya se creó en HubSpot (el webhook ya pasó y no se vuelve a disparar solo).
strety_origen/strety_relacion (ver 16.4).Si Strety no tiene un match (título ambiguo o no encontrado en la ventana de tiempo),
las propiedades quedan con un valor marcador en vez de vacías — así la corrida
siguiente no reintenta lo mismo para siempre:
| Propiedad | Marcador |
|---|---|
strety_url |
https://2.strety.com/no-encontrado |
strety_teams |
— No resuelto — (opción sin_resolver del picklist) |
Si más adelante el dato sí existe en Strety (por ejemplo, se vinculó a mano), borrar
el valor marcador directamente en HubSpot para que la próxima corrida lo vuelva a
intentar. Un campo realmente vacío significa "nunca se intentó" y sí se reintenta
solo en cada corrida.
strety_origen / strety_relacion — Plan B, no automatizadoLa API REST pública de Strety no expone "Related Resources"/"Created from" — solo el
MCP oficial (requiere una sesión de Claude con login manual a Strety). Estas 2
propiedades (de dónde viene la tarea — Rock/Issue/Headline/Metric/reunión — y a qué se
relaciona) se completan caso a caso a mano; ni el webhook de n8n ni el mantenedor
las tocan. El contador "Sin origen/relación (pendiente de MCP)" del botón masivo sirve
solo para saber cuántas quedan pendientes de esa pasada manual — no dispara nada solo.
Hallazgo real (18-sep-2026, ya corregido): las llamadas salientes a Strety/HubSpot no
tenían timeout configurado — si alguna quedaba sin responder ni cerrar la conexión,
el botón masivo quedaba "corriendo" indefinidamente (se dio un caso real de 18 horas,
0% CPU, sin avanzar) y bloqueaba cualquier corrida futura con error "ya en curso". Ya
se agregó timeout a todas esas llamadas. Si de todas formas vuelve a pasar (corre por
más de ~10 minutos sin terminar en un uso normal), reiniciar octopuss-portal y
octopuss-api limpia el estado trabado — sin riesgo de datos duplicados o corruptos,
en el peor caso algunas tareas quedan sin actualizar y se corrigen solas en la próxima
corrida.
/rrhh/trabajadores, 12-ago-2026)Rol rrhh_admin o admin_roles. Es la base de datos legal/organizacional de cada
persona — RUT, nombre legal, fecha de ingreso, antigüedad previa (AFP) y saldo
inicial de vacaciones (días hábiles + progresivos, con su fecha de corte). Se
completa de a poco, sin bloquear — la ficha se marca "Incompleto" si falta RUT, fecha
de ingreso o saldo inicial (el saldo inicial no se exige a quien tiene el check "No sujeto
a control de vacaciones", §17.5), pero lo único que bloquea pedir una ausencia es no
tener RUT (validación en POST /ausencias/solicitudes; el cumpleaños exige además la fecha
de nacimiento). El checkbox "Ver solo incompletos" filtra quién falta.
Cargo (jerárquico, "a quién reporto") es distinto de Departamento (de
equipo, "a qué área pertenezco" — usado solo por las reglas de Restricción
Vacaciones, ver §15.2). Cada cargo tiene un cargo_superior_codigo opcional (nulo
= tope del organigrama); quién lo ocupa sale de asignarlo en la Ficha de cada
persona (abajo), no de un campo "titular" en el cargo mismo.
cargo_superior_codigo hasta encontrar el primer cargo con ocupanteLista simple (nombre → código autogenerado) — solo para las reglas de vacaciones
simultáneas del mantenedor Restricción Vacaciones (§15.2), sin jerarquía propia.
Click en "Editar" en la fila de cada trabajador:
devengo_feriado_correccion en memoria para la fórmula).usuario_info_complementaria.sin_control_vacaciones,jefe_email) o del Cargo si está asignadoactivo=false al quitarlo en vez de borrar la fila, para no perder el historialFuente única: calcularEstadoCuentaVacaciones(usuarioId) en
portal/src/routes/ausencias_solicitud.js arma una lista cronológica de movimientos desde
la fecha de corte con el saldo acumulado después de cada uno; calcularSaldoVacaciones se
deriva de ella. Por construcción, el último saldo del estado de cuenta es el saldo que usa el
sistema (correos, comprobantes, reparto hábil/progresivo al aprobar, /ausencias/saldo).
Movimientos que afectan el saldo:
| Movimiento | Regla |
|---|---|
| Saldo inicial | De la ficha, a la fecha de corte (sin corte cae a la fecha de ingreso). Siempre es la primera línea. |
| Devengo de días hábiles | Desde la fecha de ingreso (no desde el corte): +1,25 cada mes cumplido (modo mensual) o +15 al cumplir el año (modo anual). Ver §17.6. |
| Progresivos por antigüedad | El nivel que ya tenía al corte está incluido en el saldo inicial. Después del corte: +1 el mismo día en que sube el nivel y nivel días completos en cada aniversario del ingreso. Ver §17.6. |
| Vacaciones por solicitud | Tramos aprobados con inicio >= corte (dias_feriado_legal y dias_feriado_progresivo). |
Registro manual (ausencia_cuenta, tipo vacaciones) |
Consumo resta, abono suma, solo si descuenta_saldo = true. |
ausencia_cuenta.descuenta_saldo (BOOLEAN, default true) es la única regla de saldo de los
registros manuales; reemplazó a la inferencia por created_at >= fecha de corte, que descontaba
dos veces vacaciones viejas cargadas después del corte. El check "No descontar del saldo" del
Registro de Ausencias lo deja en false (solo vacaciones de tipo consumo; resolverDescuentaSaldo
en ausencias_admin.js). Un registro manual para alguien sin control se guarda siempre con
false. Las filas con fecha anterior al corte se marcan anteriorAlCorte en el estado de cuenta.
El backfill (una sola vez, bloque protegido en schema_Octron.sql) replicó lo que cada fila hacía
bajo la regla vieja: ningún saldo cambió al desplegar.
Sin control de vacaciones (usuario_info_complementaria.sin_control_vacaciones, default
false): el estado de cuenta devuelve exento: true, saldo null y sin movimientos (mismo camino
que una ficha sin fecha de ingreso). El PATCH /rrhh/trabajadores/:id solo cambia el flag con un
booleano explícito: un cliente que no lo envía no lo pisa.
Endpoints (mismo gate admin_roles/rrhh_admin que el resto de /ausencias):
GET /api/ausencias/resumen-vacaciones (una fila por persona activa con ficha, incluye exento),
GET /api/ausencias/estado-cuenta-vacaciones?email= (movimientos con saldo acumulado),
PATCH /api/ausencias/:id y GET /api/ausencias/:id/historial (ver "Edición e historial").
Edición e historial de registros manuales (13-oct-2026). PATCH /ausencias/:id edita un
registro de ausencia_cuenta: solo fecha_inicio, fecha_fin, horario (si el tipo lo exige), nota
y descuenta_saldo (este último solo en vacaciones de tipo consumo y nunca true para alguien sin
control de vacaciones). No cambia persona, tipo ni movimiento (se elimina y se vuelve a crear).
Exige motivo (400 si falta) y rechaza una edición sin cambios reales. Corre en una transacción:
UPDATE + INSERT en ausencia_cuenta_historial (cambios JSONB {campo:{antes,despues}}, motivo,
usuario, created_at; GRANT SELECT/INSERT al rol mantenedor y SELECT a metabase_ro). La comparación
(calcularCambiosRegistro) normaliza horas (HH:MM:SS = HH:MM) y notas vacías. Si cambian las
fechas, después de responder: se borran y recalculan las alertas de conflicto del registro
(ausencia_alerta_conflicto) y se reevalúa la jefatura (reasignarPorAusenciaRegistrada). En la
pantalla, el lápiz ✎ abre el editor y la marca "Editado (n)" abre el historial. GET /ausencias ahora
trae por fila ediciones (solo manuales) y dias_habiles (días laborales por calcularDiasHabiles;
null en tipos con horario; en solicitudes viene del tramo) — alimenta la columna Días.
Diagnóstico rápido:
-- registros manuales de vacaciones y si descuentan
SELECT id, email, fecha_inicio, fecha_fin, movimiento, descuenta_saldo, created_at::date
FROM octopuss.ausencia_cuenta WHERE tipo = 'vacaciones' AND eliminado_en IS NULL ORDER BY id;
-- quiénes no se rigen por vacaciones
SELECT u.email FROM octopuss.usuario_info_complementaria i JOIN octopuss.usuario u ON u.id = i.usuario_id
WHERE i.sin_control_vacaciones;
Pruebas: portal/test/estadoCuentaVacaciones.test.js (12), saldoExento.test.js (6),
aprobadorAusencia.test.js (10) y edicionRegistroAusencia.test.js (5), con pool/correo falsos
en memoria (node --test).
Decisiones de datos tomadas el 13-oct-2026 (Alexi): la vacación de Jaime Alvarado (21–23-sep)
ya estaba descontada en su saldo inicial → se editó el registro a "sin descuento" desde el editor, con su
motivo en el historial (saldo −7 → −4). Los 4 registros de Alexi (oct–dic) y los días progresivos de Alexi,
Gonzalo Zúñiga y Jaime Alvarado ya estaban incluidos en sus saldos iniciales → sin cambios.
Límites conocidos: el historial solo registra ediciones (la creación y la eliminación quedan en
creado_por/created_at y eliminado_por/eliminado_en del propio registro); los registros que vienen de
una solicitud no se editan desde el Registro de Ausencias.
Reglas puras en portal/src/lib/devengoVacaciones.js (sin BD, con pruebas en portal/test/devengoVacaciones.test.js);
el nivel progresivo en calcularDiasProgresivos (diasHabiles.js). Validado con RR.HH. y con la Dirección del Trabajo (DT).
Días hábiles. 15 al año = 1,25 por mes. Los ciclos se miden desde la fecha de ingreso (art. 73: "la fecha que enteró la última anualidad").
Todo lo anterior a la fecha de corte ya está en el saldo inicial: solo se generan créditos posteriores.
vacaciones_devengo_modo (modo, vigente_desde, quién, cuándo, resumen de la simulación). Solo INSERT; el estado de cuenta usa el modo vigente en cada fecha,mensual desde 2000-01-01./api/grupos-restriccion): GET config-devengo, POST config-devengo/simular (no escribe), PUT config-devengo (exige confirmacion: "CONFIRMAR").Progresivos (art. 68). Nivel = floor((min(meses_previos,120) + meses_en_la_empresa − 120) / 36): primero 10 años (de empleadores anteriores cuentan hasta 10) y recién después
1 día por cada 3 años nuevos. Ejemplos: sin años previos el primer día es a los 13 años en la empresa; con 5 años previos, a los 8; con 10 o más previos, a los 3.
progresivo_antiguedad).nivel días completos (tipo progresivo_aniversario): los progresivos se agregan al feriado anual de cada año.Respaldo normativo (DT):
Límites conocidos: el sistema acredita meses completos (la DT también cuenta fracciones de mes, pero eso solo importa para un finiquito, que Octron no calcula) y asume que el saldo inicial de cada ficha ya incluye el devengo mensual hasta su corte.
Sistema propio para armar cotizaciones de servicios (CCTV, control de acceso, obra de mano,
traslados, máquina elevadora, etc.) a partir de un correo/ticket del cliente, con costo/margen
calculado igual que el Excel real de Jaime. Vive en el menú Cotizador del sidebar.
cotizador_ver: solo lectura (crear/ver cotizaciones, sin editar mantenedores ni márgenes).cotizador_admin: además puede crear/editar/eliminar categorías, tipos de máquina, tarifarioscotizador_margenes: permiso aparte para editar el valor de un margen ya existente (vercotizador_admin, se suma./admin/usuarios →cotizador_ver ocotizador_admin — no hace falta agregarlo aparte a la lista de acceso beta del módulo. Decisión| Mantenedor | Qué configura |
|---|---|
| Configuración | Categorías (crear/renombrar/desactivar/eliminar — eliminar solo si no tiene productos activos asociados, si los tiene muestra el link a HubSpot para revisarlos/reasignarlos antes) + parámetros sueltos (reservado para notificaciones de Requerimientos pendientes a futuro). |
| Máquina Elevadora | Tipos de máquina (crear/editar — el código interno se genera solo del nombre, no se pide a mano), altura por defecto según cliente (12m Sodimac / 8m Tottus) y tarifario de traslado/precio día/semana por sucursal. |
| Sucursales | Tarifario de Traslado/Viático/Hospedaje por sucursal (usado en "Traslados y Viáticos" del formulario). |
| LLM y Prompt | Cadena de modelos (Groq/NVIDIA) y prompt de sistema que interpreta el correo/ticket del cliente — mismo mecanismo que Prompts de scoring/bases, un grupo de prompt por canal (sodimac_correo/tottus_zendesk). |
| Requerimientos pendientes | Cola de correos ya leídos del buzón (soporte@octopuss.cl) que todavía no se convirtieron en cotización — descartar (con comentario), refinar el prompt desde un caso real, o reinterpretar con el prompt actualizado. |
El job cotizador_leer_correos (Mantenedor de Ejecuciones, cada 5 min entre 08:00-19:00, ver §11.3)
lee el buzón de soporte, detecta correos de Sodimac/Tottus y los deja en la cola de Requerimientos
pendientes. Al generar (desde ahí, o pegando el texto directo en "Nueva cotización"), el LLM:
parametro cotizador_margenes_editables + rol cotizador_margenes): mientrasfalse, nadie puede cambiar el margen de una categoría, en ningún mantenedor. ActivarloUPDATE parametro SET valor='true' WHERE clave='cotizador_margenes_editables'. Hoy está entrue (activado 25-sep-2026, pedido de Alexi).Botón "Traer del Banco Central" (vía mindicador.cl, mismo republicador oficial que ya usa el resto
de Octron, sin necesitar credenciales propias) para USD, UF y Euro — USD lleva compra/venta con +5 de
resguardo, UF/Euro son un valor único sin ese ajuste. Libra Esterlina (GBP) no está implementada:
mindicador.cl no la publica (solo trae la commodity "Libra de Cobre", que es otra cosa) y el
webservice directo del Banco Central la tendría pero exige registrar credenciales propias — pendiente
sin fecha de vencimiento hasta que Alexi indique cómo resolverlo (ver proximos_pasos.md).
Mantiene las empresas de HubSpot con RUT correcto y sin duplicados. Tiene dos piezas: (1) un paso 3 "higiene de empresas" dentro del job diario hubspot_limpieza_y_asociacion, que resuelve solo lo seguro y detecta lo demás, y (2) el mantenedor "CRM Resolver Empresas" (Ventas › Mantenedores, /ventas/crm-resolver-empresas), donde una persona resuelve los casos con acciones preestablecidas. El instructivo de uso está en el Manual de Operación (Módulo Ventas).
Documentos de diseño en el repo: docs/superpowers/specs/2026-10-09-crm-resolver-empresas-design.md (spec, con la §14 de cambios al implementar) y docs/superpowers/plans/2026-10-09-crm-resolver-empresas.md (plan). Código en main, commits d66098a..d706ac1.
El paso 3 se controla con la variable de entorno HIGIENE_EMPRESAS_MODO:
| Modo | Qué hace |
|---|---|
apagado (por defecto) |
No hace nada: termina antes de llamar a HubSpot o a la BD |
solo_casos |
Detecta, guarda y revisa casos. No ejecuta acciones automáticas |
completo |
Además normaliza RUT, completa RUT desde organismo y fusiona lo seguro |
DRY_RUN=true no escribe nada en ningún modo. El job programado corre con DRY_RUN=false (body_json = {"DRY_RUN":"false"}, §11), así que sin esta variable el paso 3 no corre: para encenderlo hay que agregar "HIGIENE_EMPRESAS_MODO":"solo_casos" (y luego "completo") al body_json del job desde el Mantenedor de Ejecuciones, con autorización de Alexi.
Estado al 12-oct-2026: modo apagado en el job programado. Los casos se cargaron una vez, a mano, en solo_casos (9-oct, 2.282 casos). Pendiente: primera acción manual real, y encender solo_casos y luego completo (45 RUT con formato por normalizar esperan ese modo).
Ejecución manual: cd /srv/app/portal && DRY_RUN=true HIGIENE_EMPRESAS_MODO=completo node scripts/hubspot_higiene_empresas.js (ensayo en seco, solo lectura). La prevención del paso 2 (19.4) no depende del modo: está activa siempre que corre el job.
Archivo (en /srv/app/portal) |
Responsabilidad |
|---|---|
src/lib/empresasPais.js |
detectarPais(): chile / extranjera / indeterminado |
src/lib/empresasReglas.js |
Reglas puras: analizar(), evaluarCaso(), elegirPrincipal(), normalizarNombre(), construirMapaDominios(), etc. Sin red ni BD |
src/lib/empresasAcciones.js |
planificarAccion() (pura), aplicarOps(), ejecutarAccion() (orquesta una acción manual) |
src/lib/empresasHubspot.js |
Capa HubSpot en vivo: traerTodasEnVivo, leerLote, actualizarRut, fusionar |
src/lib/empresasCasosDb.js |
Acceso a crm_empresa_caso / crm_empresa_accion (pool licitaciones) |
src/lib/rut.js |
Reutilizado: validarRut (módulo 11), formatearRutConPuntos |
scripts/hubspot_higiene_empresas.js |
Paso 3 del job |
scripts/hubspot_limpieza_y_asociacion.js |
Orquestador: llama al paso 3 después de limpieza (1) y asociación (2) |
src/routes/crm_resolver_empresas.js |
API del mantenedor |
frontend/src/features/crm-resolver-empresas/ |
Pantalla (React) |
api/schema_Octron.sql |
Bloque crm_resolver_empresas (tablas, rol, GRANT) |
octopuss_ia, esquema octopuss)crm_empresa_caso — un caso por fila. Campos principales: categoria (sin_rut, rut_ambiguo, rut_repetido, posible_duplicado, rut_invalido), huella (hash de los IDs de empresa ordenados), ids_hubspot text[], empresas jsonb (foto al detectar: id, nombre, dominio, RUT, país, deals, contactos, creada), pais_estado, rut_actual, rut_sugerido, rut_candidatos, rut_distintos, n_deals, n_contactos, motivo, nota, accion_sugerida, estado (abierto | resuelto | descartado), fecha_abierto, usuario, accion, fecha_cerrado, resultado_id (ID de la empresa resultante) y ultima_revision.
(categoria, huella) WHERE estado <> 'resuelto': un caso descartado conserva su huella (no se duplica ni se reabre); uno resuelto que reaparece genera una fila nueva.usuario es el email de quien ejecutó la acción, o sistema si fue el job. accion: fusionar, asignar_rut, elegir_rut, corregir_rut, limpiar_rut, marcar_distintas, no_aplica_rut, descartar o cambio_directo_hubspot.crm_empresa_accion — historial de toda acción, manual o automática: fecha, caso_id, origen (manual | automatico), usuario, accion (además normalizar_rut, completar_rut, fusion_segura, cerrar_caso), empresas_ids, principal_id, resultado_id, rut_antes, rut_despues, detalle jsonb, ok, error.
GRANT explícito a octopuss_licitaciones_mantenedor (lectura y escritura) y SELECT a metabase_ro. Se agregó además GRANT SELECT ON organismo al pool del mantenedor: el job lee de ahí los RUT y antes no tenía permiso.
buscarTodos). No usa la copia Postgres hubspot_company: esa copia conserva empresas ya fusionadas (16 de 27 en la prueba del 9-oct) y generaría casos sobre empresas que ya no existen.country/hs_country_code de HubSpot → si falta, el dominio (.cl o .gob.cl = Chile; un código de otro país como .ar o .cn = extranjera) → si sigue indeterminado, se considera chilena solo si su RUT pasa el módulo 11 o coincide con un organismo de nuestras licitaciones. Las extranjeras y las indeterminadas nunca se normalizan ni se completan, y las extranjeras no entran a "Sin RUT".completo; cada una deja fila en crm_empresa_accion con origen='automatico'):
76.299.375-9, DV en mayúscula) si es válido y la empresa es chilena.organismo: nombre normalizado igual, un único RUT válido, que ninguna otra empresa tenga y que el nombre no esté repetido entre empresas.dominios_correo_filtro.js: gmail, hotmail, etc.) y que el nombre no sea de relleno (N/A, Particular, etc.). La forma legal (S.A., SpA, Ltda., EIRL) solo se descarta al final del nombre.resuelto con accion='cambio_directo_hubspot' y usuario='sistema'. El cierre no pisa un caso que una persona ya resolvió mientras el job corría.INSERT … ON CONFLICT). Los IDs que una acción automática tocó en esta corrida quedan fuera de los casos nuevos; se detectan en la siguiente.Prevención en el paso 2 (hubspot_asociar_empresas_por_dominio.js): antes de crear una empresa nueva por dominio, ahora busca también en hs_additional_domains (los dominios de empresas ya fusionadas, ej. usm.cl), con un mapa armado una vez por corrida. Sin esto, un contacto con un dominio absorbido volvería a crear la empresa duplicada. Se descartó buscar por RUT: los contactos no lo traen y organismo no tiene dominio.
POST /api/crm-resolver-empresas/casos/:id/accion → ejecutarAccion():
SELECT … FOR NO KEY UPDATE (si otra persona o el job ya lo resolvió → 409). Se usa NO KEY UPDATE y no FOR UPDATE porque el historial se escribe desde otra conexión con FK al caso, y esa FK pide FOR KEY SHARE, que FOR UPDATE bloquearía (verificado).leerLote).planificarAccion() valida y arma las operaciones. No ejecuta nada si una empresa ya no existe, si cambió el nombre, el dominio o el RUT respecto de la foto que vio la persona, si el problema ya no existe, o si los RUT son distintos y no se confirmó (confirmarRutDistintos). Descartar siempre se permite.aplicarOps() ejecuta contra HubSpot. HubSpot devuelve un ID nuevo por cada fusión; la siguiente fusión de la cadena usa ese ID. Si la principal no tenía RUT y otra sí, se escribe ese RUT sobre la empresa resultante.COMMIT. Si HubSpot falla, el caso sigue abierto y queda una fila con ok=false y lo que alcanzó a hacerse (detalle.hechas).La fusión no se reintenta a ciegas tras un timeout (conRetry429(..., { reintentarTimeout: false })): HubSpot pudo ejecutarla antes del corte y repetirla fallaría. Ante un timeout se consulta el ID absorbido; si ahora resuelve a otra empresa, la fusión se hizo y se usa ese ID.
Rutas: GET /api/crm-resolver-empresas/casos (filtros categoria, estado, pais, q, orden, descartadas, paginación), POST …/casos/:id/accion y GET …/historial.
Rol crm_resolver_empresas_admin (admin_roles siempre puede). Asignado a Alexi, Claudia Huenchuleo y Mauricio Videla; se agregan más desde /admin/usuarios (el rol se congela al iniciar sesión: la persona debe volver a entrar). Sin acceso beta. El control está en portal/index.js (crmResolverEmpresasAuth, mismo patrón que Strety-HubSpot) y la pantalla en App.jsx/AppShell.jsx (Ventas › Mantenedores).
resultado_id).batch/read de un ID ya fusionado devuelve la empresa sobreviviente con otro ID, no un error. leerLote descarta lo que no coincida con un ID pedido (soloIdsPedidos). Si todos los IDs de un lote no existen, responde HTTP 207 con results: [] (no lanza error).num_associated_* son calculados y tardan unos segundos en actualizarse tras una fusión; las asociaciones de la API pueden repetir filas por tipo de asociación (contar IDs únicos). Los negocios y contactos asociados a ambas empresas se deduplican: no es pérdida de datos.-- casos por categoría y estado
SELECT categoria, estado, count(*) FROM octopuss.crm_empresa_caso GROUP BY 1,2 ORDER BY 1,2;
-- últimas acciones (manuales y automáticas)
SELECT fecha, usuario, origen, accion, empresas_ids, rut_antes, rut_despues, ok, error
FROM octopuss.crm_empresa_accion ORDER BY id DESC LIMIT 20;
-- acciones que fallaron
SELECT fecha, usuario, accion, error, detalle FROM octopuss.crm_empresa_accion WHERE NOT ok ORDER BY id DESC;
El log de cada corrida del paso 3 (línea [higiene-empresas] resumen: …) se ve en el Mantenedor de Ejecuciones. Si una acción manual muestra "No se pudo ejecutar la acción", revisar el log del servicio octopuss-portal; si el historial no se pudo escribir queda el aviso HISTORIAL NO ESCRITO con todos los datos en el log.
cd /srv/app/portal && node --test test/*.test.js: 51 pruebas del portal, 37 de este módulo (test/crmResolverEmpresas.test.js: país, normalización de nombres, detección de casos, fusión segura, acciones, cadena de fusiones, reintentos).node scripts/prueba_empresas_casos_db.js: prueba de humo contra la BD real dentro de una transacción con ROLLBACK (no deja filas).DRY_RUN=true HIGIENE_EMPRESAS_MODO=completo node scripts/hubspot_higiene_empresas.js.hubspot_company sigue conservando empresas absorbidas (su reconciliación no las borra); se corrige aparte.