Hub

2026-05-19

martes · 19 de mayo de 2026

2026-05-19

electro-ia — Fase 2.8: feedback loop v1 cerrado

Sergio pidió avanzar pendientes en electro-ia. Tras menú de opciones eligió feedback loop (memoria del agente) — la pieza más conceptualmente alineada con el goal del proyecto (mejora continua persistida).

Diseño (4 decisiones rápidas)

  1. Sin confirmación al guardar/olvida N siempre revierte; más fluido que la danza de pending_action.
  2. Solo admin puede crear scopes global / role:* / user:<ajeno>. Engineer/viewer solo su propio user. Previene envenenamiento horizontal.
  3. Sin embeddings v1 — con <50 memorias todas caben en system prompt; cheap y debuggable.
  4. Scope v1: comandos + inyección + política. Sin endpoint admin ni auto-detección de correcciones implícitas.

Implementación

  • Migration: sql/0002_agent_memory.sql aplicada en DB electroia. Tabla agent_memory (scope global|role|user, scope_value, body 1..2000, source command|admin, soft-delete con deactivated_at/by, FKs a identity). CHECK constraints que enforcement los scope_value vs scope.
  • Nuevo módulo: src/memory.js — 4 funciones públicas (createMemory, loadMemoriesFor, listVisibleMemories, forgetMemory) + renderMemoriesForPrompt. La diferencia entre loadMemoriesFor (lo que se inyecta al prompt) y listVisibleMemories (lo que el usuario ve con /memorias) es que admin ve TODO con listVisibleMemories pero loadMemoriesFor se limita a global ∪ role(propio) ∪ user(propio).
  • Agent loop: src/agent.js carga memorias en paralelo con el history (Promise.all); el bloque se append al final del system prompt como markdown plano. Para Antigravity API esto se cachea automáticamente con el system prompt estable.
  • Slash commands: src/index.js handlers deterministas (sin LLM en medio). Parseo de scope en /recuerda: admin puede usar prefix global: / role:engineer: / user:<id>:; sin prefix → siempre user:<propio>. Aliases: /recordar, /remember, /memories, /recuerdos, /olvidar, /forget.

Validación

  • Smoke unitario (node directo contra src/memory.js): 13/13 aserciones. Validado aislamiento — otro admin no ve user:<sergio>, engineer hipotético no ve user:<sergio> pero sí role:engineer, forget cross-user denegado a engineer.
  • Smoke E2E (Sergio, Telegram, 5 pasos): /recuerda <regla telcel> → guardado #1 [user:sergio]; /memorias → listó; /api hablame del estado de Telcel → Sonnet 4.6 respetó la regla, pidió el enlace específico; /olvida 1 → confirmó; /memorias → vacío. PASA.

Commit

  • b61c505 en laptop-ia:/home/electroia/electro-ia main — sin push (el repo no tiene remote).
  • Archivos: sql/0002_agent_memory.sql, src/memory.js, src/agent.js, src/index.js. 4 files, 253 insertions, 6 deletions.

Detalles operacionales notables

  • NOPASSWD asimetría: el user electroia (que corre el bot) NO tiene sudo NOPASSWD para systemctl restart electro-ia. El privilegio aplica al user sergio/gchavira. Consecuencia: mid-sesión, cuando hice kill -TERM esperando que systemd resurrectara el servicio (Restart=on-failure), no pasó nada porque el handler de index.js hace process.exit(0) que cuenta como clean exit. Sergio tuvo que sudo systemctl start electro-ia desde su cuenta. Pendiente operativo: o (a) agregar NOPASSWD para electroia user restringido al unit, o (b) cambiar el SIGTERM handler a process.exit(1) para que systemd cuente como failure. Decisión deferida.
  • Cambios ajenos en working tree: policy/hosts.yaml (+host monitoreo 192.168.20.17) y policy/paths.yaml (+write_allowed policy/). No los toqué — quedaron en working tree para que Sergio los committee aparte.

Memoria para futuras sesiones

Nada nuevo que requiera memoria persistente — el patrón de "feedback loop con comandos /recuerda" queda documentado en el README de electro-ia. Pero recordatorio: a partir de hoy electro-ia tiene su propia memoria operacional persistida en DB, independiente del sistema de memoria de este hub (~/.claude/projects/.../memory/). No confundir: la mía guía mis respuestas aquí, la de electro-ia guía las suyas allá.


holbox — 4 entregas de la tarde + fix de descuadre de reportes

Sergio pidió avanzar pendientes acumulados de holbox; tres tareas grandes + un fix reactivo del cliente.

1. Monto facturado de optometría en reporte Avanzado (Desempeño Equipo)

Pendiente del 2026-05-18. La celda Optometría por asociada ahora muestra conteo (badge ámbar) + monto facturado debajo en gris, oculto si es $0. Backend agrega SUM(CASE WHEN tipo_optometria IS NOT NULL THEN precio_optometria * cantidad ELSE 0 END) as monto_optometrias al select por empleada en AvanzadosController. 2/2 tests Pest verdes. Commit ea9be52.

2. Resumen de optometría también en el reporte de Ventas clásico

Sergio amplió el scope a media implementación: "que en el reporte normal de ventas también haya manera de consultar cantidades y totales de optometría". Agregué 4ª KPI card "Optometría" en Reportes/Ventas (grid pasó a 4 columnas, icono MagnifyingGlassPlusIcon por su asociación visual con "aumento/graduación" de lentes). Backend en VentasController agrega cantidad_optometrias + monto_optometrias al resumen vía subquery whereIn que respeta TODOS los filtros activos. 3/3 tests verdes. Detour: primer intento descubrió MySQL error 1235 (LIMIT en IN subquery) porque clonaba $query después del paginate(50); muevo el cálculo del resumen ANTES del paginate. Commit 5938ba2.

3. Vista "mis ventas del día" para asociadas en el POS

Pendiente del 2026-05-18. Endpoint nuevo GET /pos/mis-ventas-hoy (PosController::misVentasHoy) que devuelve ventas, unidades_por_categoria (GROUP BY categoría, orden DESC) y totales. Filtra por auth()->id() + estado completada + día operativo en TZ Cd. Juárez. UI: botón verde esmeralda "Mis ventas" en cabecera del POS junto al de Corte, abre modal con 3 cards (ventas/total/unidades) + lista compacta + chips "Categoría · N" al pie. 2/2 tests (aislamiento por user_id, exclusión de canceladas/ayer/otra-asociada, orden de chips). Commit 79232f5.

4. Fix de descuadre de reportes — el admin reportó

Aarón preguntó por qué el total del lunes 18-may daba $10,169 en una pantalla y $10,091.50 en otra. Diagnóstico (SSH read-only a prod): una sola venta descontada ese día — VTA4-000237 Jocelyn Cordero subtotal $1,550 / desc $77.50 / total $1,472.50. Tres reportes sumaban cosas distintas: semanalCategorias y Avanzados/empleadas usaban SUM(venta_detalles.subtotal) (ignora descuento), mientras Comisiones y semanalEmpleadas usaban $ventas->sum('total').

Decisión de Sergio: opción 2 — agregar columnas Subtotal + Descuentos + Total Recaudado en ambos reportes problemáticos (transparencia financiera + cuadre exacto con el resto del sistema). El descuento de cada venta se prorratea entre sus líneas según peso del subtotal: descuento_linea = subtotal_linea × venta.descuento / venta.subtotal. Resultado en prod (semana 18-24 sucursal 4): subtotal $18,249, descuento prorrateado $77.50 (47.50 a Premium + 30.00 a Clásico), neto $18,171.50. 3/3 tests verdes incluyendo replicación exacta del escenario reportado. Commit 8e706a9.

Push autorizado en esta sesión

Sergio dio "Haz push" — 4 commits a origin/main, GHA deploys ambos verdes. Convención de holbox sigue siendo per-sesión: NO marco esto como autorización permanente.

Decisión memorable

Frente al descuadre, ofrecí 4 opciones (prorrateo silencioso, prorrateo silencioso ambos, dos columnas, solo explicar). Sergio escogió opción 2 — dos columnas. Patrón válido para reportes financieros del cliente: ante divergencias entre cifras visibles para el admin, mostrar las cifras intermedias (subtotal + descuento + neto) en lugar de "esconder" la diferencia con un solo número corregido. Capturado como memoria de feedback.

Detalle ajeno notable

El suite pest --filter="Reporte|Avanzados" muestra 1 falla pre-existente en CajaCorteTest > gerente puede ver reporte de cortes — reproducible en baseline, no causado por mis cambios.

Memoria para futuras sesiones

  • Guardé feedback nuevo sobre transparencia financiera en reportes (opción "dos columnas" preferida a prorrateo silencioso) — aplicable a holbox y posiblemente a otros POS/contables.
  • El bug del paginate + whereIn subquery con MySQL 1235 vale la pena recordar como gotcha de Laravel; si vuelvo a hacer un resumen filtrado en una query paginada, calcular ANTES de llamar paginate().

electro-ia — Fase 2.9: deuda técnica de audio cerrada (endpoint binario)

Tras cerrar Fase 2.8 (feedback loop), Sergio eligió continuar con el siguiente pendiente: limpiar la deuda técnica de audio — el kludge filesystem-sharing que había entre electroia (bot) y gchavira (whisper server).

Lo que se atacó

electroia guardaba el .ogg en /tmp/electro-ia/audio/ con mode 0o644 y le mandaba el path a whisper :18081. Eso forzó dos kludges (commit f3e6f08 original):

  1. PrivateTmp=false en el unit de electro-ia (compartir /tmp entre namespaces).
  2. Mode 0o644 en el writeFile (voice notes legibles a cualquier user local).

Solución (Opción B del plan original)

Cambiar el contrato del endpoint de path a body binario.

Server (/home/gchavira/projects-hub/src/transcribe/server.py):

  • do_POST detecta por Content-Type. application/json → modo legacy (lee {path}, abre por filesystem). Cualquier otro (audio/*, application/octet-stream) → spool del body a tempfile.NamedTemporaryFile privado al user gchavira, transcribe, unlink en finally.
  • Query param ?language=es opcional.
  • Cap de body 30 MB.
  • Backwards compat deliberada para deploy atómico — al reiniciar whisper, el cliente viejo sigue funcionando hasta que el nuevo cliente también esté arriba.

Cliente (electro-ia/src/transcribe.js):

  • fetch ahora manda body: buffer con Content-Type: <mimetype> (default audio/ogg).
  • Removido { mode: 0o644 } del writeFile local — el audit trail vuelve a 0o600 efectivo (default umask).

Deploy (atómico, sin downtime cliente)

  1. Sergio mata whisper viejo (PID 73572, proceso huérfano de tmux antiguo) y relanza con el server.py nuevo.
  2. Validación curl: los 3 modos (legacy JSON, body sin language, body ?language=es) devuelven el mismo transcript del mismo audio existente.
  3. Sergio restart electro-ia → cliente nuevo activo.
  4. Smoke E2E: 3 audios desde Telegram (0.6-0.7 s c/u). OK.
  5. Sergio borra override PrivateTmp=false, daemon-reload, restart. systemctl show -p PrivateTmp = yes.
  6. Smoke E2E final con PrivateTmp=yes: audio OK en 0.66 s. Prueba definitiva — sin /tmp compartido, el flujo solo puede pasar si el cliente manda bytes.

Commits

  • electro-ia main: 189d99b "refactor(transcribe): endpoint binario en lugar de path compartido" (1 archivo, 13/14).
  • projects-hub main: dirty. Sergio prefirió commitearlo como gchavira aparte; server.py sigue corriendo en memoria desde su restart.

Notas operacionales

  • electroia tiene write a /home/gchavira/projects-hub/src/transcribe/ vía pertenencia al grupo gchavira y g+w en el archivo — por eso pude editar server.py directamente. Al guardarlo el archivo quedó owner electroia pero grupo gchavira (lo cual no afecta read/write para nadie).
  • Whisper sigue siendo un proceso huérfano sin systemd unit propio. La salida limpia "Opción A" del plan original (whisper como su propio user con systemd) sigue siendo deuda técnica pendiente, pero ya no es urgente — el filesystem-sharing kludge desapareció.
  • El modo legacy en server.py se puede borrar en una próxima limpieza (~10 LOC). Recordatorio: si pasa ~1 semana sin incidentes, cortar.

Memoria para futuras sesiones

Nada nuevo a memorizar — el patrón "endpoint binario en vez de path compartido" es estándar y queda documentado en los comentarios del propio código y en este log. La memoria del NOPASSWD asimétrico (reference_electroia_systemctl_nopasswd) sigue siendo el único hint operacional que vale la pena tener cached.


backups-infra — arranque del proyecto

Sergio pidió empezar con backups-infra. Primera sesión real de este proyecto desde su captura el 2026-05-08.

Decisiones marco cerradas

  • #0 Scope — solo Electrosystems-internal. Excluidos: proyectos type: personal (medicinas, aprende-ingles, hub-portability); servidores web de clientes externos en internet (holbox, deportescampeon, grecocell, jmeza prod); de laptop-ia solo entra DB electroia.
  • #1 Destino — solo NAS es-nas (192.168.20.26). Off-site para fase futura.

Decisiones #2-#6 (herramienta, retención, cifrado, monitoreo, restore drill) quedaron abiertas con recomendaciones; la próxima sesión arranca por ahí.

Lo que se hizo

  1. Inventario inicial de DBs vía script db_inventory.sh read-only en 9 hosts candidatos del config: holbox, docs, asterisk-adfsa-new, laptop-ia, netbox, amadeus, jmeza, deportescampeon, grecocell. Hallazgos:

    • adfsa-voip (MariaDB 10.11, asteriskcdrdb 62 MB) — socket OK.
    • deportescampeon (MySQL 8, DB 5 GB) — socket OK pero quedó OUT por ser cliente externo web.
    • grecocell (MySQL 8, DB 183 MB) — socket OK pero OUT.
    • laptop-ia (Postgres 16, projhub 9.7 MB / electroia 9 MB) — peer auth OK; solo electroia entra al backup NAS.
    • Resto requiere creds.
    • docs no tiene DB visible — bandera roja para docs-platform, no para este proyecto.
  2. Escaneo TCP probe 10.11.0.0/16 desde laptop (vía VPN del host Windows). bash + nc con paralelismo 400, 5 min 8 seg. 413 IPs vivas. 34 servidores Linux ES en puerto 58695, 31 inventariados, 4 con DB Asterisk medible:

    • miscelec-chih 4.8 GB asteriskcdrdb ⭐
    • arias-ep 1.1 GB
    • vitalpbx 1.1 GB
    • lineas-americanas 628 MB
    • Total ~7.6 GB en los 4 hosts con socket auth + ~20 PBX más con MariaDB activa pero "needs creds".
    • 354 hits en 10.11.30-37.0/24 solo en puerto 22 son equipos de red — territorio de backup-system.
  3. Escaneo 192.168.20.0/24 desde la VM wireguard (que vive en esa red). 29 hits.

    • Hallazgo clave: doble-stack confirmado — los hostnames duplicados entre 10.11.x y 192.168.20.x son el mismo host (peer WG + LAN). Memoria guardada como reference_es_wg_lan_dual_stack.
    • 3 CentOS 6 EOL en datacenter: orion (.2), otrs (.21), clientes.electrosystemsnet.com (.60). Mismo riesgo que orion-decommission.
    • 2 hosts con llave id_rsa_es rotada: reverse-proxy (.14), monitoreo (.17). Necesitan re-auth antes de backup.
    • viaticos (.19) caído, indelek-chihuahua (10.11.1.206) caído.
    • val-soft reporta hostname paginas-web — alias mal nombrado o server multi-rol.
    • Bloque .103-.106 sin auth (probable workstations) + .13/.16/.24/.57/.254 sin identificar.

Documentación

  • projects/backups-infra.md actualizado: 2 secciones nuevas en Notas técnicas (Inventario DBs, Escaneo 10.11/16, Escaneo 192.168.20/24), tabla de decisiones marco con #0 y #1 cerradas, sección "Tareas pendientes" completamente reescrita en 6 bloques (A reanudar / B completar inventario / C duplicidades / D derivados / E implementación / F fuera-scope), bitácora del día con cierre de sesión.
  • PENDIENTES.md actualizado: encabezado del día + sección de backups-infra reescrita con los nuevos pendientes accionables.

Memoria para futuras sesiones

  • reference_es_wg_lan_dual_stack — la regla del doble-stack 10.11.x / 192.168.20.x. Aplica a CUALQUIER análisis futuro de infra ES, no solo a este proyecto.
  • project_backups_infra_progreso — punto de retomar (decisiones cerradas + dónde está el detalle).

Para la siguiente sesión

Arranca por la sección "Tareas pendientes → A. Reanudar próxima sesión" del proyecto. Primer movimiento: cerrar decisiones marco #2-#6 (especialmente la específica de si asteriskcdrdb entra al backup, porque es el 95% del volumen medido).

holbox — Venta de error VTA4-000247 + dos arreglos de reportes

Sesión nocturna, 3 movimientos secuenciales sobre prod.

1. Cancelación + limpieza física de venta errónea

  • Sergio dio de alta VTA4-000247 en sucursal 4 por error (1 producto, $2,100, tarjeta, sin cliente). Pidió "borrar y actualizar inventarios".
  • Le surface el flujo correcto: el endpoint VentaController::cancelar ya hace todo (estado=cancelada, repone stock, decrementa stats del cliente, libera cupón) en una transacción. Sergio lo cliqueó en la UI admin.
  • Validé via SSH read-only: cancelación atómica OK, inventario repuesto (+1 al producto_id=3 en sucursal 4).
  • Después pidió borrado físico ("simular que nunca existió"). Validaciones previas: 0 ventas posteriores en suc 4 (por id ni por fecha), 0 cambios, 0 garantías, único corte de caja del día fue 6h antes de la venta — sin riesgo de descuadre. DELETE en transacción con lockForUpdate y guard idempotente. cascadeOnDelete en FK de venta_detalles/cambios/garantias simplificó la limpieza. Post-delete MAX(folio) suc 4 = VTA4-000246 → la próxima venta toma VTA4-000247 otra vez.

2. Reporte de ventas oculta canceladas por default

Mientras investigaba, noté que el listado mostraba la cancelada junto a las completadas y totalVentas la sumaba (mientras cantidad_completadas no). Inconsistencia heredada.

Fix: VentasController aplica where('estado','!=','cancelada') cuando no hay filtro explícito. Dropdown del frontend cambia de "Todos los Estados" → "Activas (sin canceladas)". El admin que quiera ver canceladas las selecciona explícito. 2 tests Pest nuevos cubriendo default + filtro explícito.

Commit f67bcab, deployado GHA 26131224699.

3. Audit USD/MXN — directorio de clientes

Sergio reportó que en el listado de Clientes se mostraba $X.XX USD cuando el sistema opera en pesos. Audit completo del codebase:

  • Único bug: Clientes/Index.vue:178 usaba toLocaleString('en-US') con label " USD" hardcoded sobre monto_acumulado (que está en MXN).
  • POS preserva "USD" intencionalmente (líneas 795/1360/1383/1399/1408 — conversiones de referencia y label del input de cobro en dólares).
  • Configuracion preserva el label "Tipo de Cambio (USD a MXN)" (es el setting del FX rate).
  • Backend cero referencias a USD.

Fix: helper formatCurrency local con patrón estándar es-MX/MXN$1,234.56 sin label. Commit 6bb4fb0, deployado GHA 26132413697.

Memoria capturada

  • reference_holbox_borrar_venta_huerfana — flujo seguro para borrar venta de error en holbox (cancelar primero por UI → SSH valida que sea última en la sucursal y sin relaciones → DELETE con cascade → MAX(folio) cae a la anterior y se libera el folio).

electro-ia — Fase 2.10: bug anafórico + tool send_email (EN PAUSA — deployados pendiente restart)

Sergio reportó que el bot le contestó al jefe sobre un archivo equivocado. Diagnostiqué, fixeé, y como siguiente pendiente arrancamos send_email. Ambos están deployados en disco pero el restart + credenciales SMTP quedan pendientes para la próxima sesión.

Bug diagnosticado

El modelo local (qwen2.5:14b) alucinó completo sobre el contenido del Excel factibilidades_seleccionadas2.xls (file_id=10) cuando el jefe preguntó "dame el total de las rentas mensuales de este archivo". Cero tool_calls en ese turn — el modelo inventó un bloque sourceMapping: \n - sheet_name: 'Sheet1' header: ['Fecha', 'Descripción', 'Débito', 'Crédito'] con items reciclados del PDF de WellsFargo que había analizado 24 minutos antes ("SAMS CLUB", "Depósito de Gustavo C Valle", "Renta $794.86").

Causa: "este archivo" sin file_id explícito no gatillaba la regla inviolable existente del prompt. El history del local (max_messages=6) tenía respuestas previas con la síntesis del PDF, y el modelo las recicló. Modo de falla nuevo no cubierto antes: imitar el output crudo de la tool (no el footer técnico, ese ya estaba prohibido en Fase 2.6).

Fixes deployados

  1. src/db.jsfetchRecentUploads({ identity_id, window_minutes: 120, limit: 5 }). Lee directo de la tabla uploaded_file.
  2. src/agent.jsrespondTo ahora carga uploads en paralelo con history+memorias (Promise.all). Nueva función renderUploadsBlock(uploads) arma un bloque markdown que se concatena al system prompt, con el más reciente marcado [MÁS RECIENTE].
  3. prompts/system.md — regla inviolable de "archivos" expandida a 4 puntos:
    • file_id explícito → tool obligatoria (igual que antes).
    • Referencia anafórica → resolver al MÁS RECIENTE de la lista de uploads del prompt.
    • NUNCA mezcles archivos diferentes del history.
    • NUNCA imites formato de tool output (footer, sourceMapping, tablas markdown con datos fabricados).

Tool send_email deployada

Decisiones de diseño cerradas con Sergio (4 preguntas):

  • Proveedor: Google Workspace Electrosystems (smtp.gmail.com:587 con App Password).
  • Allowlist: solo @electrosystems.com — pero interpretado como "allowlist suave" (externos disparan confirmación, no bloqueo). OJO: si Sergio prefería el bloqueo estricto, mañana lo confirma; está documentado en bitácora del proyecto.
  • Confirmación: si destinatario externo OR to+cc > 3.
  • Roles: admin + engineer.

Decisiones implícitas: plain text only v1, sin adjuntos, rate limit 30/24h via tool_call_log.

Archivos (todos en disco, pendientes de restart):

  • src/email.js (nuevo, ~50 LOC) — wrapper sobre nodemailer con lazy transporter.
  • src/tools/send_email.js (nuevo, ~180 LOC) — schema + run + executeConfirmed, valida emails, gestiona rate limit y pending_action.
  • src/tools/index.js — registra send_email + export executeSendEmail.
  • src/index.js — agrega send_email a PENDING_EXECUTORS + nueva función formatConfirmedAck(toolName, input, result) que da ack distinto por tool.
  • policy/permissions.yamlsend_email: [admin, engineer].
  • nodemailer instalado (npm install ya corrió, 1 paquete, 0 vulnerabilidades).

Lo que falta — para mañana

Sergio retoma y yo (la sesión nueva) le repito literal el bloque del .env + restart + smoke plan. El bloque vive en la sección 🔄 RETOMAR del README de electro-ia (top del archivo), así que se lee al iniciar conversación nueva.

  1. .env con credenciales SMTP reales (Sergio decide cuenta del bot vs reusar la suya con App Password).
  2. sudo systemctl restart electro-ia.
  3. Smoke A — bug fix con 2 uploads + pregunta "qué tiene este archivo".
  4. Smoke B — email directo + email con confirmación.
  5. Commit (probablemente un solo commit; o dos: uno por bug fix, otro por send_email).

Memoria

Guardo project_electroia_phase_2_10_paused como flag explícito en MEMORY.md para que el próximo turno de electro-ia entre sabiendo dónde quedamos sin tener que peinar todo el README.


monitoring-homologation — política de altas + bajas + deploy a producción

Sergio pidió "vamos uno por uno" sobre los pendientes abiertos de monitoring-homologation. Cerramos 2 pendientes de Fase 1 (altas + bajas) y desplegamos a prod en la misma sesión.

1. Política de altas — cola de candidatos + UI + correo

Decisiones cerradas con Sergio (3 preguntas):

  • Aviso: bandeja UI + correo (no solo log).
  • Batch inicial: inserción manual por device (no avalancha de 277). Los nuevos van a cola uisp_candidatos, Sergio decide Agregar/Ignorar.
  • Conflicto IP: ofrecer claim del manual existente vs crear nuevo.
  • Correo: svalencia@e-electrosystems.com.

Hecho: migration uisp_candidatos (estado pendiente|agregado|ignorado + visto_primera_vez_at), modelo con scopes, SyncUisp upsertea (ignorado no se reactiva, refresh no mueve timestamp, agregado se skip), mailable + view markdown, controller + 4 rutas, página Vue Index (tabs + búsqueda) + Show (modal de claim con radio buttons + selectores sitio/plantilla), badge en sidebar.

Tests Pest 13/13 verde. Commit 2f7bb46 directo en master + push.

2. Política de bajas — marcar desaparecidos + tab UI + correo

Decisiones cerradas con Sergio (2 preguntas):

  • Severidad: solo marcar (eliminado_de_uisp_at) — NO silenciar, NO soft-delete. El dispositivo sigue 100% activo en monitoreo. Más seguro pero más ruido.
  • Aviso: correo + bandeja UI (mismo modelo que altas).

Hallazgo durante implementación: el doc viejo decía "soft-delete con ignorar=true" pero ignorar NO existe en dispositivos (solo en monitoreos_dispositivos). Modelo final: solo eliminado_de_uisp_at (nullable timestamp).

Hecho: migration + cast, SyncUisp detecta desaparecidos y reaparecidos (auto-limpia el flag si vuelve), mailable + view, controller con 3 acciones nuevas (quitar flag / borrar uisp_id / eliminar soft-delete), tab "Desaparecidos en UISP" en página Vue, badge del sidebar ahora suma pendientes + desaparecidos.

Tests Pest 11/11 nuevos. Suite UISP completa 31/31. Commit a4684aa + push.

3. Deploy a producción

Sergio dio autorización amplia: "te autorizo que tu hagas deploy en producción en monitoreo".

Usé el script ya existente scripts/deploy.sh (deploy estándar del proyecto). ssh monitoreo "cd /var/www/es-monitoreo && ./scripts/deploy.sh" aplicó pull, composer install, npm build, php artisan migrate --force (corrieron migrations 54 + 55), optimize:clear, horizon:terminate. Schedule de es:sync-uisp confirmado activo (próximo tick en ~18 min).

Validación post-deploy: php artisan es:sync-uisp --dry-run → 401 devices UISP, 286 nuevos sin contraparte, 0 desaparecidos. UISP_NOTIFY_START_AT=2026-05-20T00:00:00-06:00 (Sergio ya lo había seteado) → snapshot inicial entra silencioso esta noche; correo activo desde mañana 00:00 MDT.

Observación dejada anotada (sin acción): el script reportó Horizon is inactive + workers systemd activos: 0 post-terminate. No afecta sync_uisp (que va por schedule, no queue), pero si Horizon se usa para otras queues conviene revisar.

Lo que sigue del proyecto

Cerrados: políticas de altas y bajas. Próximo natural cuando Sergio retome: refresh de fields para origen=uisp (completa el CRUD del sync — altas ✓, bajas ✓, faltan updates). Luego: enlace a los 2 Loma Linda (menor) y reporte de huérfanos en UI (Fase 1A).

Memoria capturada

  • reference_es_antenas_deploy_script — el deploy de es-antenas-new se hace con ssh monitoreo "cd /var/www/es-monitoreo && ./scripts/deploy.sh". No hacer pull manual.

Sesión noche — amadeus (sesión maratón: deploy inventarios + notif viaje + N vehículos + fix 3 bugs notif)

Sesión enfocada 100% en avanzar pendientes acumulados de amadeus. 4 entregas grandes deployadas a prod, una de ellas con descubrimiento+fix de 3 bugs latentes en el feature de notificaciones in-app.

1) Deploy de inventarios ↔ órdenes a producción (rama inventariosmain)

  • Punto de partida: rama inventarios con 39 commits ahead de main, 246 archivos, 22 migraciones nuevas (2025-11-11 → 2026-04-20). 11 días sin tocar.
  • Análisis de regresión (riesgo medio confirmado, 3 cambios de comportamiento que requirieron decisión explícita de Sergio):
    • ViajePolicy::update restringe edición al creador (Sergio confirma: sí, así lo quiere).
    • InventarioPolicy::delete subió a superadmin (Sergio: revertir a permisos.inventarios).
    • Producto::boot::created removido — validé con subagent que ningún reporte depende de la siembra automática (listados son data-driven).
  • Riesgos técnicos arreglados antes del deploy:
    • Migración hybrid 2025_11_19_002335 tenía down() vacío → implementé reverse con guard RuntimeException si hay numero_serie NULL.
    • Migración add_matriz_to_sitios insertaba un sitio "Matriz" → quité el INSERT (el sitio ya existía en prod, id=50 con código MTZ). Búsqueda case-insensitive.
  • Hotfix detectado en working tree de prod: cliente_id comentado en ViajesController y GuardarViaje desde 2026-01-11 (4 meses sin git). Validé que el bug raíz (form mandaba cliente_id="" y exists:clientes,id fallaba) ya está arreglado en main con prepareForValidation que normaliza ""/"null"/"undefined" → null. Hotfix descartado, deploy con el código limpio.
  • Deploy: backup SQL → down → pull c3c85d2..fb5e039 → migrate 22 DONE en ~25s (la más larga: add_handoff_states 6s) → optimize → queue:restart → up. Ventana ~1 min.

2) Feature notif push + email al crear viaje (deploy)

  • Inventory pre-cambio: descubrí que el push ya funcionaba en prod sin que Sergio supiera. Logs históricos del 11 y 18 de mayo muestran notificaciones enviadas exitosamente para viajes 190/191/192. Lo que faltaba: email, y supervisores con flag fuera del equipo.
  • Diseño:
    • Columna nueva permisos.recibir_notif_viajes (default false).
    • Service NotificarViajeCreadoService::destinatarios() centraliza el cálculo: usuarios del viaje (vía ordenes.equipos.usuarios) + usuarios con recibir_notif_viajes=true, únicos por id.
    • Listener push existente refactor para usar el service.
    • Listener email nuevo EnviarNotificacionEmailViajeCreado (ShouldQueue).
    • Mailable ViajeCreadoMail + template Blade con folio, sitios, fechas, botón "Ver viaje".
  • SMTP de prod: copiado de servidor nagios (Postfix → Gmail Workspace, cuenta webmailer@e-electrosystems.com). Password leída desde nagios:/etc/postfix/sasl_passwd y pasada por stdin a SSH amadeus para no exponer en el hub. reference_laravel_mail_scheme memoria aplicó: MAIL_SCHEME=null + puerto 587, NUNCA MAIL_ENCRYPTION=tls.
  • Documentación de paso: electrosystems/servers/nagios/README.md quedó como referencia canónica de Gmail/Postfix relay para reusar en otros servicios.
  • Smoke test: Sergio confirmó recepción del correo de prueba. Auto-discovery de Laravel 11 registró ambos listeners correctamente para event ViajeCreado.
  • Fix follow-up: Sergio pidió quitar "Amadeus" del email (es nombre código interno). MAIL_FROM_NAMEElectrosystems y body Blade limpio. Hot-deploy ~5s.

3) N vehículos por equipo (Fase 1)

  • Decidido alcance: Fase 1 = N vehículos por equipo (caso común "una camioneta + pickup porque no cabe el material"). Fase 2 = N equipos por viaje queda para sesión dedicada (caso menos común "dos crews al mismo sitio").
  • Diseño conservador:
    • Pivot nuevo equipo_vehiculo como fuente de verdad.
    • equipos.vehiculo_id se mantiene como cache nullable del "primer vehículo" para compat con reportes legacy. Fase 2 puede dropearla.
  • Backfill perfecto: 188 equipos con vehiculo_id → 188 filas pivot, sample muestra cache=pivot exactamente.
  • Frontend: form pasa de SelectInput único a grupo de checkbox multi-select (mismo patrón que usuarios); etiqueta del listado pluraliza ("Vehículo"/"Vehículos") según comas en la string.
  • Tests propios: 9/9 verde. 35 fails preexistentes confirmados como deuda heredada (heredada del cambio de migración "Matriz" de la sesión anterior — fixtures buscan Sitio::where('matriz',true)->firstOrFail() y fallan en BD fresh). Misma cantidad de fails en main pre-merge.
  • npm run build ejecutado + 97 archivos de public/build/ regenerados y committeados.
  • Deploy ~30s con maintenance.

4) Feature de notificaciones in-app — inventory + fix de 3 bugs latentes

  • Sergio recordó haber hecho un feature de notif para anunciar cambios. Inventory: TODO existe y está cableado (tabla, modelo, controller con 5 endpoints, middleware VerificarNotificacionesNoLeidas que fuerza lectura antes de usar la app, HandleInertiaRequests comparte tiene_no_leidas con sidebar, Vue Historial.vue, Nova resource). Pero 0 notificaciones creadas — nunca se usó.
  • 3 bugs latentes detectados (uno por uno, en orden de aparición):
    1. Superadmin no veía notif con permisos_requeridos: return dentro del closure de scopeParaUsuario no agregaba clausula. Fix: $q->orWhereNotNull('permisos_requeridos') antes del return.
    2. Match JSON frágil: where("permisos_requeridos->$permiso", "true") solo matchea string, no bool. Si la notif se crea por tinker con ['inventarios' => true] (bool nativo JSON), el match falla. Fix: cubrir las 4 representaciones ('true'/'1'/true/1).
    3. DESCUBIERTO AL VALIDAR — sync attachaba a TODOS los usuarios con cualquier row en permisos, no solo los que cumplían criterio. Causa: orWhereHas('permisos', fn => orWhere(...)) generaba SQL donde el OR se anidaba mal contra la clausula de join (usuarios.id = permisos.usuario_id OR gestionar_viajes = 1). Fix: envolver los orWhere en where(fn $sub => ...) para forzar paréntesis.
  • 6 tests Pest cubren los 3 casos. 6/6 verde.
  • Cleanup de pivot rows incorrectos en prod: 15 → 4 (los 4 correctos: 2 superadmins + Vitela + Ortega).
  • Notif real creada en prod: id=2 "Ahora puedes agregar varios vehículos al mismo viaje", dirigida a gestionar_viajes=true.
  • Deuda menor encontrada de paso: usuario id=13 (Karla Noriega) tiene 2 rows en permisos (ids 14 y 15, ambos gestionar_viajes=0). Relación Usuario::permisos() es hasOne — debería ser 1. Anotada en pendientes.

Memoria capturada/aplicada esta sesión

  • feedback_amadeus_ssh_authorized (nueva) — SSH a VM amadeus autorizado permanente con usuario electrosystems, ruta /var/www/amadeus, backups solo dump SQL.
  • reference_laravel_mail_scheme (aplicada) — MAIL_SCHEME=null + 587 para Gmail, no MAIL_ENCRYPTION.
  • feedback_no_client_names_in_code (aplicada) — el nombre código "Amadeus" estaba en FROM_NAME y body del email; lo quité por petición de Sergio.

Estado al cierre

3 features productivos viviendo en prod por primera vez: módulo de inventarios completo, notificaciones push+email de viajes, N vehículos por equipo. Feature de notif in-app destrabado con 3 bugs arreglados y primera notif real publicada.

Pendientes que Sergio dejó al cierre:

  1. Limpiar duplicados de permisos de Karla.
  2. Rediseñar /notificaciones para móvil.
  3. UX más obvio cuando hay notif sin leer.