Hub

holbox

holbox

active medium work
Creado
2026-05-08
Actualizado
2026-05-29
Directorios
  • /home/sergio/code/holbox

Pendientes abiertos (12)

Ver todos →

🎯 Top de ataque (12)

  • #383 📅 2026-06-01 ⏱ 3-4h ⚠️ 💰 FACTURABLE — Reducir fallos de WhatsApp (33% actual) con fix de calidad de dato en 2 capas: (1) upstream validar teléfono al capturar en POS/alta de cliente (10 dígitos + LADA mexicana válida, se apoya en PhoneNumber/libphonenumber del #197/#368) para atrapar typos antes de gastar envíos; (2) downstream en el webhook marcar números que rebotan con 131026 (flag suave whatsapp_no_disponible, NO opt-out duro porque 131026 a veces es transitorio) + aviso en banda del POS "este número no recibe WhatsApp, verifícalo". Diagnóstico completo en bitácora 2026-05-28. Esperando que Aarón apruebe (Sergio le mostrará el reporte con la columna de causa de falla primero).
  • 📅 2026-05-30 (Fase 2 futura) Imagen/PDF en lugar de link, vía plantilla Meta con header media. Generar PNG/PDF del ticket server-side (Browsershot/snappy), subir a /{phone_id}/media, usar media_id en send. La arquitectura ya tolera este cambio sin refactor: el contract WhatsAppSender gana un parámetro o un segundo método.
  • 📅 2026-06-01 (mejora futura, no urgente) Cuando el error en quick-add sea email duplicado, sugerir "Ya existe un cliente con ese email, ¿lo seleccionas?" con shortcut para asignarlo a la venta en curso sin abandonar el form.
  • 📅 2026-06-02 (opcional pulir) Autocomplete de productos en el form — hoy el select de productos lista todos (26 en dev, presumiblemente más en prod). Si se vuelve incómodo, cambiar a autocomplete por SKU/nombre. Por ahora la lista es manejable.
  • 📅 2026-06-03 Versión anterior del pendiente (preservada como historia): Diseño tentativo:
  • #148 📅 2026-06-04 #148 (NUEVO 2026-05-21, bloqueado por Aarón) Registrar Holbox Óptica en Google Business Profile. Sin el local registrado en Google Maps, cuando el cliente da clic en el pin del template WhatsApp (optica_ubicacion_v1), Maps no encuentra "Holbox Óptica" y resuelve a otro lugar. Mitigación corta aplicada 2026-05-21: address en .env extendido a "Avenida de la Raza 7030, 32500 Cd. Juárez, Chih." para que Maps geocodifique la calle correcta. Solución de fondo: Aarón crea el perfil del local en Google Business Profile (gratis); cuando Google lo apruebe, el pin abrirá la ficha real del local.
  • #160 📅 2026-06-05 #160 (NUEVO 2026-05-22, PAUSADO 2026-05-24) Revisar habilitar recepción de llamadas al número de WhatsApp Business. Análisis cerrado: la solución técnica ideal es WhatsApp Business Coexistence (Meta mayo 2025) — mismo número en app móvil + Cloud API simultáneo vía Messaging Echoes, llamadas en celular, sin costo extra, sin tocar nuestro código. Pausado porque (a) Sergio no ve la opción "Coexistence" en Meta Business Manager del WABA de Holbox (rollout regional incompleto a 2026-05) y (b) Aarón prefiere no hacer setup elaborado. Despausa condicional a decisión sobre #165 (cotización CRM) — si va por CRM SaaS, el CRM probablemente cubre llamadas y #160 queda N/A. Detalle completo bitácora 2026-05-24 (noche).
  • #256 📅 2026-06-07 ⏱ 28h (cotizado) 🔥 💰 FACTURABLE (DESBLOQUEADO 2026-05-28 — Aarón aprobó) — Fase 1: Bandeja básica + atención manual. Webhook inbound extendiendo el del #149 para procesar messages, tablas conversaciones + mensajes_wa + identificación cliente/prospecto, UI /admin/conversaciones con bandeja + chat view + asignación humana + estado activa/archivada + pipeline de prospectos. Entregable independiente y útil aunque no se haga Fase 2 — el equipo puede ver y contestar todos los mensajes en un solo lugar. 28h × $350 = $9,800 MXN cotizado a Aarón (facturación = horas reales × tarifa con flujo asistido, ver regla del hub). EN PROGRESO — plan técnico cerrado en plan-crm-fase1.md (preguntas P1/P2/P3/P5 resueltas por Sergio, ver bitácora). 3 entregas deployables: ✅ E1 captura inbound ~11h DEPLOYADA A PROD (commit a8ad89e, GHA 26605651268 verde — migraciones + backfill telefono_e164 corridos en prod); ✅ E2 bandeja read-only ~10h DEPLOYADA A PROD OCULTA (commits 30632da + bdcf05e, GHA 26659946880 verde; sin link en menú a propósito para testing previo a entrega — acceso solo por URL /admin/conversaciones, role:admin; revertir bdcf05e al entregar); ✅ E3 atención+envío+conversión ~9h DEPLOYADA A PROD (commit 9769533, push 2026-05-29 16:18 local; menú sigue oculto — revertir bdcf05e al entregar). tenant_id nullable desde día 1 (decisión #258). Prospecto = tabla nueva (no flag en clientes). Decisiones: telefono_e164 materializado (P1), solo admin atiende (P2), hilo global (P3), imágenes desde día 1 (P5).
  • #257 📅 2026-06-08 #257 💰 FACTURABLE (condicional a #256 entregada) — Fase 2: Asistente automático + integración holbox.store + escalado. Servicio IAResponder con Antigravity Haiku 4.5 (interno, no se menciona al cliente) + guion configurable desde /admin/asistente (textarea editable, versionado, variables {nombre_negocio}/{catalogo}/{promociones}), conexión con catálogo de holbox.store (sync periódico configurable, productos/precios/stock/links directos), lógica de escalado (intent "humano" / queja / fuera de guion / N mensajes sin resolver), notificaciones push+email al equipo, modo sombra inicial (asistente propone, humano aprueba y envía) configurable. 26h × $350 = $9,100 MXN, 1.5 semanas calendario (con leve traslape sobre el final de Fase 1). Costo recurrente Anthropic estimado $3-15 USD/mes para Holbox (a cargo del cliente, no del desarrollo). Los datos para reportería quedan registrados desde día 1 — pantallas en #260.
  • #258 📅 2026-06-10 #258 — Fase 3 multi-tenant opcional (interno, NO en propuesta cliente-facing). Abstracciones tenant_id en conversaciones/mensajes_wa/asistente_config, panel super-admin, docs de adopción para portar a Joyerías Meza / Greco Cell / Deportes Campeón. ~30-40h × $350 = $10,500-14,000 MXN, cobrable a cada cliente que adopte después. Decisión técnica: dejar tenant_id como columna nullable desde Fase 1 para no migrar después si Sergio decide adoptar el módulo en otro cliente sin tocar a Holbox. No mencionar a Aarón salvo que pregunte.
  • #260 📅 2026-06-11 #260 💰 FACTURABLE (extensión opcional posterior a #257) — Reportería del módulo Conversaciones. Pantallas: conversaciones por día/semana/mes, % resueltas por asistente vs humano, tiempo de respuesta promedio, preguntas más frecuentes (útil para mejorar el guion), prospectos → clientes con tasa de conversión. Los datos ya están registrados desde el día 1 (Fase 1+2 los persisten), solo se construyen las vistas. ~5-8h × $350 = $1,750-2,800 MXN. Cotizar y arrancar cuando Aarón la pida (típicamente mes 2-3 tras arranque del módulo).
  • 📅 2026-06-12 Atender el flujo continuo de cambios pedidos por el cliente post-rollout.

Actividad en bitácora 15 días

jun
jul
ago
sep
oct
nov
dic
ene
feb
mar
abr
may
L
X
V
Menos
Más

Holbox — Punto de venta multi-sucursal

Contexto

Sistema de punto de venta para el cliente personal Holbox. Cliente personal de Sergio, no facturado vía Electrosystems.

Recién implementado en producción en varias sucursales (al 2026-05-08). El cliente sigue pidiendo cambios para dejar el sistema “listo” después del rollout. Fase post-deploy: bugs y ajustes salen del uso real.

Resumen de pendientes principales

  • Tickets por WhatsApp.
  • 4 reglas nuevas de comisión (categoría Golden + categoría Bamboo Clásico + add-on Optometría + 3 SKUs Ixchel/Kin) — detalle abajo. Sin deadline fijo; el delta del reporte semanal se paga a mano si hace falta.
  • 3 pendientes nuevos (2026-05-18): monto de optometría en reporte de ventas; vista de “mis ventas del día” + totales por categoría para asociadas; flag para excluir categorías (bolsitas) del disparador de precio_sale. Detalle abajo en bitácora 2026-05-18 (PM-2).
  • Iteración continua post-rollout.

Principio operativo confirmado por Sergio (2026-05-08)

Aarón (admin de Holbox) debe avisar con anticipación cualquier cambio que afecte a las empleadas (comisiones, reglas de venta, etc.). Los cambios en el sistema no son inmediatos — anunciar primero, ajustar el sistema después.

Si Aarón anuncia algo nuevo a las asociadas y el sistema todavía no lo refleja al cierre del reporte semanal, el delta se paga a mano (volumen bajo en Holbox, impacto pequeño). No es razón para apretar un ship.

Modelo de comisiones existente (revisado del codebase 2026-05-08)

Ubicación: app/Http/Controllers/ReporteController.php::comisiones → vista resources/js/Pages/Reportes/Comisiones.vue.

Periodo: lunes-domingo en TZ local (vía LocalDateRange::reportTimezone()). Aplica a roles asociada y gerente (no admin). Filtrable por sucursal.

Cálculo actual por usuario:

  1. Comisión base = ventas_totales × comision_porcentaje (config key comision_porcentaje, default 3%).
  2. Bono premium escalonado (no acumulativo, se queda con el escalón más alto alcanzado):
    • 4-5 lentes premium → comision_4_premium (default $300)
    • 6-7 lentes premium → comision_6_premium (default $500)
    • 8+ lentes premium → comision_8_premium (default $800)
  3. “Premium” se identifica por flag booleana categorias.es_premium = true en cada producto vendido.

Tabla categorias: id, nombre, precio_regular, precio_sale, es_premium, reporte_equipo_mostrar, reporte_equipo_nombre. Cada producto tiene categoria_id.

Diseño confirmado por Sergio (2026-05-08)

Modelado: Opción A — tabla comision_reglas con flag trigger. Una sola tabla acomoda los 3 tipos de regla (categoría, add-on de venta, producto específico) y queda abierta a tipos futuros.

Schema propuesto:

comision_reglas:
  id              BIGINT PK
  trigger         ENUM('categoria', 'addon_optometria', 'producto')
  categoria_id    BIGINT NULL  FK -> categorias.id     -- usado cuando trigger='categoria'
  producto_id     BIGINT NULL  FK -> productos.id      -- usado cuando trigger='producto'
  tipo            ENUM('por_unidad', 'por_paquete')
  monto           DECIMAL(10,2)
  unidades_por_paquete  INT NULL                        -- usado cuando tipo='por_paquete'
  descripcion     VARCHAR NULL                          -- libre, para que el admin entienda en UI
  activa          BOOLEAN DEFAULT true                  -- soft-toggle sin borrar histórico
  created_at, updated_at

Validaciones (constraint a nivel app):

  • trigger='categoria'categoria_id NOT NULL, producto_id NULL.
  • trigger='producto'producto_id NOT NULL, categoria_id NULL.
  • trigger='addon_optometria' ⇒ ambos NULL.
  • tipo='por_paquete'unidades_por_paquete NOT NULL y > 0.
  • tipo='por_unidad'unidades_por_paquete NULL.

Las 4 reglas a poblar

#ReglaTriggerFKTipoMontoPaqueteCálculo
1Optometríaaddon_optometriapor_unidad$100$100 × líneas-de-venta con cualquiera de los 3 tipos de optometría aplicados
2Categoría Goldencategoriacategoria_id=<Golden>por_unidad$50$50 × unidades vendidas
3Categoría Bamboo Clásicocategoriacategoria_id=<Bamboo Clásico>por_paquete$1006$100 × floor(unidades / 6)
4Modelos Ixchel / Kinproducto3 filas, una por SKUpor_unidad$30$30 × unidades vendidas, por cada SKU de la lista

Regla 4 — los 3 SKUs específicos (mensaje de Aarón vía admin de Holbox, 2026-05-08, ya en operación con las asociadas desde 2026-05-07):

  • CJ-001AIxchel
  • CJ-0001BIxchel Sunrise
  • CJ-006CKin Sunrise

(Nota: el padding de los SKUs es inconsistente — 001A vs 0001B vs 006C. Confirmar contra el catálogo en producción antes de seedear, para no fallar el match por un cero de más o de menos.)

Reglas de cálculo (todas):

  • Periodo: lunes-domingo en TZ local (igual al reporte actual).
  • Sobrantes del paquete (ej. 7 unidades de Bamboo Clásico = 1 bloque de 6 + 1 sobrante) se pierden al cierre de semana, no acarrean.
  • Bamboo escala lineal por bloques: 6→$100, 12→$200, 18→$300, etc. (no es escalonado tipo el bono premium 4/6/8 actual).
  • Optometría suma sobre el conteo de líneas de venta que tengan add-on de optometría aplicado, no sobre el cliente ni el ticket.
  • Las 4 reglas se suman a la comisión existente (base 3% + bono premium escalonado 4/6/8). No reemplazan nada.

Pendientes para verificar antes de implementar (read-only, ya autorizado por Sergio):

  • (2026-05-11) Confirmar nombres exactos de categorías → confirmado: id=2 Bamboo Clásico, id=3 Bamboo Golden, id=6 Bamboo Premium Golden (Sergio confirmó “Golden” aplica a ambas).
  • (2026-05-11) Confirmar los 3 SKUs Ixchel/Kin → confirmados en prod: id=42 CJ-001A Ixchel, id=41 CJ-0001B Ixchel Sunrise, id=36 CJ-006C Kin Sunrise.
  • (2026-05-11) Identificar cómo se modela el add-on de optometría → columnas tipo_optometria (nullable string) + precio_optometria (decimal) en venta_detalles (PosController.php:185).
  • (2026-05-11) Confirmar los 3 tipos de optometría → antireflejante, tinte, transition con precios actuales en prod $1400 / $1700 / $2000. Para la comisión, los 3 valen igual ($100/línea, ya implementado).

Tareas pendientes — actualizadas

  • #367 (CERRADO 2026-05-27 — código deployado, falta capturar datos) Sale price condicional por composición de venta + override por producto. Commit a604ad2, GHA 26499410106. Migración + lógica + UI listas; suite 320/38 (cero regresiones). Pendiente operativo de Sergio (NO bloquea deploy): cuando Aarón pase los 2 montos sale para cat #9 + SKU del modelo especial con sus 3 precios → capturar en /categorias/9/edit y /productos/{id}/edit. Detalle completo en bitácora 2026-05-27.

    • Plan original con las 5 preguntas que Sergio le hace a Aarón (preservado como referencia para cuando Aarón pase los datos): Sale price condicional por composición de venta en categoría “Holbox I.A TR-90” (id=9) + override de precio para un modelo específico. Aarón pide 3 reglas nuevas que conviven entre sí:
    1. Sale price “intra-categoría” — los productos de la cat #9 aplican su precio_sale sólo cuando la venta lleva 2+ unidades de esa misma categoría. Una sola unidad de #9 (sin acompañante intra) queda en precio_regular. (Hoy el disparador genérico de precio_sale se basa en totalLentesCount ≥ 2 de cualquier categoría con cuenta_para_precio_sale=true; eso ya no aplica para cat #9.)
    2. Sale price “mixto” — cuando la venta lleva 1 unidad de #9 + 1+ unidad de otra categoría, los productos de #9 aplican un sale price DISTINTO al de la regla 1 (un segundo monto, aún por definir con Aarón). Hoy no hay manera de modelar dos sale prices por categoría — la tabla categorias sólo tiene precio_sale.
    3. Override por producto — un modelo en particular dentro de la cat #9 debe tener precio_regular propio + sus propios dos sale prices (regla 1 y regla 2). El producto sigue perteneciendo a la cat #9; sólo los precios son específicos suyos.
    • Schema tentativo (a decidir antes de tocar código):
      • Opción A — extender categorias con precio_sale_mixto (decimal nullable) que se aplica en venta mixta; mantener precio_sale para la regla 1. Migración + UI mínima en /categorias/{id}/edit.
      • Opción B — tabla nueva categoria_precios_condicionales con tipo ∈ {intra, mixto}, monto y trigger (cantidad mínima de la propia categoría / cantidad mínima fuera de la categoría). Más extensible si Aarón pide reglas similares en otras categorías a futuro.
      • Override por producto — ya existe productos.usa_precio_propio + precio_regular por producto (PosController::calcularSubtotalCarrito). Confirmar si soporta también precio_sale por producto o sólo precio_regular; si no, agregar productos.precio_sale_propio (y precio_sale_mixto_propio si Opción A).
    • Puntos a tocar (mismo flujo que cuenta_para_precio_sale):
      • PosController::index — exponer al frontend los campos nuevos por producto/categoría.
      • PosController::calcularSubtotalCarrito — agregar branch que detecte composición del carrito (¿cuántas unidades de cat #9? ¿hay líneas fuera de #9 con cat cuenta_para_precio_sale=true?) y resuelva qué sale aplicar por línea de venta.
      • Frontend POS subtotalProductos espejea la lógica del backend (cliente no debe ver un precio mientras backend cobra otro).
      • cargarSolicitudAlPOS y cualquier path que reuse nuevaLineaProducto(producto) debe seguir funcionando con los campos nuevos (lección del hotfix #137 PR3 — armar shape a mano duplica conocimiento).
    • Preguntas para Aarón antes de implementar:
      1. ¿Cuáles son los dos sale prices (regla 1 vs regla 2)? Hoy precio_sale de la cat #9 ya tiene un valor — ¿el nuevo “mixto” es mayor o menor?
      2. ¿Cuál es el modelo particular que debe tener precio propio? SKU + nombre + precio_regular + sus 2 sale prices.
      3. ¿La regla “intra-categoría” es exactamente “2+ unidades de cat #9” o es “2+ SKUs distintos de cat #9”? (Importa si una persona compra 2 unidades del mismo SKU.)
      4. Si la venta lleva 3+ unidades de #9, ¿aplica el sale “intra” a todas o sólo a 2? (Default tentativo — aplica a todas las unidades de #9 en el carrito.)
      5. La regla 2 “mixto” exige que el “1+ de otra categoría” sea una cat con cuenta_para_precio_sale=true, o cuenta cualquier cosa (incluyendo bolsitas/estuches con el flag OFF)? Default tentativo — sólo cats con flag ON, para no premiar agregar una bolsita y disparar el sale mixto.
    • Interacción con cuenta_para_precio_sale: la categoría #9 hoy tiene el flag true. La nueva regla 1 reemplaza el disparador genérico para los productos de #9 (ya no basta cualquier “2+ lentes”, tiene que haber 2+ de la propia categoría). Para el resto de categorías premium con flag true el comportamiento actual no cambia.
    • No tocar producción hasta que Aarón confirme los 2 montos sale + el SKU del modelo especial. Riesgo concreto de cobrar mal una venta real.
  • (2026-05-14) Tickets por WhatsApp — Fase 1 (link a vista pública). Detalle abajo en bitácora 2026-05-14 (PM-3). No deployado todavía — esperando autorización de Sergio.

  • (2026-05-18) Pantalla admin de preview de WhatsApp para que el admin de Holbox valide el contenido y reciba un envío real de prueba antes de habilitar la feature globalmente. Detalle abajo en bitácora 2026-05-18. Commit 375bc47, deployado a prod (GHA run 26056253441).

  • (2026-05-18) Template ticket_holbox_v3 con cuerpo enriquecido aprobado y deployado (saludo personalizado + modelo + folio + garantía). 3 commits deployados: ce93f6a (estructura inicial con modelo + folio, GHA 26067189002), c76b96e (agrega nombre del cliente como 3a variable, GHA 26068094831), d35174f (slug v2 → v3 tras aprobación de Meta, GHA 26068398056). Cuerpo del v3 es idéntico al v2 — fue solo re-submisión. Helper Venta::modeloPrincipal() + Venta::nombreClienteCorto(), refactor del sender, preview UI con 3 placeholders.

  • (2026-05-22) #083 Activar WhatsApp en prod — Aarón completó método de pago + token en Meta Business Manager. .env de DigitalOcean con WHATSAPP_FEATURE_ENABLED=true + WHATSAPP_DRIVER=meta + META_WHATSAPP_PHONE_NUMBER_ID + META_WHATSAPP_TOKEN, reload php8.3-fpm, sin redeploy. Validado end-to-end con VTA2-000164 el 2026-05-21 noche (4 mensajes en ráfaga, todos con wamid). Detalle bitácora 2026-05-18 (PM-4, PM-5, PM-6).

    • 2026-05-20: Sergio reporta que ya configuró phone number ID, token, método de pago y mensaje de prueba OK. Meta ahora pide URL de Privacy Policy para publicar la app. Solucionado abajo — bitácora 2026-05-20.
  • (2026-05-20) Aviso de Privacidad público en /aviso-de-privacidad + redirects desde /privacy y /privacy-policy. URL para Meta: https://holbox.val-soft.com/aviso-de-privacidad. Commit local 21ed2f5. Detalle en bitácora 2026-05-20.

  • (2026-05-20) Baja de WhatsApp (opt-out) para clientes — flag absoluto whatsapp_opt_out separado de acepta_whatsapp. Asociada lo acciona desde el modal “Editar cliente” del POS (sin entrar al CRUD), admin desde /clientes/{id}/edit. Filtro en dispatch + en job handle. Commit 1bd5898, deployado a prod (GHA 26187225103).

  • (2026-05-20) Opt-in inline en banda del POS para clientes existentes — banner ámbar “Aún no recibe tickets por WhatsApp · [Activar]” visible cuando el cliente seleccionado tiene teléfono pero no está suscrito ni dado de baja. Endpoint POST /pos/cliente/{id}/whatsapp/opt-in respeta el veto de opt-out. 10/10 tests. Commit 64d0b2c, deploy en curso (GHA 26187571082).

  • (2026-05-18) DP de optometría en un solo campo — el admin del cliente pidió quitar los subcampos “Binocular” y “Monocular” del bloque DP en el form de captura y dejar un solo renglón. Migración reversible consolida columnas dp_binocular/dp_monoculardp con backfill COALESCE(binocular, monocular). Cambio aplicado en form admin, tabla histórica, banda POS al elegir cliente, ticket interno y ticket público. Commit 43bf8cb, deployado a prod (GHA run 26058182934). 9/9 tests Optometría verde.

  • #383 📅 2026-06-01 · ⏱ 3-4h · ⚠️ 💰 FACTURABLE — Reducir fallos de WhatsApp (33% actual) con fix de calidad de dato en 2 capas: (1) upstream validar teléfono al capturar en POS/alta de cliente (10 dígitos + LADA mexicana válida, se apoya en PhoneNumber/libphonenumber del #197/#368) para atrapar typos antes de gastar envíos; (2) downstream en el webhook marcar números que rebotan con 131026 (flag suave whatsapp_no_disponible, NO opt-out duro porque 131026 a veces es transitorio) + aviso en banda del POS “este número no recibe WhatsApp, verifícalo”. Diagnóstico completo en bitácora 2026-05-28. Esperando que Aarón apruebe (Sergio le mostrará el reporte con la columna de causa de falla primero).

  • 📅 2026-05-30 — (Fase 2 futura) Imagen/PDF en lugar de link, vía plantilla Meta con header media. Generar PNG/PDF del ticket server-side (Browsershot/snappy), subir a /{phone_id}/media, usar media_id en send. La arquitectura ya tolera este cambio sin refactor: el contract WhatsAppSender gana un parámetro o un segundo método.

  • (2026-05-24) #149 Webhook de status de Meta para WhatsApp cerrado end-to-end en prod. 2 commits (4383abe + 937807b fix del fallback huérfano), suite 295/28 (+18 verdes), GHA verdes 50s c/u. Smoke validado por Sergio: simulador disparó 4 mensajes, transitan sent→delivered→read; tras fix del fallback los disparos del simulador ya no contaminan /admin/whatsapp-logs con preview destildado. Detalle bitácora 2026-05-24.

  • #197 (CERRADO 2026-05-27) Soporte no-MX con libphonenumber deployado a prod. Sergio escogió opción (b) tras evaluación. Commit 35ac39f en origin/main: giggsey/libphonenumber-for-php 9.0.30 + refactor PhoneNumber (nuevo toE164(?$region='MX') + toE164Mx delega con pre-procesamiento de legacy 01XXX/521XXX que libphonenumber no conoce), 9 llamadores migrados (4 MetaCloudSender + 4 LogSender + 1 PreviewController). Tests +6 nuevos (USA +1, ES +34, PA +507, MX local, MX +52, garbage→null). Suite 317/38 (vs 312/43 pre-cambio — resolvió 5 fallos previos como side-effect). Cambio de comportamiento: 1XXXXXXXXXX (11 dígitos sin +) ya NO normaliza a MX — libphonenumber lo interpreta como USA y toE164Mx lo rechaza por no empezar en +52. Bandera viva: #368 (LADA 915 El Paso en 10 dígitos) depende de observación post-deploy.

  • #368 (CERRADO 2026-05-27) LADA 915 (El Paso) en 10 dígitos resuelta vía heurística pre-parse en PhoneNumber::toE164. Sergio escogió ruta híbrida (a)+(b) ligero: política de capacitación al cajero para que capture US con +1 explícito, + heurística de seguridad solo para 915 (caso real reportado) sin extender a otras LADAs fronterizas. Verificado empíricamente que LADA MX 915 no existe (libphonenumber rechaza +529158380000 como inválido), así que cero colisión con clientes mexicanos legítimos. Commit 424bdc1, GHA 26552660322 verde, fix vivo en prod (9158380000+19158380000; 5551234567+525551234567 regression OK). Suite PhoneNumber 17/17 (+5 nuevos), WhatsApp+PhoneNumber 72/72. BD no requirió UPDATE: teléfonos se guardan raw 10 dígitos en clientes.telefono; los 56 clientes 915 existentes quedan auto-resueltos por el fix de runtime. Anomalías de basura detectadas para limpieza separada: id=50 (91547963333), id=460 (9157126856131), id=590 (91513008090). Caso LADA 575 (NM sur, 1 cliente id=357) queda para otro ticket si reaparece volumen.

  • (2026-05-22) #082 Recordatorios de cupones de fidelidad por WhatsApp — activos en prod tras encendido del feature flag global (#083). Implementación había quedado deployada el 2026-05-20 (commit cc7db96, GHA 26202727769); scheduler diario 09:00 local dispara los 5 puntos (días 3/7/15/25/30) por WhatsApp en paralelo al email con tracking JSON separado.

    • Diseño cerrado 2026-05-20 (PM-5/PM-6): 6 plantillas separadas — 5 recordatorios (fidelidad_dia_03/07/15/25/30) con copy alineado al email actual + 1 nueva nivel_alcanzado para celebrar nuevo nivel de fidelidad. Canales email+WhatsApp en paralelo con tracking separado, categoría Meta MARKETING, {{1}} viene de Cliente::nombre (ya guarda solo el primer nombre — fallback cliente).
    • Templates aprobados 2026-05-20 (PM-7/PM-8). Los 6 (fidelidad_dia_03/07/15/25/30 + nivel_alcanzado) están vivos en Meta.
    • Implementación lista y deployada 2026-05-20 (PM-8 + PM-9). PM-8 = migración + 2 jobs + senders + comando + dispatch nivel (commit cc7db96 deployado GHA run 26202727769). PM-9 = pivote a opt-in implícito según decisión de Aarón: default whatsapp_respetar_opt_in=0, UI sin casillas de opt-in, banner ámbar removido, endpoint optIn retirado, aviso de privacidad reforzado con frase explícita “dar el número = consentir”. WhatsApp suite 46/46 verdes. Pendiente: deploy del PM-9 + setear Configuracion::set('whatsapp_respetar_opt_in', '0') en prod + activar feature flag.
    • Bloqueado por feature flag global: mismo prerequisito que el ticket (Aarón configurar phone number ID + token + método de pago). Ya parcialmente cubierto — falta solo prender WHATSAPP_FEATURE_ENABLED=true.
    • Implementación se ataca cuando lleguen los 5 template names aprobados. Plan técnico abajo en bitácora 2026-05-20 (PM-5).
  • (confirmado 2026-05-14) Fix de quick-add de cliente validado en sucursal — la asociada ya ve el banner de validación cuando intenta registrar un cliente con email duplicado. Commit 856e8aa + locale ES 33dc608 cierran el ciclo. Mejora futura opcional registrada abajo.

  • 📅 2026-06-01 — (mejora futura, no urgente) Cuando el error en quick-add sea email duplicado, sugerir “Ya existe un cliente con ese email, ¿lo seleccionas?” con shortcut para asignarlo a la venta en curso sin abandonar el form.

  • (2026-05-14) Traducir mensajes de validación a español. Hecho hand-rolled (sin dep externa):

    • lang/en/ publicado vía php artisan lang:publish (baseline 4 archivos).
    • lang/es/{validation,auth,passwords,pagination}.php escritos a mano, validation.php cubre los ~120 keys de Laravel 12, sección attributes con ~50 campos del codebase (nombre/apellido/email/teléfono/sku/precio_venta/sucursal_id/etc).
    • config/app.php default 'locale' cambiado de 'en' a 'es' — efectivo aunque prod no actualice su .env.
    • .env.example y .env local: APP_LOCALE=es. Atención: prod .env (DigitalOcean) sigue con APP_LOCALE=en; Sergio lo cambia ahí o lo elimina (caerá al default 'es').
    • Test Pest valida end-to-end (POST /clientes con email duplicado → "Ya existe un registro con este email."; required y email format igual ES). 6/6 archivo verde, 109 assertions.
    • Suite completa: 36 tests pre-existentes fallan (PasswordReset rutas, ExampleTest / 302, drift de mensajes “exitosamente” vs “correctamente”). Verificado que cero tests asertan contra strings EN de validación, así que mi switch no rompió ninguno.
  • (2026-05-13) Bug: la quick-add de cliente en el POS no mostraba errores de validación — el form formNuevoCliente en POS/Index.vue no renderizaba formNuevoCliente.errors.*, así que un 422 (email duplicado por unique:clientes, email mal formado, teléfono >20, etc.) dejaba la forma muda y se percibía como “no se puede registrar clientes”. Fix: agregado banner arriba + mensajes inline bajo cada campo + borde rojo cuando el campo tiene error, todos con variantes dark: para legibilidad. El form /clientes/create admin ya usaba <InputError>, solo faltaba esto en POS.

  • (2026-05-14) Buscador en lista de clientesClienteController::index ahora acepta ?q= y filtra Cliente::query() con WHERE LIKE %q% sobre nombre, apellido, telefono, email y CONCAT(nombre, ' ', apellido) (para soportar búsqueda “Luis Hernández”). Frontend en Clientes/Index.vue con input + MagnifyingGlassIcon + botón limpiar, router.get con preserveState+preserveScroll+replace, debounce 400ms (mismo patrón de Reportes/Ventas.vue). Empty state diferenciado para “sin resultados” vs “sin clientes”. Paginator usa withQueryString(). Tests Pest: 5/5 verdes incluyendo el nuevo con 7 escenarios. Pint corrido. No deployado todavía — esperando autorización de Sergio.

  • (2026-05-13) Captura de aumentos de optometría por cliente — Form admin completo en /clientes/{cliente}/optometrias (OD/OI/DP/observaciones, todos opcionales, historial), banda en POS para validar con cliente, impresión en ticket solo si la venta incluye optometría. Commits f4caf0a + d68c85a (step=“any”). 6/6 tests.

  • (2026-05-13) Emails de recordatorio de cupones de descuento por fidelidaddescuentos:enviar-recordatorios artisan + scheduler diario 09:00 local. 5 puntos (días 3/7/15/25/30 desde generación), textos literales de la imagen de Aarón, branding reusado de emails/layout.blade.php. Idempotente vía columna recordatorios_enviados JSON. Commit 092f076. 10/10 tests.

  • (2026-05-13) Leaderboard de vendedoras en POS — widget flotante bottom-left, ordenado por ticket promedio sem lun-dom, polling 5 min, mensaje “Las mejores de Holbox no venden barato. Crean experiencia” + bono semanal configurable + feature flag leaderboard_activo (default off, solo admin lo ve hasta que el admin del cliente apruebe) + colapsado en móvil. 4 commits pusheados a origin/main el 2026-05-13: 05f1f765cf4a6edadbef4de67123. Deploy GHA run 25832757521. Aprobado por el admin del cliente 2026-05-14. Falta solo la acción de UI: Sergio activa toggle leaderboard_activo + setea bono_lider_semanal en /configuracion.

  • (confirmado 2026-05-14) Supuestos de la columna optometría del reporte — admin del cliente validó: se cuenta por línea de venta con add-on, independiente del tipo (antirreflejante/tinte/transition agrupados). Coincide con la implementación actual en app/Http/Controllers/Reportes/AvanzadosController.php:134 (SUM(CASE WHEN tipo_optometria IS NOT NULL THEN 1 ELSE 0 END)). Cero cambio de código necesario.

  • (2026-05-11) Implementar las 4 reglas nuevas de comisión (Fase 1 — MVP que aplica al reporte). Implementado + deployado a prod vía GitHub Actions (commit ea70491).

  • (2026-05-14) UI admin para gestionar reglas de comisión (Fase 2). CRUD + toggle implementado. Detalle abajo en bitácora 2026-05-14 (PM-2). No deployado todavía — esperando autorización de Sergio.

  • 📅 2026-06-02 — (opcional pulir) Autocomplete de productos en el form — hoy el select de productos lista todos (26 en dev, presumiblemente más en prod). Si se vuelve incómodo, cambiar a autocomplete por SKU/nombre. Por ahora la lista es manejable.

  • 📅 2026-06-03 — Versión anterior del pendiente (preservada como historia): Diseño tentativo:

    • Ruta: /admin/comision-reglas (middleware role:admin).
    • Vistas Inertia: Admin/ComisionReglas/Index.vue (tabla), Admin/ComisionReglas/Form.vue (crear/editar).
    • Controller: Admin\ComisionReglasController con resource methods (index, create, store, edit, update, destroy/toggle).
    • Campos del form:
      • Trigger (select: categoria / addon_optometria / producto)
      • Categoria (select, requerido si trigger=categoria, oculto si no)
      • Producto (autocomplete por SKU o nombre, requerido si trigger=producto, oculto si no)
      • Tipo (select: por_unidad / por_paquete)
      • Monto (input number)
      • Unidades por paquete (input number, requerido si tipo=por_paquete)
      • Descripción (input text, opcional pero recomendado)
      • Activa (toggle)
    • Validaciones server-side: ya definidas en projects/holbox/README.md (constraints lógicos del trigger + tipo).
    • Tabla: columnas Trigger / FK referenciada (nombre de categoría o producto) / Tipo / Monto / Paquete / Descripción / Activa (toggle inline) / Acciones.
    • Soft toggle: ya hay flag activa — el toggle del frontend solo cambia ese campo (no borra historia). Borrar duro solo cuando una regla era un error puro.
    • Cuidado: si Sergio edita o desactiva una regla, el reporte semanal en curso recalcula con la nueva config — Aarón debería avisar a las asociadas antes de cambios que las afecten (regla operativa ya capturada en este doc).
    • Estimado: medio día (~4h) para CRUD básico funcional. Otro medio día para pulir UX (autocomplete de productos, mensajes de validación, confirmaciones de toggle, etc.).
  • (2026-05-12) Bug en reporte de ventas: el filtro se pierde al regresar del detalle. Fix aplicado en Show.vue: la flecha “volver” pasó de <Link href=route('reportes.ventas')> a <button @click="goBack"> que invoca window.history.back() con fallback a router.visit(route('reportes.ventas')). Validado en prod 2026-05-12. Commit 3bab159.

  • (2026-05-12) Bug PWA: no instalable en escritorio + ícono/nombre viejos al reinstalar en celular. Resuelto en 4 capas: PNGs 192/512/maskable + apple-touch (commit 61615d0); naming alineado en AppServiceProvider (commit 7cc0227); nginx Content-Type para .webmanifest; cliente debe limpiar “Cached images and files” en Chrome Android. Validado en escritorio + Android.

  • (verificado 2026-05-14) Selector de sucursal en reporte de comisiones (frontend) — el control ya existía desde commit 8caf5907 (2026-03-26): <select v-model="sucursalId"> con props.sucursales, leyendo props.filters.sucursal_id, en el watch de fetchReport. Lo que faltaba era el backend, que Sergio completó en b563c7c (2026-05-12). El pendiente estaba mal redactado — no faltaba UI, faltaba cerrar el circuito que la UI llevaba esperando.

  • (2026-05-19) Totales de optometría en reporte de ventas — cantidades + montos. Implementado en dos lugares (Sergio pidió que el reporte clásico también lo mostrara):

    1. Reportes/Avanzados (tab Desempeño Equipo): la celda de Optometría muestra conteo (badge ámbar) + monto facturado debajo en gris, oculto cuando es $0. Backend en AvanzadosController.php agrega SUM(CASE WHEN tipo_optometria IS NOT NULL THEN precio_optometria * cantidad ELSE 0 END) as monto_optometrias al select por empleada. Commit ea9be52.
    2. Reportes/Ventas (reporte clásico): nueva 4ª KPI card “Optometría” (grid pasó a lg:grid-cols-4) con monto facturado + cantidad de líneas. Backend en VentasController.php agrega cantidad_optometrias y monto_optometrias al resumen, calculados con subquery whereIn que respeta TODOS los filtros (fechas/sucursal/asociada/estado/metodo_pago/producto/categoría). Commit 5938ba2. Tests 5/5 verdes (2 de avanzado + 3 de ventas incluyendo respeto a filtro de asociada). Deployado a prod 2026-05-19 vía GHA run 26122121308.
  • (2026-05-19) Asociadas: vista de “mis ventas del día” con totales por categoría. Implementado: endpoint GET /pos/mis-ventas-hoy (PosController::misVentasHoy, route name pos.misVentasHoy) que devuelve ventas (folio/hora/sucursal/método/total/num_lineas) + unidades_por_categoria (GROUP BY categoría, orden DESC por unidades) + totales (num_ventas/total_vendido/unidades_total), filtrado por auth()->id() + estado completada + día operativo TZ local (LocalDateRange::singleDay(todayYmd())). UI: botón “Mis ventas” verde esmeralda en la cabecera del POS al lado del de Corte, abre modal estilo abrirHistorial con 3 KPI cards (ventas/total/unidades) + lista compacta de ventas + chips de unidades por categoría. Tests Pest 2/2 verdes (PosMisVentasHoyTest — aislamiento por user_id, exclusión de canceladas, exclusión de ayer, orden por unidades). Commit 79232f5. Deployado a prod 2026-05-19 vía GHA run 26122121308.

  • (2026-05-18) Excluir categorías del disparador de precio_sale (bolsitas). Implementado y deployado a prod. Commit 04ad770, GHA run 26066193131 (49 s, success). Verificado en prod: BOLSITA y ESTUCHE quedaron cuenta_para_precio_sale=false, las 6 categorías de lentes a true. Detalle abajo en bitácora 2026-05-18 (PM-3).

  • #136 (CERRADO 2026-05-22) Bono semanal de leaderboard en el reporte de comisiones. Implementado: ReporteController::comisiones computa la líder global (asociada con mayor ticket_promedio en la ventana lun-dom, tiebreak por ventas_total — misma lógica que LeaderboardController) y agrega campo bono_lider por fila + esquema.bono_lider_semanal. La columna “Bono Líder” muestra chip ámbar con trofeo en la fila de la líder; el monto se suma a comision_total para que Aarón pague todo en una sola línea. Líder se calcula sin filtrar por sucursal (es un bono global); si la líder no aparece en el listado al filtrar por sucursal, el bono no se muestra ahí — esperado, Aarón corre el reporte sin filtro para nómina. 3 tests Pest nuevos (líder recibe el bono y otros no, default 0 sin config, gerentes/admin nunca son líderes aunque tengan ticket promedio alto). Suite 267/38 (baseline 264/38, +3 verdes, cero regresión). Commit f0eccd6, GHA run 26316253665.

  • #137 (PRIORIDAD ALTA — Aarón 2026-05-22, CERRADO 2026-05-22) Descuento empleadas con autorización admin diferida. Feature end-to-end completo en prod en 3 PRs el mismo día. PR1 (e27626b, schema + flag + setting + UI clientes): 12/12 tests. PR2 (6411254, cola admin + panel /admin/descuentos-empleado + botón POS solicitar + badge polling): 21/21 tests. PR3 (48d514f, consumo en procesar() + modal “Aprobadas” en POS + comando expirar + guards fidelización): 19/19 tests. Suite completa 264/38 vs baseline 212/38 (+52 verdes acumulados, cero regresiones). Detalle completo en bitácora 2026-05-22 (3 entradas). Plan ejecutable: ~/.claude/plans/me-esta-pidiendo-prioridad-indexed-cupcake.md.

  • 📅 2026-06-04 — #148 (NUEVO 2026-05-21, bloqueado por Aarón) Registrar Holbox Óptica en Google Business Profile. Sin el local registrado en Google Maps, cuando el cliente da clic en el pin del template WhatsApp (optica_ubicacion_v1), Maps no encuentra “Holbox Óptica” y resuelve a otro lugar. Mitigación corta aplicada 2026-05-21: address en .env extendido a “Avenida de la Raza 7030, 32500 Cd. Juárez, Chih.” para que Maps geocodifique la calle correcta. Solución de fondo: Aarón crea el perfil del local en Google Business Profile (gratis); cuando Google lo apruebe, el pin abrirá la ficha real del local.

  • 📅 2026-06-05 — #160 (NUEVO 2026-05-22, PAUSADO 2026-05-24) Revisar habilitar recepción de llamadas al número de WhatsApp Business. Análisis cerrado: la solución técnica ideal es WhatsApp Business Coexistence (Meta mayo 2025) — mismo número en app móvil + Cloud API simultáneo vía Messaging Echoes, llamadas en celular, sin costo extra, sin tocar nuestro código. Pausado porque (a) Sergio no ve la opción “Coexistence” en Meta Business Manager del WABA de Holbox (rollout regional incompleto a 2026-05) y (b) Aarón prefiere no hacer setup elaborado. Despausa condicional a decisión sobre #165 (cotización CRM) — si va por CRM SaaS, el CRM probablemente cubre llamadas y #160 queda N/A. Detalle completo bitácora 2026-05-24 (noche).

  • #161 + #162 (CERRADO 2026-05-27 — 3 entregas en prod) 💰 FACTURABLE a Aarón — Reporte semanal por empleada “Resultados de la semana” + impresión PDF. 3 entregas commiteadas y pusheadas a prod en la misma sesión: Entrega 1 1350caa + 8f68a0c, Entrega 2 ed531a9, Entrega 3 259697a (GHA 26553517448). Suite 371/28 (mismos 28 preexistentes, +14 verdes vs baseline 357). Tabla comentarios_semanal_empleada + endpoint guardarComentario + vista A4 print-friendly en Reportes/ImprimirReporteEmpleada.vue con los 4 bloques (resultados+comparativa, meta próxima semana con frase fija, comentario coach, firmas). Sin librería PDF — usa @media print del browser (decisión de Sergio sobre 2 alternativas más). Permisos admin+gerente. Cotización confirmada a $350 MXN/h × horas reales. Spec original abajo:

    • Bloque 1 — Resultados de la semana (por empleada, lun-dom TZ Cd. Juárez): venta total $, tickets realizados, ticket promedio, premium vendidos, clásicos vendidos, acetatos vendidos, optometría vendidos, conversión premium %, accesorios/add-ons/upsells, ranking general (posición en leaderboard semanal).
    • Bloque 2 — Comparación vs semana pasada (genera crecimiento real): cada métrica del bloque 1 con su valor “esta semana” / “semana pasada” / variación % (ticket promedio, premium, conversión premium, etc.).
    • Bloque 3 — Meta de la nueva semana: específica, medible, alcanzable. Campos editables por admin (o sugeridos por sistema desde el delta de la semana pasada): ticket promedio meta, premium meta, conversión premium meta, posición meta en ranking. Cierre con frase fija “Tu crecimiento depende de tu enfoque diario. Aquí y ahora.”
    • Bloque 4 — Comentario del coach: campo free-text que Alfonso (o admin) edita por empleada antes de imprimir/entregar. Ejemplos del spec: “Excelente mejora en premium. Ahora enfócate en subir add-ons.”, “Muy buena actitud con clientes. Trabajar cierre de segunda pieza.”
    • Sub-tarea #162: impresión por empleada — exportar a PDF (o vista print-friendly) un reporte individual por empleada para entregarlo físicamente / mandar imagen por WhatsApp. Probable mismo patrón que el ticket público (Browsershot/snappy a futuro, o window.print() con stylesheet print). Decidir formato 1 hoja A4 o ticket largo.
    • Notas operativas:
      • Categoría “Acetatos” depende del #163 (hay que crearla primero para que el bloque 1 tenga datos).
      • “Add-ons / upsells / blue light”: Aarón mencionó que cuando entren los blue light, a la hora de cobrar la asociada agrega un blue light extra a la venta (ticket normal + blue light = upsell). Este upsell debe contarse en la métrica de add-ons del reporte. Modelado tentativo: marca el producto como es_addon=true en productos (o por categoría), reporte cuenta líneas con ese flag presentes en ventas con ≥1 producto principal.
      • “Conversión premium %” = (tickets con ≥1 premium) / (total tickets) × 100.
    • Tamaño: feature grande — separar en 3 entregas (resultados + comparativa, meta editable, comentario + impresión). Confirmar prioridad con Sergio antes de arrancar.
    • Cotización cliente-facing lista 2026-05-26: propuesta-reporte-semanal-aaron-2026-05.md. Total 26-37h, $9,100-$12,950 MXN divididos en las 3 entregas. Esperando OK de Aarón antes de tocar código. Sergio revisa el doc, ajusta lo que quiera y lo manda.
  • #163 (CERRADO 2026-05-25) Categoría con flag de presentación separada en TOP SKUs. Implementado y deployado. La categoría real en prod se llama “Holbox I.A TR-90” (id=9, 95 productos, creada 2026-05-22), no “Acetatos” como sugería el spec original. Flag categorias.separar_en_top_skus (boolean default false), checkbox en Create/Edit /categorias con dark-mode pareado y descripción explicativa, backend AvanzadosController con LEFT JOIN a categorias + partition() por flag, frontend con componente reutilizable SkuRankingTable.vue (sort interno, ranking #1..#N por ingresos) renderizado 1 general + N separados (accent ámbar). Participación recalculada por grupo independientemente (general excluye separadas; cada grupo separado calcula % sobre sus propias unidades). 4 tests Pest verdes; suite 289/38 vs baseline 285/38 (+4, 0 regresiones). Commit 2035963, GHA run 26419939509. Falta de Sergio: prender el flag en /categorias/9/edit cuando deploy esté verde + aprovechar para corregir reporte_equipo_nombre=null de la misma categoría (saldría con label vacío en Desempeño Equipo).

  • #164 (CERRADO 2026-05-25) Vista de detalle de compras por cliente. Nueva ruta /clientes/{cliente}/compras con controller Cliente\ComprasController (controller separado siguiendo convención de OptometriaController). Header con datos del cliente + nivel fidelidad, 4 KPI cards (compras / total facturado / optometrías / canceladas) que reflejan los filtros aplicados, tabla principal con badges por venta (cupón verde / empleada rosa / optometría ámbar / regalo azul / cancelada rojo + título tachado) y detalle expandible inline mostrando líneas con SKU + categoría + precio + cantidad + flags por línea (regalo, tipo de optometría + monto add-on). Link “Ver ticket” en cada fila a /ventas/{id} para vista completa. Decisiones de producto: gerente ve TODAS las sucursales (necesario para reclamos cruzados, contrario al reporte general), canceladas ocultas por default + checkbox para incluirlas, sin default de fecha. Backend con filtros when() + boolean() parse + whitelist de sort + resumen pre-paginate con clone $query + eager loading selectivo with(['detalles.producto.categoria:id,nombre', 'sucursal:id,nombre', 'user:id,name', 'codigoDescuento:id,codigo,porcentaje,motivo', 'solicitudDescuentoEmpleado:id,porcentaje']). Frontend con debounce 400ms + preserveState/preserveScroll/replace, expandible vía ref(new Set()) clonado para reactividad de Vue, dark-mode pareado en todo. Botón “Compras” en columna de acciones de Clientes/Index.vue (icono ShoppingBagIcon color emerald) + link “Ver historial de compras” en header de Clientes/Edit.vue. 7 tests Pest verdes (autorización admin/gerente, aislamiento por cliente, scope gerente cross-sucursal, filtros sucursal/categoría, incluir_canceladas default vs explícito, resumen pre-paginate respeta filtros, badges expuestos en estructura Inertia). Suite 296/38 vs baseline 285/38 (+11 verdes — 7 míos + 4 que se desbloquearon al re-buildar manifest de Vite con la página nueva, 0 regresiones). Pint pass. Commit bbdd35e, GHA run 26421608104 ✅ success.

  • (2026-05-26) #165 — Cotizar CRM para Holbox (pre-research entregado). Documento completo en research-crm-2026-05.md. Recomendación primaria: piloto Kommo Base con NÚMERO DEDICADO 1 mes ($30-50 USD/mes). Bloqueador crítico documentado (WABA único impide conectar CRM al número actual). 6 candidatos evaluados. Bola en cancha de Sergio→Aarón con 4 preguntas críticas. Pendientes derivados abiertos abajo (CRM-PIL-01..03 piloto + CRM-INT-04/05 💰 integración + CRM-IH-99 💰 in-house alternativo). El research en sí no se cobra; los de integración/in-house sí, a $350 MXN/h.

  • (2026-05-26) #165 follow-up — Propuesta cliente-facing redactada para Aarón. Doc en propuesta-crm-aaron-2026-05.md. Es la versión “para mandar” derivada del research interno: módulo Conversaciones in-house, sin mención de IA ni Anthropic, tiempos comprimidos por flujo asistido (54h totales = Fase 1 inbox 28h + Fase 2 asistente configurable + integración holbox.store + escalado 26h, 3 semanas calendario, $18,900 MXN una sola vez vs $1,800 MXN/mes de Kommo). Reportería sacada de scope inicial — datos quedan registrados desde día 1, las pantallas se cotizan aparte (~$1.75-2.8k) si Aarón las pide después. Incluye funcionalidad detallada (bandeja unificada, asistente con guion editable, conexión con catálogo de holbox.store para precios/stock/links, escalado, asignación equipo, pipeline prospectos), 5 casos de uso narrados (incluye uno con tienda online), matriz comparativa contra Kommo/Respond.io con TCO 12/24/36 meses (break-even baja a mes 10), plan de arranque con Fase 1-2 traslapadas levemente y modo sombra antes de autoenvío, 4 cosas que Sergio necesita de Aarón para iniciar (incluye acceso a holbox.store, no urge hasta mediados de Fase 2). Bola en cancha de Sergio — leer la propuesta, ajustar lo que quiera y mandarla a Aarón cuando guste.

  • #255 (CERRADO 2026-05-28) — Propuesta cliente-facing enviada a Aarón + APROBADA. Sergio le mandó propuesta-crm-aaron-2026-05.md y Aarón autorizó la propuesta in-house ($18,900 MXN / 54h / 2 fases / 3 semanas). Arranca la construcción → desbloquea #256 (Fase 1). Pendientes operativos heredados para conseguir de Aarón conforme avanza: (2) lista inicial de preguntas comunes para el guion del asistente (material de Fase 2); (3) definir quiénes del equipo reciben notificaciones de escalado y quiénes pueden tomar conversaciones; (4) acceso al backend de holbox.store (no urge para Fase 1, sí a mediados de Fase 2). Detalle en bitácora 2026-05-28.

  • #256 📅 2026-06-07 · ⏱ 28h (cotizado) · 🔥 💰 FACTURABLE (DESBLOQUEADO 2026-05-28 — Aarón aprobó) — Fase 1: Bandeja básica + atención manual. Webhook inbound extendiendo el del #149 para procesar messages, tablas conversaciones + mensajes_wa + identificación cliente/prospecto, UI /admin/conversaciones con bandeja + chat view + asignación humana + estado activa/archivada + pipeline de prospectos. Entregable independiente y útil aunque no se haga Fase 2 — el equipo puede ver y contestar todos los mensajes en un solo lugar. 28h × $350 = $9,800 MXN cotizado a Aarón (facturación = horas reales × tarifa con flujo asistido, ver regla del hub). EN PROGRESO — plan técnico cerrado en plan-crm-fase1.md (preguntas P1/P2/P3/P5 resueltas por Sergio, ver bitácora). 3 entregas deployables: ✅ E1 captura inbound ~11h DEPLOYADA A PROD (commit a8ad89e, GHA 26605651268 verde — migraciones + backfill telefono_e164 corridos en prod); ✅ E2 bandeja read-only ~10h DEPLOYADA A PROD OCULTA (commits 30632da + bdcf05e, GHA 26659946880 verde; sin link en menú a propósito para testing previo a entrega — acceso solo por URL /admin/conversaciones, role:admin; revertir bdcf05e al entregar); ✅ E3 atención+envío+conversión ~9h DEPLOYADA A PROD (commit 9769533, push 2026-05-29 16:18 local; menú sigue oculto — revertir bdcf05e al entregar). tenant_id nullable desde día 1 (decisión #258). Prospecto = tabla nueva (no flag en clientes). Decisiones: telefono_e164 materializado (P1), solo admin atiende (P2), hilo global (P3), imágenes desde día 1 (P5).

  • 📅 2026-06-08 — #257 💰 FACTURABLE (condicional a #256 entregada) — Fase 2: Asistente automático + integración holbox.store + escalado. Servicio IAResponder con Antigravity Haiku 4.5 (interno, no se menciona al cliente) + guion configurable desde /admin/asistente (textarea editable, versionado, variables {nombre_negocio}/{catalogo}/{promociones}), conexión con catálogo de holbox.store (sync periódico configurable, productos/precios/stock/links directos), lógica de escalado (intent “humano” / queja / fuera de guion / N mensajes sin resolver), notificaciones push+email al equipo, modo sombra inicial (asistente propone, humano aprueba y envía) configurable. 26h × $350 = $9,100 MXN, 1.5 semanas calendario (con leve traslape sobre el final de Fase 1). Costo recurrente Anthropic estimado $3-15 USD/mes para Holbox (a cargo del cliente, no del desarrollo). Los datos para reportería quedan registrados desde día 1 — pantallas en #260.

  • 📅 2026-06-10 — #258 — Fase 3 multi-tenant opcional (interno, NO en propuesta cliente-facing). Abstracciones tenant_id en conversaciones/mensajes_wa/asistente_config, panel super-admin, docs de adopción para portar a Joyerías Meza / Greco Cell / Deportes Campeón. ~30-40h × $350 = $10,500-14,000 MXN, cobrable a cada cliente que adopte después. Decisión técnica: dejar tenant_id como columna nullable desde Fase 1 para no migrar después si Sergio decide adoptar el módulo en otro cliente sin tocar a Holbox. No mencionar a Aarón salvo que pregunte.

  • #259 (DESCARTADO 2026-05-28 — N/A) 💰 ALTERNATIVO a #256-#258 si Aarón elegía Kommo Pro. Aarón eligió la propuesta in-house, no el SaaS, así que esta ruta queda descartada. Se preserva por si en el futuro Holbox reconsidera migrar a Kommo. Spec original: migrar mensajería transaccional de Holbox a la API REST de Kommo. Reapuntar ticket_holbox_v3, fidelidad_dia_*, optica_* a Kommo en vez de Meta Cloud API directa. Setup inicial de Kommo + import 659 clientes via CSV + pipeline. ~10-15h × $350 = $3,500-5,250 MXN one-time. Aarón paga $90 USD/mes recurrente a Kommo. Min contrato 6 meses; evaluar a los 5 meses si renovar.

  • 📅 2026-06-11 — #260 💰 FACTURABLE (extensión opcional posterior a #257) — Reportería del módulo Conversaciones. Pantallas: conversaciones por día/semana/mes, % resueltas por asistente vs humano, tiempo de respuesta promedio, preguntas más frecuentes (útil para mejorar el guion), prospectos → clientes con tasa de conversión. Los datos ya están registrados desde el día 1 (Fase 1+2 los persisten), solo se construyen las vistas. ~5-8h × $350 = $1,750-2,800 MXN. Cotizar y arrancar cuando Aarón la pida (típicamente mes 2-3 tras arranque del módulo).

  • #208 (CERRADO 2026-05-25) Clonar traslados #00042/#00043/#00044 (CEDIS → MISIONES I) a MISIONES II, MISIONES III, SENDERO y RIO GRANDE. 12 traslados nuevos creados en una sola transacción: del #42 → #45/#46/#47/#48 (86 líneas c/u), del #43 → #49/#50/#51/#52 (3 líneas c/u), del #44 → #53/#54/#55/#56 (3 líneas c/u). Todos en estado pendiente, mismas líneas/cantidades, user_id y notas heredados. Detalle en bitácora 2026-05-25. Bandera pendiente de Sergio: avisar a Aarón que CEDIS tiene 0 unidades en ~22 productos del listado antes de que el almacén intente “Enviar”.

  • (2026-05-26) #245 — Editar traslados pendientes desde la UI. Implementado e implementación pusheada (commit 3b4536b, GHA en progreso). Modo edición inline en Show.vue con tabla editable + selector de agregar + textarea de notas; backend update() con abort_if(!isAuth,403) + detalles()->delete()+recreate en transacción + auditoría (editado_at/editado_por_user_id). Bloqueado para enviado|recibido|cancelado. 11 tests Pest verdes. Detalle en bitácora 2026-05-26.

  • 📅 2026-06-12 — Atender el flujo continuo de cambios pedidos por el cliente post-rollout.

Acceso al servidor de producción

  • Alias SSH: holbox164.90.246.248 (DigitalOcean), user sergio. Read-only OK.

En progreso

  • Iteración post-deploy: lo que vaya saliendo del uso real en sucursales.

Notas técnicas

Stack

  • Laravel 12, PHP 8.5.3
  • Laravel Sail (Docker)
  • Pest, Pint, Boost MCP
  • PHPUnit 11

⚠️ Sobre el ANTIGRAVITY.md del codebase

Tiene un preámbulo titulado “Laravel Sail & Gemini 3.1 Pro Project Rules” que menciona “Gemini 3.1 Pro” con un modelo y features inventados (Thinking Mode, Structured Outputs como prácticas obligatorias). Es ruido AI-generado, ignorar. Las únicas reglas reales del preámbulo que vale la pena respetar son las de Sail/Pest/Pint/PHPStan, ya cubiertas por la sección estándar de Laravel Boost.

Convenciones (Laravel Boost estándar)

  • Comandos vía vendor/bin/sail.
  • Pest tests, Pint formato (vendor/bin/sail bin pint --dirty --format agent).
  • Modelos: tipos estrictos, retornos tipados en relaciones, Enums para statuses.
  • Antes de migraciones: vendor/bin/sail artisan schema:dump o checar migrations existentes.

Bitácora

2026-05-29 (tarde) — #256 CRM Fase 1 Entrega 2 implementada (bandeja read-only)

Pidió Sergio: “vamos a avanzar con el módulo CRM”. Siguiente entrega lógica = E2 (E1 ya estaba en prod). Implementada y commiteada sin push (push dispara GHA→deploy prod, requiere auth per-sesión).

Qué se hizo (commit 30632da en holbox, repo limpio sobre a8ad89e):

  • app/Http/Controllers/Admin/ConversacionController.phpindex (Inertia, conteos por estado) · feed (JSON polling de la lista, resumen por conversación con preview/no_leidos/ventana, ordenado por actividad) · show (JSON hilo + ficha contacto, resetea no_leidos al abrir) · marcarLeida · media (sirve los binarios que el Job DescargarMediaWa de E1 guarda en disco local, ruta protegida role:admin).
  • Rutas (en routes/web.php, dentro del grupo role:admin): admin.conversaciones.index/feed/show/leida/media.
  • Frontend (Inertia + Vue 3, polling estilo Leaderboard.vue — lista 5s, hilo 4s, onUnmounted clear): resources/js/Pages/Admin/Conversaciones/Index.vue (orquestador 3 columnas) + componentes Conversaciones/{ConversacionLista,ConversacionHilo,FichaContacto}.vue. Lista agrupada por estado (esperando/tomada/archivada) con badge no_leidos; hilo con burbujas in/out, imágenes inline (resto de media como link), estado de ventana 24h; ficha cliente (fidelidad/últimas compras/optometría/sucursal) o prospecto. Dark mode pareado + sin features CSS post-Chrome 109 (Win7 ok). Link “Conversaciones WhatsApp” agregado al dropdown admin del AuthenticatedLayout.
  • E2 es read-only: sin tomar/responder/convertir (eso es E3); pie del hilo lo aclara en banda.
  • Tests: tests/Feature/ConversacionesBandejaTest.php, 8/8 verdes (index+conteos, asociada→403 en index y feed, feed ordenado+preview, prospecto vs cliente, show ficha cliente + reset no_leidos, show prospecto, marcarLeida). Suite WhatsApp: los 8 fallos de EnviarRecordatoriosWhatsappTest son preexistentes (verificado con stash A/B — fallan idéntico sin mis cambios), cero regresión. Pint OK. npm run build corrido (necesario para que el test de página Inertia encuentre el componente en el manifest de Vite).

Push + deploy hechos (2026-05-29): Sergio pidió que el feature no aparezca en menús para probarlo en prod antes de entregarlo (que el equipo no entre sin avisar). Se quitó el link del dropdown admin (commit bdcf05e, comentario inline para revertir al entregar) y se pusheó → GHA 26659946880 verde, E2 vive en prod oculta. URL de testing (solo admin, por URL directa): https://holbox.val-soft.com/admin/conversaciones. Endpoints que consume la UI: /admin/conversaciones/feed, /admin/conversaciones/{id}, /admin/conversaciones/{id}/leida, /admin/conversaciones/mensajes/{id}/media.

Entregar el feature (cuando Aarón lo apruebe): revertir bdcf05e (git revert bdcf05e) para que reaparezca el link “Conversaciones WhatsApp” en el menú admin, build + push.

Validado en prod 2026-05-29: Sergio entró a /admin/conversaciones en prod y confirmó que la bandeja se ve bien. E2 aceptada.

✅ E3 atención + prospectos IMPLEMENTADA Y COMMITEADA (commit 9769533, sin push) — cierra el desarrollo de Fase 1. WhatsAppSender::sendText (texto libre, registra en whatsapp_mensajes para reusar el webhook de status #149); tomar (candado optimista atómico → 409 si ya la tomaron) / devolver / archivar; enviar (valida ventana 24h → 422 fuera, persiste out con status real); ProspectoController index + convertir (transacción: crea cliente, repunta conversación al cliente nuevo, conserva historial). Frontend: botones de atención + input de envío en el hilo, “Convertir a cliente” en la ficha, página /admin/prospectos. Sigue oculto del menú (mismo patrón que E2). 10 tests Pest nuevos verdes (18 con E2); cero regresión (38 fallos preexistentes idénticos con/sin E3, verificado con stash A/B); Pint OK.

✅ Push+deploy de E3 a prod (2026-05-29 16:18 local). Sergio autorizó el push. Commit 9769533origin/main, GHA disparado. Falta: testing en prod de las features de E3 (tomar/responder/convertir prospectos en /admin/conversaciones) → entrega formal a Aarón (revertir bdcf05e para mostrar el menú). Con esto el #256 queda listo para cerrar.

2026-05-29 — #383 reprogramado a la siguiente semana

Sergio pidió mover #383 (reducir fallos de WhatsApp, fix de calidad de dato en 2 capas) a la siguiente semana. Cambié su tag de 📅 cond:aprobacion-aaron a 📅 2026-06-01 (fecha tentativa). Nota: sigue siendo 💰 FACTURABLE y aún depende de que Aarón apruebe el reporte de causa de falla antes de arrancar código.

2026-05-28 (tarde) — CRM Fase 1 Entrega 1 implementada (#256) — captura de WhatsApp inbound

Pidió Sergio: resolvió las 4 preguntas abiertas del plan (P1=materializar clientes.telefono_e164; P2=solo admin atiende; P3=hilo global por número; P5=imágenes visibles desde el día 1). Con eso, plan cerrado → arranqué Entrega 1.

Qué se hizo (commit a8ad89e en holbox, SIN push — espera autorización de Sergio):

  • Migraciones: prospectos, conversaciones, mensajes_wa (las 3 con tenant_id nullable+index desde el día 1 para multi-tenant futuro #258) + clientes.telefono_e164 materializado con backfill (P1).
  • Modelos Prospecto/Conversacion/MensajeWa con consts de estado/tipo/dirección y relaciones. Cliente recibe hook saving que mantiene telefono_e164 en sync + relación conversacion().
  • PhoneNumber::fromWhatsAppId() — normaliza el wa_id que manda Meta; maneja el legacy mexicano 521XXXXXXXXXX (el 1 de móvil que libphonenumber marca inválido) cayendo a toE164Mx. Sin esto, los clientes MX no matchearían.
  • Webhook WhatsappWebhookController::receive() ahora procesa messages[] además de statuses[] (sin tocar firma HMAC ni handshake). Cada inbound aislado en try/catch → siempre 200 salvo firma/JSON inválido (Meta no reintenta en loop por error de negocio).
  • Servicio ConversacionInbound — idempotencia por unique(wa_message_id), match cliente (por telefono_e164) o alta de prospecto, upsert de conversación con ventana de 24h de Meta, inserción del mensaje en el hilo.
  • Job DescargarMediaWa — baja el binario de media entrante de Meta (2 pasos: resolver URL temporal + descargar con bearer) a storage/whatsapp-media/ (P5).
  • Config: services.whatsapp.meta.api_version (default v20.0) para el sender y la descarga.

Tests: 28 Pest verdes (11 inbound + 4 media job + 13 del webhook #149 sin regresión). Baseline de la suite completa: 14 fallos preexistentes (POS/kiosk, 302 redirects) → con mis cambios 13 fallos (1 menos) → cero regresión introducida, verificado con git stash A/B. Pint aplicado.

Útil sola (E1): los mensajes entrantes (texto + imágenes) ya dejan de perderse — quedan persistidos en BD aunque todavía no haya pantalla. Verificable con tinker.

Push a prod: Sergio autorizó el push. Commit a8ad89e deployado vía GHA 26605651268 (verde, ~50s) — migraciones + backfill de telefono_e164 corridos en prod. El webhook de prod ya captura inbound; los mensajes entrantes empiezan a persistirse desde ya.

Próximo paso (otra sesión, decisión de Sergio): Entrega 2 — bandeja /admin/conversaciones read-only (3 columnas estilo WhatsApp Web, polling estilo Leaderboard, ficha cliente/prospecto, solo rol admin), dark-mode pareado + compat Win7. Luego E3 (asignación + envío manual + conversión de prospectos).

2026-05-28 — CRM aprobado por Aarón → arranca construcción (Fase 1 #256)

Pidió Sergio: “Ya le mandé la propuesta CRM a Aarón y la autorizó, vamos a empezar a trabajar en ello.”

Qué significa: la propuesta cliente-facing in-house (propuesta-crm-aaron-2026-05.md) — módulo Conversaciones dentro del sistema actual, $18,900 MXN / 54h / 2 fases / 3 semanas calendario — quedó autorizada. Resuelve #255.

Cambios de estado de pendientes:

  • #255 cerrado — propuesta enviada y aprobada.
  • #256 desbloqueado y activado — Fase 1 (bandeja básica + atención manual, $9,800 MXN cotizado). Fecha tentativa de arranque: esta semana, target entrega 📅 2026-06-07 (1.5 semanas calendario por la cotización). Sergio eligió plan técnico primero antes de codear.
  • #259 descartado (N/A) — era la ruta alternativa Kommo Pro; Aarón eligió el módulo in-house, así que no aplica. Se preserva por si Holbox reconsidera a futuro.
  • #257 (Fase 2 IA + holbox.store), #258 (multi-tenant opcional), #260 (reportería) siguen condicionados a que #256 entregue.

Recordatorio de facturación: los $18,900 / 54h son la cotización cliente-facing aprobada. La facturación real va por horas reales × $350/h con el flujo asistido (regla del hub bill-real-hours), que históricamente baja mucho vs la cotización (ej. #161 cotizado 8-12h, real ~1.5h). La cotización funciona como techo comprometido con Aarón.

Pendientes operativos heredados del #255 (conseguir de Aarón conforme avanza, sobre todo para Fase 2): (2) lista de preguntas comunes para el guion del asistente; (3) quiénes reciben notificaciones de escalado y quiénes pueden tomar conversaciones; (4) acceso al backend de holbox.store (no urge para Fase 1).

Próximo paso: diseñar el plan técnico de Fase 1 (schema conversaciones/mensajes_wa con tenant_id nullable per #258, extensión del webhook inbound del #149 para procesar messages, rutas/controladores, páginas Vue de la bandeja + pipeline de prospectos), presentarlo a Sergio para revisión, y al OK arrancar implementación.

2026-05-27 (cierre madrugada) — #161 Entrega 3 cerrada (commit 259697a, GHA 26553517448) — TODO el #161+#162 deployado a prod

Pidió Sergio: “Vamos a terminar el pendiente #161”. Después de Entregas 1+2 deployadas y validadas, atacar la 3 (comentario coach + impresión PDF) para cerrar las 3 cotizadas y deployadas en la misma sesión.

3 decisiones de diseño confirmadas con Sergio antes de tocar código:

  1. Formato PDF: Print CSS A4 vertical (browser nativo, sin librería PDF, sin dependencias). Descartadas ticket largo 80mm (no aplicable a hoja entregable formal) y DomPDF (overhead innecesario + se lleva regular con Tailwind v4).
  2. Permisos: middleware role:admin,gerente (= mismo del reporte completo). Coincide con el spec “tú o Alfonso”.
  3. Autorización: auth completa para toda Entrega 3 (commit + push + deploy) — igual que Entregas 1 y 2.

Implementación (7 archivos, 902 inserciones / 206 eliminaciones):

  • database/migrations/2026_05_28_000001_create_comentarios_semanal_empleada_table.php — tabla comentarios_semanal_empleada con unique (user_id, semana_iso).
  • app/Models/ComentarioSemanalEmpleada.php — modelo Eloquent espejando el patrón de MetasSemanalEmpleada.
  • app/Http/Controllers/Reportes/SemanalEmpleadaController.php — refactor mínimo: extraído calcularReportes() privado reutilizable entre index() e imprimir(). Nuevos métodos guardarComentario (POST, body vacío → DELETE), imprimir (GET, devuelve Inertia A4). index() carga bulk comentariosEsta (esta semana) + comentariosAnterioresAll (todos los previos, recortado a 8 por user en PHP).
  • routes/web.php — 2 rutas nuevas bajo middleware role:admin,gerente: comentario.update (POST) y imprimir (GET).
  • resources/js/Pages/Reportes/SemanalEmpleada.vue — bloque violeta nuevo bajo la tabla: textarea (rows=3, maxlength 2000) + estado “Guardado” / “Sin guardar” / “Sin comentario”, botón “Guardar comentario” violet, acordeón “Ver N comentarios anteriores” colapsable que lista cada uno con su semana_iso. Pie de tarjeta agregó botón “Imprimir reporte” azul que abre la vista print en _blank.
  • resources/js/Pages/Reportes/ImprimirReporteEmpleada.vue — página standalone (sin AuthenticatedLayout) con toolbar print-hidden (botones Imprimir + Cerrar) + hoja A4 con @page { size: A4 portrait; margin: 0 }. 4 bloques: encabezado (HOLBOX + semana + asociada + ranking), tabla KPI+comparativa, tabla metas próxima semana + frase fija “Tu crecimiento depende de tu enfoque diario. Aquí y ahora.”, caja para comentario coach (línea-print si no hay), pie con firmas Coach/Asociada. setTimeout(() => window.print(), 300) al onMounted dispara el diálogo automáticamente.
  • tests/Feature/Reportes/SemanalEmpleadaComentarioTest.php — 9 tests Pest verdes: crea/sobrescribe/vacía-borra/422/403/gerente-OK; index expone comentario_esta_semana + comentarios_anteriores ordenado desc; imprimir 200 para asociada / 404 para no-asociada.

Suite final: 371 passed / 28 failed. Mismos 28 preexistentes (PosController/TrasladoController/Auth/Profile/CajaCorte/Configuracion/Example — documentados en bitácoras previas). +14 verdes vs baseline 357 post-Entrega 2 (9 míos + 5 que se desbloquearon por rebuild del manifest Vite con las páginas nuevas). Cero regresión.

Vite build: verde 1.67s. Pint: verde (autoformat menor en SemanalEmpleadaController.php agregó Collection import).

Deploy: commit 259697a → push autorizado → GHA 26553517448 (Deploy Holbox App) ejecutándose. Migración 2026_05_28_000001 corre en el deploy estándar.

Horas reales sesión Entrega 3: ~1.5h (decisiones 10min + código 60min + tests 15min + commit/push/docs 10min). Dentro del rango cotizado 8-12h (que asumía flujo no asistido) — facturar al rango bajo $2,800 MXN o ajustar a horas reales × $350 según prefiera Sergio.

Pendiente operativo de Sergio (NO bloquea deploy):

  1. Validar en prod holbox.val-soft.com/reportes/semanal-empleada que aparece bloque comentario coach + botón “Imprimir reporte” + que abre la vista A4 con Ctrl+P lista para imprimir/guardar PDF.
  2. Decidir cómo facturar al cliente las 3 entregas (rangos cotizados o horas reales menores). Mandar factura a Aarón con desglose por entrega.

2026-05-27 (cierre noche) — #368 cerrado: heurística LADA 915 El Paso (commit 424bdc1, GHA 26552660322)

Pidió Sergio: atacar el #368 (LADA 915 El Paso en 10 dígitos). Pidió también UPDATE en BD prod para registros 52915...1915....

Decisión técnica. Sergio escogió ruta híbrida (a)+(b) ligero: política de capacitación al cajero (US con +1 explícito) + heurística mínima solo para LADA 915 (el único caso reportado con volumen real). Descartadas: (c) picker pais_codigo (UX overhead innecesario) y (b) expandido a 5 LADAs fronterizas (frágil sin datos reales de las otras).

Verificación empírica previa al código. Vía sail tinker confirmé:

  • 9158380000 con region=MX → valid=false (LADA MX 915 no existe en plan de numeración).
  • 9158380000 con region=US → +19158380000, válido.
  • Cero colisión con clientes MX legítimos: ningún número MX puede ser interpretado como LADA 915.

Diagnóstico del problema real. No era que se mandaran WhatsApps al número +52 errado — era que PhoneNumber::toE164 retornaba null (libphonenumber rechaza 9151234567 como inválido bajo MX) y los senders fallaban silenciosamente. El cliente nunca recibía el WhatsApp.

Fix (app/Support/PhoneNumber.php:19-37). 7 líneas pre-parse: si defaultRegion === 'MX' Y $digits tiene exactamente 10 chars Y empieza con 915, anteponer +1 al raw antes de delegar a libphonenumber. Cambio quirúrgico, contained, sin tocar toE164Mx (ya no tiene callers en app/).

Tests nuevos (tests/Unit/PhoneNumberTest.php): 5 casos — caso reportado, separadores comunes, idempotencia con +1 explícito, regression de LADAs MX (555/999/921), y override de region a US. Suite PhoneNumber 17/17 verde, WhatsApp+PhoneNumber 72/72.

Verificación en prod. Tras GHA 26552660322 (success), vía SSH:

  • 9158380000+19158380000
  • 915 838 0000+19158380000
  • 5551234567+525551234567 ✓ (regression CDMX)
  • 6566035759+526566035759 ✓ (regression Cd Juárez)

El UPDATE en BD que pidió Sergio NO aplicó. Investigación previa mostró que clientes.telefono guarda raw 10 dígitos (lo que teclea el cajero), no normalizado. Cero registros con 52915... o variantes. La normalización a +52/+1 ocurre solo en runtime al construir el payload para Meta. Por tanto los 56 clientes con 915XXXXXXX quedan auto-resueltos por el fix de runtime sin tocar la BD.

Hallazgos paralelos (no atacados aún):

  • 3 anomalías en clientes.telefono con basura: id=50 (91547963333), id=460 (9157126856131), id=590 (91513008090). Quedan para limpieza manual si Sergio decide.
  • 1 cliente LADA 575 (id=357, 5759158792, NM sur USA) que sufre el mismo bug pero no entra al fix actual. Si reaparecen casos en /admin/whatsapp-logs, abrir nuevo ticket para extender la heurística o pasar a opción (c).

Política paralela (a). Sergio comunica a Aarón/asociadas en sucursales: si el cliente es de El Paso, capturar con prefijo +1 915 ... o al menos 1915 .... La heurística es seguridad; la captura explícita sigue siendo lo deseable a largo plazo (consistencia con clientes de otros países).

2026-05-27 (noche tardía) — #161 Entrega 2 implementada (commit ed531a9, pendiente push a prod)

Entrega 2: metas editables para próxima semana — COMPLETADA localmente.

Archivos nuevos/modificados (10):

  • database/migrations/2026_05_28_000000_create_metas_semanal_empleada_table.php — tabla metas_semanal_empleada con unique (user_id, semana_iso).
  • app/Models/MetasSemanalEmpleada.php — modelo Eloquent con casts y relaciones user()/createdBy().
  • app/Services/MetaSugerenciaService.php — servicio puro sugerirParaProximaSemana(). Tendencia positiva = continúa delta; negativa = no baja; ranking = menor es mejor, mejora-1 o mínimo de las dos.
  • app/Support/LocalDateRange.php — métodos nuevos nextWeekRange() y weekIso().
  • app/Http/Controllers/Reportes/SemanalEmpleadaController.phpindex() extendido con metas_esta_semana, metas_proxima_semana, metas_sugeridas_proxima_semana, proxima_semana_iso, cumplimiento (cumplio/cerca/lejos) por empleada. Nuevos métodos editarMetas GET + guardarMetas POST con updateOrCreate.
  • routes/web.php — 2 rutas nuevas bajo mismo middleware role:admin,gerente.
  • resources/js/Pages/Reportes/SemanalEmpleada.vue — chips de meta en columna “Esta semana” (emerald/amber/red/gray), botón “Editar metas para proxima semana” por tarjeta (violet outline), frase fija de cierre (italic emerald, centered).
  • resources/js/Pages/Reportes/EditarMetasEmpleada.vue — nueva página con selector de semana, 4 inputs con “Usar sugerencia” por campo, botón guardar emerald. Dark mode paired.
  • tests/Feature/Reportes/SemanalEmpleadaMetasTest.php — 7 tests Feature verdes.
  • tests/Unit/Services/MetaSugerenciaServiceTest.php — 5 tests Unit verdes.

Suite final: 357 passed / 28 failed (mismos 28 preexistentes, cero regresiones). Build npm run build OK. Pint OK (3 fixes de estilo menores). Commit ed531a9. NO PUSHEADO — requiere autorización de Sergio.

2026-05-27 (noche tardía) — #161 Entrega 1 validada en prod por Sergio + arranque Entrega 2

  • Sergio validó el Reporte Coach por Empleada en holbox.val-soft.com/reportes/semanal-empleada y confirmó: “se ve bien”.
  • Follow-up menor durante la validación: el reporte no aparecía en el menú de navegación. El agent armó ruta + página + controller pero olvidó agregar el link al AuthenticatedLayout. Agregado en commit 8f68a0c como DropdownLink “Reporte Coach por Empleada (Semanal)” + su ResponsiveNavLink mobile, push a main, deploy auto.
  • Arranque inmediato de Entrega 2 (metas editables + comparativa meta-vs-logrado) delegado a laravel-fixer agent en background con plan técnico cerrado:
    • 4 metas (no las 10 KPIs): ticket_promedio, premium_unidades, conversion_premium_pct, ranking_posicion.
    • Tabla metas_semanal_empleada (unique user_id + semana_iso YYYY-Www).
    • MetaSugerenciaService puro con regla “tendencia positiva = continúa delta; tendencia negativa = no bajar; ranking = mejor de las dos”.
    • Comparativa visual en chips emerald/amber/red (≥100% / ≥80% / <80%) sobre el valor “Esta semana” del reporte.
    • Frase fija de cierre al pie del reporte: “Tu crecimiento depende de tu enfoque diario. Aquí y ahora.”
    • Nueva ruta GET/POST /reportes/semanal-empleada/metas/{empleada} con Vue EditarMetasEmpleada.vue.
    • Tests Pest +12 esperados (7 controller + 5 service).
  • Permisos: mismo middleware role:admin,gerente del reporte.

2026-05-27 (cierre) — #197 deployado a prod + arranque #161 con aprobación de Aarón

  • Pidió Sergio: confirmar estado del #197 (libphonenumber) y avanzar al #161.
  • Verificado: commit 35ac39f (“holbox #197: soporte WhatsApp internacional con libphonenumber-for-php”) está en origin/main, GHA verde, deploy en prod. Local sincronizado.
  • #197 cerrado en checklist + PENDIENTES.md con Resuelto inline. Bandera viva: observar /admin/whatsapp-logs por incidencias LADA 915 que motivaron #368.
  • #161 — Aarón aprobó las 3 entregas de la propuesta cliente-facing ($9,100-$12,950 MXN). Arrancando Entrega 1 (Resultados + comparativa vs semana pasada, 12-16h / $4,200-$5,600 MXN). Pendiente confirmar con Sergio los 2 inputs solicitados a Aarón (lista de add-ons/upsell + categoría acetatos id=9) antes de tocar código.

2026-05-28 — Diagnóstico de mensajes WhatsApp fallidos + columna “causa de falla” en el reporte

Qué pidió Sergio: revisar cuántos mensajes de WhatsApp están fallando y la causa real; proponer solución (delay, reintento o lo que aplique).

Diagnóstico (datos de prod, tabla whatsapp_mensajes, vía artisan tinker SSH a holbox.val-soft.com, read-only):

  • 95 mensajes reales (sin previews): 38 leídos + 25 entregados + 1 enviado + 31 fallidos (33%).
  • Desglose de los 31 fallos por código de error de Meta:
    • 131026 “Message undeliverable” — 28 fallos (90%): 22 en ticket_holbox_v3 (transaccional) + 6 en nivel_alcanzado (marketing).
    • 131049 “ecosystem engagement” (cap de marketing) — 2, en nivel_alcanzado.
    • 130472 “User’s number is part of an experiment” — 1, en nivel_alcanzado.
  • Causa raíz del 90%: números mal capturados o sin WhatsApp. Confirmado con 2 evidencias: (a) los 22 tickets fallidos van a números que NUNCA recibieron ningún mensaje exitoso; de 24 números únicos que fallan, solo 3 alguna vez entregaron. (b) un recipient_id de muestra es 5213034985013 → nacional 303-498-5013, y la LADA 303 no existe en México = teléfono mal tecleado. Fallos repartidos todos los días (25-28 may), no ráfaga = goteo de calidad de dato, no bug de config.
  • Solo 3 fallos son marketing real (131049×2 + 130472), comportamiento por diseño de Meta. El 131049 YA está manejado (auto whatsapp_opt_out vía WhatsappMensaje::OPT_OUT_ERROR_CODES).

Por qué delay/retry NO sirven (descartado con base): el 131026 llega async por webhook DESPUÉS del 200 OK de Meta, así que los reintentos del job ni se disparan; re-enviar al mismo número muerto solo gasta intentos. No es error de rate-limit (sería 130429/131056), así que delay tampoco revive el número. Para marketing (131049/130472) reintentar va contra política de Meta y lastima el quality rating.

Lo implementado esta sesión (paso 1 de bajo riesgo que pidió Sergio): columna informativa de “causa de falla” en el reporte /admin/whatsapp-logs, para que Sergio le muestre a Aarón que la mayoría son números mal tecleados y pueda venderle el fix de fondo. Archivos:

  • app/Models/WhatsappMensaje.php — nuevo const ERROR_EXPLANATIONS (mapa código Meta → {categoria, motivo} en español), const ERROR_CATEGORIA_LABELS, y método estático explicarError(?int, ?string): ?array. Categorías: numero / marketing / opt_out / sesion / sistema / otro.
  • app/Http/Controllers/Admin/WhatsappLogsController.php — cada mensaje expone motivo_falla + categoria_falla; nuevo resumenFallos (conteo de fallidos agrupado por causa, respeta filtro preview).
  • resources/js/Pages/Admin/WhatsappLogs/Index.vue — banner-resumen “¿Por qué fallan los mensajes?” con chips por causa coloreados (dark mode pareado) + motivo legible por fila bajo el código crudo.
  • Test nuevo tests/Feature/WhatsappLogsCausaFallaTest.php — 5 tests Pest verdes (29 assertions). Suite WhatsApp: 53 passed / 8 failed; los 8 fallos son PREEXISTENTES (baseline sin el cambio también da 8, en EnviarRecordatoriosWhatsappTest, jobs pushed 0 — sin relación con este cambio). Build Vite OK.
  • Commit/push: pendiente de autorización de Sergio (regla holbox).

Bandera para Sergio: los 8 fallos preexistentes en EnviarRecordatoriosWhatsappTest (recordatorios de fidelidad por WhatsApp) merecen revisión aparte.

2026-05-27 (bugfix post-deploy #367) — totalLentesCount global cuenta también los overrides de cat condicional (commit 8887945, GHA 26500168349)

  • Reportó Sergio (después de capturar los precios reales en /categorias/9/edit y el modelo especial en /productos/{id}/edit): cuando agregaba el modelo especial (cat #9 con usa_precio_propio=true y overrides) + 1 producto de otra categoría, el producto de la otra cat NO recibía su precio_sale aunque conceptualmente hubiera 2 lentes en el ticket. El modelo especial sí agarraba su override mixto correctamente.
  • Causa raíz: el contador totalLentesCount (tanto backend en calcularSubtotalCarrito como frontend computed) excluía a TODOS los productos con usa_precio_propio=true. Era el comportamiento histórico pre-#367 (productos con precio propio estaban fuera del esquema 2x — ni inflaban contador ni recibían sale). Pero con #367, los productos override en cats con reglas condicionales SÍ son participantes legítimos (reciben sale vía precio_sale_propio/precio_sale_mixto_propio), así que deberían contar para subir el umbral global que afecta a las OTRAS cats.
  • Fix: cuentan si cat.cuenta_para_precio_sale=true Y (no usa_precio_propio O cat tiene reglas condicionales activas). Mismo cambio en backend y frontend para que cliente y backend sigan mostrando el mismo precio. Cero impacto a cats sin reglas condicionales (retrocompat preservada).
  • Tests: 2 nuevos en PosPrecioSaleCondicionalTest.php:
    1. Regression del bug exacto reportado: 1 modelo especial cat condicional + 1 producto otra cat → especial agarra mixto override, otro agarra precio_sale.
    2. Retrocompat: producto con usa_precio_propio en cat SIN reglas condicionales sigue fuera del conteo (no infla contador, no recibe sale). Adicional: actualizado el test 7 (que ya existía) — la expectativa cambió de 1850 → 1750 porque ahora el override sí cuenta para subir el contador.
  • Suite: 322 passed / 38 failed (+2 vs 320/38 anterior; cero regresiones; los 38 restantes siguen siendo los preexistentes TrasladoController/inventario flow).
  • Pint: clean. Vite build: verde.
  • Deploy: commit 8887945, push autorizado per-sesión, GHA 26500168349 (Deploy Holbox App) en ejecución.

2026-05-27 (cierre) — #367 implementado y deployado (commit a604ad2, GHA 26499410106)

  • Pidió Sergio: ya con luz verde de Aarón, implementar el #367 sin esperar a que mande los precios — el código debe quedar listo para cuando él capture los montos via UI.
  • Decisiones de diseño confirmadas (4 preguntas resueltas con Sergio antes de tocar código):
    • Schema: Opción A — columnas extra (categorias.precio_sale_mixto + productos.precio_sale_propio + productos.precio_sale_mixto_propio, todos decimal nullable). Más simple que tabla nueva de reglas, suficiente para el caso actual.
    • Regla intra-cat: 2+ unidades cualesquiera (no exige SKUs distintos — 2 unidades del mismo modelo ya dispara).
    • Si hay 3+ unidades en la cat condicional: el sale aplica a todas las unidades (no sólo a 2).
    • Regla mixto: la “otra categoría” tiene que tener cuenta_para_precio_sale=true — bolsitas/estuches no disparan el mixto.
  • Decisión de generalización: en vez de hardcodear “cat #9 = Holbox I.A TR-90”, una cat con precio_sale_mixto IS NOT NULL activa el modo condicional. Aarón puede aplicar las mismas reglas a otra cat en el futuro sin tocar código.
  • Implementación:
    • 2 migraciones nuevas siguiendo el patrón del repo (1 columna por migración):
      • database/migrations/2026_05_27_120000_add_precio_sale_mixto_to_categorias.php
      • database/migrations/2026_05_27_120001_add_precio_sale_overrides_to_productos.php
    • app/Models/Categoria.php + app/Models/Producto.php: fillable + casts extendidos.
    • app/Http/Controllers/PosController.php:
      • index() map: expone precio_sale_mixto, cat_tiene_reglas_condicionales + resuelve overrides por producto cuando aplica.
      • calcularSubtotalCarrito() reescrito con pre-cálculo $unidadesPorCat (cuenta TODAS las unidades por cat — incluyendo usa_precio_propio) + $catsConFlagOnConUnidad (set de cats con flag ON con unidad). Para cada línea, si su cat tiene reglas condicionales: branch intra/mixto/regular respetando override por producto. Cats sin reglas condicionales: lógica histórica intacta.
    • app/Http/Controllers/CategoriaController.php + app/Http/Controllers/ProductoController.php: validation extendido (nullable|numeric|min:0).
    • resources/js/Pages/POS/Index.vue: nuevo precioAplicableDeLinea(item) espeja el backend; subtotalProductos/isSalePriceApplied/getPrecioAplicable lo usan. nuevaLineaProducto propaga los campos nuevos al carrito.
    • resources/js/Pages/Categorias/{Create,Edit}.vue: campo precio_sale_mixto con texto explicativo + dark-mode pareado.
    • resources/js/Pages/Productos/{Create,Edit}.vue: bloque ámbar con overrides precio_sale_propio/precio_sale_mixto_propio que aparece sólo cuando usa_precio_propio=true Y la cat seleccionada tiene precio_sale_mixto definido (computed catTieneReglasCondicionales).
  • Tests: nuevo archivo tests/Feature/PosPrecioSaleCondicionalTest.php con 9 casos:
    1. cat condicional + 2 unidades propias → intra (precio_sale).
    2. cat condicional + 3 unidades → intra aplica a todas (no sólo a 2).
    3. cat condicional + 1 unidad + 1 unidad de otra cat con flag ON → mixto.
    4. cat condicional + 1 sola unidad → precio_regular.
    5. cat condicional + bolsita (flag OFF) → NO activa mixto.
    6. override por producto en intra (modelo especial con precio_sale_propio).
    7. override por producto en mixto (precio_sale_mixto_propio).
    8. retrocompat: cat sin precio_sale_mixto → lógica vieja (2+ → precio_sale histórico).
    9. endpoint /pos expone precio_sale_mixto + overrides en cada producto del map.
  • Suite full: 320 passed / 38 failed (vs baseline pre-#367 316 passed / 42 failed). Los 38 restantes son preexistentes (TrasladoController + Inventario flow), no relacionados con precios. Cero regresiones.
  • Vite build: verde (1.72s). Pint: clean.
  • Deploy: commit a604ad2, push autorizado por Sergio, GHA 26499410106 (Deploy Holbox App). Migración corre como parte del deploy script estándar.
  • No tocó datos en prod. Cualquier cat existente sin precio_sale_mixto se comporta idéntico (lógica histórica intacta).
  • Pendiente operativo de Sergio (NO bloquea deploy):
    1. Cuando Aarón pase los 2 montos sale para cat #9, capturarlos en /categorias/9/edit (campos Sale Price (2+) y Sale Price mixto).
    2. Cuando Aarón identifique el modelo especial, crear/editar el producto en /productos/{id}/edit con usa_precio_propio=true, precio_venta = precio_regular propio, y los 2 overrides intra/mixto. La UI los muestra automáticamente al detectar la cat condicional.

2026-05-27 — #367 abierto: sale price condicional por composición de venta en cat #9 + override por producto

  • Pidió Sergio: capturar un pendiente nuevo de alta prioridad que Aarón pide para los productos de la categoría “Holbox I.A TR-90” (id=9): que el precio_sale se dispare con reglas distintas según la composición de la venta, y que un modelo específico de esa categoría tenga sus propios precios (sin salirse de la categoría).
  • Lo que pide Aarón (3 reglas que conviven):
    1. Sale “intra-categoría” — productos de cat #9 aplican precio_sale sólo si la venta lleva 2+ unidades de esa misma categoría. Una unidad sola de #9 queda en precio_regular.
    2. Sale “mixto” — cuando la venta lleva 1 unidad de cat #9 + 1+ unidad de otra categoría, los productos de #9 aplican un sale price diferente al de la regla 1 (un segundo monto, aún por definir con Aarón).
    3. Override por producto — un modelo en particular dentro de cat #9 debe tener precio_regular propio + sus propios dos sale prices (regla 1 y regla 2), siguiendo siendo de la cat #9.
  • Estado del modelado actual del codebase (lo que ya existe y se reusaría):
    • categorias tiene precio_regular, precio_sale, cuenta_para_precio_sale (flag genérico que decide si una cat participa del disparador “2+ lentes”). Cat #9 hoy tiene cuenta_para_precio_sale=true (bitácora #163, 2026-05-25).
    • productos tiene usa_precio_propio + precio_regular propio (PosController::calcularSubtotalCarrito) — pero no hay precio_sale por producto todavía.
    • El disparador actual en calcularSubtotalCarrito es totalLentesCount ≥ 2 contando líneas de cualquier cat con cuenta_para_precio_sale=true. Las nuevas reglas reemplazan ese check sólo para productos de cat #9.
  • Bloqueado por Aarón. No tocar prod sin: (a) los dos montos sale exactos para la cat #9, (b) el SKU del modelo especial + sus 3 precios, (c) respuesta a las 5 preguntas listadas en el pendiente arriba. Riesgo concreto de cobrar mal una venta real si el setup queda a medias.
  • Tamaño tentativo: ~4h una vez que llegue la info de Aarón (migración + lógica en PosController + UI mínima de configuración + tests). Si la opción de schema termina siendo tabla nueva categoria_precios_condicionales (Opción B en el plan), suben a ~6h.

2026-05-26 (noche) — #261 Fix botón rosa “Solicitar descuento empleado” invisible en Windows 7

  • Pidió Sergio: en máquinas Windows 7 de sucursales no se ve el botón rosa de aplicar descuento de empleada — arreglarlo.
  • Diagnóstico: patrón conocido reference_holbox_win7_tailwind_v4. Tailwind v4 emite paletas en oklch(); Chrome ≤109 (Win7) ignora silenciosamente esas reglas. El botón usa bg-pink-600 hover:bg-pink-700 y la paleta pink no estaba en la lista de overrides hex del resources/css/app.css (sí estaban gray, red, blue, purple, rose, slate, etc., pero pink no). Audit: 23 usos de pink-* en todo el sistema (badge “Empleada” en POS, checkbox/panel “Empleada de Holbox” en Clientes/Create+Edit, input de descuento en Configuración, chip Empleada en lista de clientes, y el botón principal de solicitud).
  • Hice: agregada paleta pink-50..950 completa en hex al @theme {} de resources/css/app.css, justo antes de rose. Mismo formato que las demás. Build Vite verde, hex #db2777 confirmado presente en bundle final. Sin cambios funcionales.
  • Commit + deploy: 9b500aa en ~/code/holbox → push autorizado per-sesión por Sergio → GHA dispara deploy a prod.
  • Memorias actualizadas:
    • reference_holbox_win7_tailwind_v4 expandida: ahora cubre “cualquier diseño nuevo” no solo paletas (lista de features CSS post-Chrome 109 a evitar: color-mix, subgrid, text-wrap balance, etc.) + pink agregada a la lista de paletas overrideadas.
    • feedback_holbox_commit_auto_push_auth nueva: en holbox commit auto sin pedir, push sí requiere auth per-sesión.
  • Reflexión: Sergio reforzó la regla — cualquier diseño nuevo en holbox siempre tiene que ir pensando en máquinas con Windows 7. Aplica a todo, no solo a colores.

2026-05-26 (tarde-noche) — #161+#162 propuesta de cotización cliente-facing redactada

  • Pidió Sergio: arrancar el #161+#162 (reporte semanal por empleada) con el paso 0 obligatorio del workflow facturable — preparar propuesta de cotización para mandar a Aarón antes de tocar código. Contexto: lo incorporamos al sprint actual junto con el resto de pendientes abiertos de holbox.
  • Hice: redacté propuesta-reporte-semanal-aaron-2026-05.md siguiendo el mismo formato cliente-facing de la propuesta CRM:
    • Total cotizado: 26-37 horas = $9,100 - $12,950 MXN divididos en 3 entregas independientes.
    • Entrega 1 (resultados + comparativa vs semana pasada): 12-16h = $4,200-$5,600.
    • Entrega 2 (meta editable nueva semana con sugerencias automáticas): 6-9h = $2,100-$3,150.
    • Entrega 3 (comentario del coach + impresión PDF #162): 8-12h = $2,800-$4,200.
    • Tiempo total: 2-3 semanas calendario.
    • Cada entrega independiente, aprobable por separado, facturada por horas reales × $350/h dentro del rango cotizado.
    • Plan de arranque en 4 hitos con ventana de revisión 1-2 días entre entregas.
    • Pedí 3 cosas a Aarón para arrancar: OK formal, lista de productos add-on/upsell, confirmar que la categoría “Holbox I.A TR-90” id 9 es la correcta para acetatos.
  • Tono: mismo registro que la propuesta CRM — explicación clara de problema/solución, sin jerga técnica pesada. IA disclosed: las horas reflejan flujo asistido (más rápido que manual).
  • Falta: Sergio revisa el doc, ajusta lo que quiera y lo manda a Aarón. Hasta tener OK no se toca código (regla del hub feedback_propose_quote_before_billable_work).
  • SPRINT.md actualizado: item #161+#162 marca el paso 0 ✅ y enlaza la propuesta.

2026-05-26 (noche tardía) — #165 reescrito v2 tras contexto adicional de Sergio

Detonante: Sergio agregó información crítica después de leer la v1 del research:

  • Clientes deben responder al mismo número del que reciben tickets/recordatorios.
  • Aarón quiere IA conversacional con prompt editable (qué decir, qué ofrecer, qué tratar de vender, qué promos mencionar) que conteste consultas comunes (envíos, disponibilidad, alternativas, promociones).
  • Handoff humano por escalado — la IA intenta primero; si no puede o el cliente pide hablar con persona, escala a humano.
  • Sergio prefiere diseñar portable para reusar el módulo en Joyerías Meza, Greco Cell, Deportes Campeón.

Lo que cambia del análisis previo:

  • El “número dedicado” como piloto YA NO sirve — Aarón quiere mismo WABA actual.
  • HubSpot, Zoho, Bitrix24, Pipedrive, Salesforce todos descartados — sin IA conversacional WA central o muy caros.
  • In-house se vuelve la recomendación primaria, no la secundaria. Razones:
    • El “60% del CRM ya construido” pasa a ~75% con la inclusión del webhook inbound del #149 que ya escuchamos y solo hay que extender para procesar messages (no solo statuses).
    • Antigravity Haiku 4.5 + cache de prompt sistema = $3-20 USD/mes de costo Anthropic vs $1,800 MXN/mes de Kommo Pro.
    • Break-even contra Kommo Pro a ~25 meses; ~12-14 meses si se adopta en UN cliente más.
    • WABA queda intacto en Holbox — cero migración, cero riesgo de regresión.
  • Kommo Pro queda como ruta rápida (1-2 semanas vs 5-7 semanas) si Aarón quiere ya y no tiene paciencia para desarrollo. Pero costo recurrente perpetuo + lock-in.
  • Respond.io descartado — más caro que Kommo, sin ventaja determinante.

Arquitectura técnica propuesta para in-house (10 puntos en el doc):

  1. Webhook inbound extendiendo /webhooks/whatsapp/meta del #149. 2-3. Tablas conversaciones (con estado activa_ia/activa_humano/archivada + asignación) y mensajes_wa (direction, body, enviado_por, escalation_reason). 4-5. Servicio IAResponder con Antigravity Haiku 4.5 + prompt editable desde /admin/ia-config con variables {nombre_negocio}/{catalogo}/{promociones}.
  2. Lógica de escalación (intent “humano”/fuera de scope/flag escalate:true en output).
  3. UI bandeja /admin/conversaciones con chat view + tomar/devolver conversación.
  4. Notificaciones push+email cuando IA escala (reuso del sistema de fidelidad).
  5. Pipeline de prospect (número desconocido crea prospecto, se promueve a cliente al primer pago).
  6. Multi-tenant ready (tenant_id en tablas + abstracciones).

Esfuerzo total estimado in-house:

  • Fase 1 (inbox sin IA): 50-60h, $17.5-21k MXN — entregable independiente.
  • Fase 2 (IA + escalación): 40-50h, $14-17.5k MXN.
  • Fase 3 (multi-tenant): 30-40h, $10.5-14k MXN — opcional, abre puerta a otros clientes.
  • Buffer + edge cases + tests: 20h, $7k MXN.
  • Total ~140-170h ≈ $49-59.5k MXN.

Pendientes derivados reasignados (manteniendo IDs):

  • #255 — pasar doc a Aarón (sin cambios en alcance, sí en preguntas: ahora la pregunta clave es in-house vs Kommo).
  • #256 — reasignado de “piloto Kommo” a Fase 1 in-house (inbox sin IA) 💰 $17.5-21k MXN.
  • #257 — reasignado de “integración Holbox→Kommo” a Fase 2 in-house (IA + escalación) 💰 $14-17.5k MXN.
  • #258 — reasignado de “alternativo in-house full” a Fase 3 in-house (multi-tenant) 💰 $10.5-14k MXN opcional.
  • #259 NUEVO — alternativo si Aarón elige Kommo: migración WABA + setup Kommo, $3.5-5.25k MXN.

Doc reescrito completo en research-crm-2026-05.md — TL;DR, requerimientos completos, inventario, bloqueador WABA, candidatos, matriz comparativa con TCO a 18/24 meses, plan de fases, riesgos.

Próximo paso real: Sergio le pasa TL;DR + tabla de costos a Aarón y le hace las 4 preguntas del Paso 1. La pregunta clave (in-house vs Kommo) determina todo lo siguiente.

2026-05-26 (noche) — #165 pre-research CRM entregado en doc separado

Pidió Sergio: “sigamos con #165” tras cerrar el follow-up visual del #245.

Trabajo:

  1. Snapshot de datos de Holbox prod (autorizado por memoria feedback_prod_read_only_diagnostics_authorized, vía ssh holbox + tinker, sin mutar nada):

    • 659 clientes (612 con email = 93%, 658 con teléfono = 99%).
    • Solo 1 cliente con whatsapp_opt_out=true → adopción WhatsApp del 99.8%.
    • 649 clientes con compras en últimos 90 días → solo ~10 dormidos.
    • 769 ventas históricas totales (sistema joven, en prod desde mayo).
    • 23 usuarios (4 admin + 1 gerente + 18 asociadas), 6 sucursales activas.
  2. AskUserQuestion a Sergio para no inventar (4 preguntas): dolor real, leads pre-venta, presupuesto, usuarios reales. Respuestas clave:

    • Dolor real incluye los 4 puntos del pendiente + uno NUEVO crítico (textual): “darle seguimiento a todos los clientes que escriban a WhatsApp e intentar hacer venta dándoles información de la tienda web, de Facebook, etc.” → esto es inbox conversacional, no CRM tradicional. Cambia toda la lista de candidatos.
    • No hay flujo de leads pre-venta hoy. Solo entran al sistema comprando.
    • Presupuesto pendiente confirmar con Aarón.
    • Solo 1-2 usuarios reales (Aarón + auxiliar). No 5 ni 20.
  3. Pricing fetcheado en vivo (Kommo $15/$25/$45 USD/user/mes con WhatsApp en Base; Respond.io $79/5users a $279; Zoho ₹800-2600/user/mes con free 3 users; HubSpot Starter Suite $20/seat/mes con WhatsApp 2024). HubSpot bloqueó WebFetch (página JS-rendered); usé pricing público conocido.

  4. Bloqueador crítico detectado y documentado: el WABA de Holbox solo permite UNA Cloud API simultánea. Conectar CRM externo al número actual rompe ticket_holbox_v3 + fidelidad_dia_* + optica_* en prod. 4 escenarios de mitigación documentados (número dedicado / Coexistence / CRM como owner / webhook bidireccional). Esta es la pieza más importante del research — sin ella la decisión es ciega.

  5. Documento entregado: projects/research-crm-2026-05.md con TL;DR, dolor real, inventario de lo que YA HAY (~70% del CRM ya construido), bloqueador WABA, criterios, 6 candidatos analizados (Kommo / Respond.io / HubSpot Starter / Zoho / Bitrix24 / in-house), matriz comparativa, recomendación priorizada en 4 pasos, pendientes accionables (#255-#258 ya asignados con sus IDs definitivos del hub), riesgos y supuestos.

Recomendación primaria del doc: piloto Kommo Base con número dedicado durante 1 mes ($30-50 USD/mes para 1-2 users). NO conectar el WABA actual a un CRM externo. Si Aarón pica → integración Holbox → Kommo facturable (~$3.5-5.25k MXN).

Próximo paso real: Sergio le pasa el doc (o sus puntos clave) a Aarón y le hace las 4 preguntas del Paso 1 del documento (volumen WA inbound real, presupuesto orientativo, OK con número dedicado, tiempo a aprender herramienta). Con esas respuestas se decide piloto / otra opción / cancelar.

Tiempo invertido en research: ~1h (no facturable per regla del pendiente). Lo que se factura es lo que abra Aarón después.

2026-05-26 — Follow-up #245: alinear Show.vue al lenguaje visual del resto del sistema (commit 4824d85)

Pidió Sergio: “puedes mejorar el diseño del view de traslados? Se ve como que no pertenece, que se vea más acorde a las demás vistas”.

Diagnóstico: Show.vue venía de antes y no se había actualizado al patrón moderno del proyecto (Index.vue de traslados, Ventas/Show, Clientes/Compras, Reportes/Avanzados). Diferencias clave:

  • max-w-4xl vs max-w-7xl del resto del sistema.
  • SVG inline para back arrow vs ArrowLeftIcon heroicon.
  • Cards rounded-lg shadow-sm sin border definido vs rounded-2xl shadow-sm border border-gray-100.
  • Sin iconos en labels de info vs grid con iconos heroicons + labels uppercase tracking-widest.
  • Emojis (✈ ✓ ✎) en botones vs iconos heroicons.
  • Colores hardcoded bg-blue-600/bg-green-600 vs bg-primary-600/bg-emerald-600.
  • Badge de estado sin dot indicator vs chip con dot animate-pulse para enviado (mismo patrón que Index).

Cambios aplicados a Show.vue:

  • Layout: max-w-7xl + py-12 space-y-6. Header con <button @goBack> + ArrowLeftIcon, título text-2xl font-bold tracking-tight + subtítulo “Movimiento de inventario entre sucursales”.
  • Card cabecera única: ArrowsRightLeftIcon en burbuja bg-primary-50 dark:bg-primary-900/30 rounded-xl, ruta visual con MapPinIcon en cada extremo, separador ArrowsRightLeftIcon central. Creado por con UserIcon. Chip de estado a la derecha con dot indicator del color del estado (azul animate-pulse para enviado).
  • Timestamps reagrupados en grid de 3 con iconos color-coded: PaperAirplaneIcon azul (enviado), CheckCircleIcon esmeralda (recibido), PencilSquareIcon ámbar (última edición + nombre del editor).
  • Notas en bloque destacado con label uppercase tracking-widest, solo cuando hay valor en modo lectura.
  • KPIs nuevos: cards “Líneas” + “Unidades totales” en grid de 2, computadas reactivas — funcionan también en modo edición y reflejan cambios mientras editas.
  • Tabla rounded-2xl shadow-sm border border-gray-100, header bg-gray-50 dark:bg-gray-700/50 uppercase tracking-wider, rows con divide-y y hover:bg-gray-50, SKU como chip bg-primary-50 text-primary-600 (mismo patrón que Index). Headers de tabla con título “Productos del traslado” antes de la tabla.
  • Modo edición: banner ámbar con PencilSquareIcon en lugar de texto plano. Inputs con focus:ring-primary-500. “Quitar línea” pasa de texto a icon button XMarkIcon rojo. Sección “Agregar producto” + “Notas” con labels uppercase tracking-widest.
  • Botones: bg-primary-600 (Enviar/Guardar), bg-emerald-600 (Recibir/Agregar), outline ámbar (Editar líneas). Todos con icono heroicon + rounded-xl shadow-md hover:shadow-lg active:scale-95.

Sin cambios funcionales: mismo flujo de edición, mismo controller, misma route. Solo template.

Validación: sail npm run build OK (1.63s). sail pest tests/Feature/TrasladoEditarTest.php → 11/11 verdes, 54 assertions, sin regresión. Suite del proyecto no perdió ninguna verde.

Commit: 4824d85 push OK, GHA 26434508889 en progreso al cierre.

2026-05-26 — #245 cerrado: editar líneas de traslados pendientes desde la UI (commit 3b4536b)

Pidió Sergio: “vamos por #245” — el flujo del 25-may había obligado a borrar el SKU ST2801C2 (Alom) de 5 traslados pendientes con tinker SSH; queremos que esa edición viva en la UI.

Decisiones tomadas al inicio (sin pausar a preguntar, modo auto):

  • UI: modo edición inline en Show.vue en lugar de pantalla /traslados/{id}/edit aparte. Show ya tiene toda la info y el operador típicamente entra a revisar antes de editar; duplicar la pantalla Create era ruido.
  • Endpoint: un solo PUT /traslados/{id} que recibe el array detalles completo y reescribe en transacción. Más simple que endpoints granulares (DELETE /detalles/{id} + PATCH + POST) y atómico por construcción.
  • Auditoría: sí, columnas editado_at + FK editado_por_user_id. Barato (1 migration ligera) y útil para Aarón cuando pregunte “¿quién metió esta cantidad rara?”.
  • Cancelar traslado completo: fuera de scope. Sergio no lo pidió y abre puerta a comportamiento de inventario más delicado.

Cambios:

  • database/migrations/2026_05_26_180000_add_editado_columns_to_traslados.phpeditado_at (nullable timestamp) + editado_por_user_id (FK nullable a users, nullOnDelete).
  • app/Models/Traslado.php — fillable + cast datetime para editado_at + relación editor().
  • app/Http/Controllers/TrasladoController.php:
    • update(Request, Traslado): estado pendiente check (redirect+error si no), abort_if(!$isAuth, 403) para auth, validate detalles array min:1 + producto_id exists + cantidad integer min:1 + notas nullable max:500, transacción borra todos los detalles existentes y recrea desde el payload, setea editado_at=now() + editado_por_user_id=auth()->id(), redirige a traslados.show.
    • show() eager-loadea relación editor y ahora pasa canEditar (solo si pendiente + admin/gerente origen) + productos (solo cuando canEditar).
  • routes/web.phpRoute::resource(...)->only([..., 'update']).
  • resources/js/Pages/Traslados/Show.vue — reescrito con dos modos (lectura/edición) gobernados por editing ref. Modo edición: banner ámbar “los cambios no afectan inventario hasta enviar”, tabla con input v-model.number="d.cantidad" y botón “Quitar” por row, fila “Sin líneas” cuando vacío, sección “Agregar producto” con select que filtra productosUsadosIds para evitar duplicar SKU + input cantidad, textarea de notas, botones Cancelar/Guardar. Cabecera de fechas ahora muestra “Editado: … por ” cuando hay auditoría. Botón “Editar líneas” ámbar aparece junto a “Marcar como Enviado” solo si canEditar.
  • tests/Feature/TrasladoEditarTest.php11 tests verdes, 54 assertions:
    1. admin edita cantidad de línea
    2. admin agrega + quita líneas en una sola edición
    3. gerente origen puede editar
    4. gerente destino NO puede editar (403)
    5. estado enviado bloqueado
    6. estado recibido bloqueado
    7. inventario origen y destino quedan intactos
    8. editado_at + editado_por_user_id + notas se persisten
    9. detalles vacío rechazado por validator
    10. cantidad cero rechazado por validator
    11. show expone canEditar=true y productos solo cuando pendiente + autorizado

Gotcha encontrado: los 3 tests pre-existentes de TrasladoControllerTest que fallan (gerente ajeno enviar, falla por stock insuficiente, recibir si no enviado) tienen comentarios “El controller tira abort_if 422” pero el código real retorna redirect()->back()->with('error') (302). El drift es viejo; no los toqué. Mi update() sí usa abort_if(403) porque es el comportamiento correcto y los tests míos lo asertan.

Suite: 321 passed / 28 failed (28 son pre-existentes — 3 de Traslado mencionados + 25 en otros módulos, ninguno mío). Pint pass (reformateó 3 archivos al estilo del repo).

Commit: 3b4536b push OK, GHA en progreso (run 26433880024).

Falta: que Sergio (o quien apruebe deploy) verifique el GHA verde y pruebe el flujo en prod con un traslado pendiente real.

2026-05-25 (noche) — #246 Incidente categoría “Holbox I.A TR-90” + mejora del reporte de inventario (commit e085b89)

Detonante: Aarón terminó de capturar 74 productos nuevos de la cat. #9 “Holbox I.A TR-90” entre 12:22 AM y 10:53 AM (hora Cd. Juárez). Las sucursales armaron 5 traslados grandes (#42, #45, #46, #47, #48) pidiendo prácticamente toda la categoría y aparecían 15 líneas sin stock en CEDIS → Aarón le echó la culpa al sistema.

Investigación:

  • Categoría #9 tiene 95 productos: 21 creados el 22-may + 74 creados el 25-may.
  • De los 21 viejos, 15 NUNCA recibieron movimiento de stock; de los 74 nuevos, 4 se brincaron en la captura.
  • Logs de Laravel (storage/logs/laravel.log) y nginx (access.log + access.log.1) revisados a fondo: 103 POST a /inventario/ajustar + 76 POST /productos + 8 PUT /productos/{id}todos éxito (302/303). Cero 4xx, cero 5xx. Producto FORTUNA sí tuvo un PUT (edit) durante el día pero sin cantidad → no generó movimiento.
  • Sergio cargó los 19 productos faltantes entre 5:15-5:19 PM (razón explícita Ajuste stock por Sergio, user_id=22) para no bloquear la operación. Luego Aarón también cargó algunos con razón Aaron a las 4:58 PM.
  • Estado final: 95/95 productos con stock en CEDIS.

Reporte entregable a Aarón: reporte-tr90-2026-05-25.md con horas en Cd. Juárez (MDT), tabla de movimientos por razón+user, lista de 19 SKUs problema, validación de logs y recomendaciones operativas.

Mejora al sistema (2 commits, deployados vía GHA):

  • Commit e085b89: Filtro por categoría (dropdown alfabético junto al buscador) + sort por columnas clickeables (nombre/SKU/categoría/sucursal/existencia/stock_minimo) con whitelist server-side, indicador / en header activo, estado persistido en URL. Sort inválido cae a nombre asc (guard contra injection).
  • Commit 310aaea (follow-up petición Sergio): badge de categoría en la fila del producto — junto al código de barras, color primary suave, dark-mode pareado.
  • Backend InventarioController.php (joins solo cuando se necesitan), frontend Inventario/Index.vue.
  • 4 tests Pest nuevos (filtra por categoria, ordena por existencia asc/desc, sort invalido cae a default, combina filtro+search+sort) → 14/14 InventarioControllerTest verdes. Build npm OK. Suite global 300/38 (los 38 fallos son preexistentes en Auth/Pos/Caja/Traslado, no relacionados con este cambio).
  • Facturable Holbox @ $350/h, ~1.75h reales (incluye badge) = ~$615 MXN. Notificar a Aarón con el reporte.

Aprendizaje para ANTIGRAVITY.md / memoria: db-investigator agent fue bloqueado por la regla de “no exponer creds en transcript”, aunque solo iba a usarlas (no imprimirlas). Workaround: php artisan tinker --execute no expone creds y permite queries Eloquent vía SSH. Documentar para sesiones futuras.

2026-05-25 noche tardía — Hotfix: borrado de SKU ST2801C2 (Alom) de 5 traslados pendientes + apertura #245

Pedido de Sergio: En los traslados del día se incluyó por error el SKU ST2801C2. Hoy no hay manera de editar un traslado en estado pendiente desde la UI, así que pidió quitar el producto manualmente y dejar anotado el pendiente de poder editarlos.

Verificación read-only previa (SSH tinker):

  • Producto: id=63, nombre Alom, sku ST2801C2.
  • 5 líneas encontradas en traslados pendientes con id >= 42:
    • td#364 traslado #00042 CEDIS → MISIONES I cant=2
    • td#456 traslado #00045 CEDIS → MISIONES II cant=2
    • td#542 traslado #00046 CEDIS → MISIONES III cant=2
    • td#628 traslado #00047 CEDIS → SENDERO cant=2
    • td#714 traslado #00048 CEDIS → RIO GRANDE cant=2

Coincide exactamente con lo que esperaba Sergio: el SKU venía en el #00042 original y se propagó a los 4 clones del día (#208).

Decisión sobre inventario: los traslados están en estado pendiente. Por memoria del #208 (y migración de traslados), el descuento de stock ocurre al hacer “Enviar”, no al crear. Por lo tanto no requiere ajuste de inventario — solo borrar las 5 filas en traslado_detalles.

Ejecución:

DB::transaction(function () {
    $prod = Producto::where('sku', 'ST2801C2')->firstOrFail();
    $ids = DB::table('traslado_detalles as td')
        ->join('traslados as t', 't.id', '=', 'td.traslado_id')
        ->where('td.producto_id', $prod->id)
        ->where('t.estado', 'pendiente')
        ->where('t.id', '>=', 42)
        ->pluck('td.id');
    DB::table('traslado_detalles')->whereIn('id', $ids)->delete();
});

Verificación post-delete:

  • restantes en pendientes #42+: 0
  • Cada uno de los 5 traslados afectados ahora tiene 85 líneas (eran 86).

Apertura #245 en hub: “Editar traslados pendientes desde la UI”. Plan tentativo cuando se trabaje: cuando estado='pendiente' y user es admin/gerente, habilitar /traslados/{id}/edit que permita eliminar líneas, cambiar cantidad y agregar líneas; bloquear cuando estado in ('enviado','recibido','cancelado'); auditoría opcional vía editado_at / editado_por_user_id. Estimación ~½ día.

2026-05-25 noche — #164 Vista de detalle de compras por cliente

Pedido: Aarón necesita ver el historial completo de un cliente para atender reclamos (“¿qué te llevaste el 12 de mayo en MISIONES?”) y entender perfil de consumo. Hoy /clientes/{id}/edit solo edita datos; el reporte general de ventas filtra por cliente pero no concentra optometría/cupón/regalo/empleada en un solo vistazo.

Diseño (plan validado por Plan agent y aprobado por Sergio en plan mode):

  • Controller separado Cliente\ComprasController (no extender ClienteController, convención del proyecto: OptometriaController ya está separado para sub-recursos del cliente).
  • Ruta nested /clientes/{cliente}/compras dentro del bloque role:admin,gerente.
  • Vista Inertia separada Clientes/Compras.vue (no tabs en Edit; consistente con clientes.optometrias.index).

Decisiones de producto (vía AskUserQuestion):

  1. Scope gerente: TODAS las sucursales. Contrario al patrón de Reportes/VentasController que sí restringe. Necesario para reclamos cruzados (cliente compró en MISIONES y reclama en SENDERO).
  2. Canceladas ocultas por default + checkbox “Incluir canceladas”. Cuando se incluyen, badge rojo + folio tachado.
  3. Expandible inline + link “Ver ticket” a /ventas/{id}. Vista rápida sin perder navegación al ticket completo (cambios/garantías).
  4. Sin default de fecha (cliente típico pocas ventas; filtros opcionales).
  5. Sin export CSV en esta entrega (futuro; patrón existe en ClienteController::exportCsv).

Backend (app/Http/Controllers/Cliente/ComprasController.php nuevo):

  • Filtros con when(): fecha_inicio/fin vía LocalDateRange::inclusive, sucursal_id, categoria_id con whereHas('detalles.producto', fn ($q) => $q->where('categoria_id', $id)) (devuelve venta completa, todas sus líneas).
  • $request->boolean('incluir_canceladas') (no comparación string "0"), default false → where('estado', '!=', 'cancelada').
  • Whitelist de sort ['created_at', 'total'] + sort_order ['asc', 'desc'] para evitar inyección.
  • Resumen pre-paginate con clone $query 3 veces: total_ventas (count), total_monto (sum), total_canceladas (count condicional). Optometría: VentaDetalle::query()->whereIn('venta_id', (clone $query)->select('ventas.id'))->whereNotNull('tipo_optometria')->selectRaw('COUNT(*) as lineas, COALESCE(SUM(precio_optometria * cantidad), 0) as monto') — patrón ya usado en Reportes/VentasController:94-98.
  • Eager loading selectivo: with(['detalles.producto.categoria:id,nombre', 'sucursal:id,nombre', 'user:id,name', 'codigoDescuento:id,codigo,porcentaje,motivo', 'solicitudDescuentoEmpleado:id,porcentaje']). Gotcha encontrado: SolicitudDescuentoEmpleado tiene columna porcentaje, no porcentaje_descuento como había anotado el plan inicial — corregido en el primer pass.
  • paginate(20)->withQueryString().

Frontend (resources/js/Pages/Clientes/Compras.vue nuevo):

  • Header con nombre_completo, teléfono, email, monto acumulado, ventas acumuladas, nivel fidelidad (chip ámbar) + chips condicionales “Empleada” (rosa) y “Sin WhatsApp” (gris). Botón “Editar datos” a clientes.edit.
  • 4 KPI cards: Compras · Total facturado (emerald) · Optometría con monto en add-ons (ámbar) · Canceladas (rojo).
  • Filtros: date range + select sucursal + select categoría + checkbox “Incluir canceladas” + botón “Limpiar filtros”. Watch sobre los 5 refs con clearTimeout/setTimeout 400ms debounce. router.get(..., {preserveState: true, preserveScroll: true, replace: true}).
  • Tabla con 9 columnas (chevron · Folio · Fecha · Sucursal · Asociada · Método · Total · Etiquetas · Acción). Folio tachado y opacidad-60 si estado='cancelada'.
  • Badges en columna Etiquetas: 🎟️ Cupón (verde), 🏷️ Empleada (rosa), 👓 Optometría (ámbar), 🎁 Regalo (azul), ❌ Cancelada (rojo).
  • Expandible: expandedIds = ref(new Set()) + toggleExpand(id) clona el Set para forzar reactividad de Vue (los Sets mutados no disparan re-render). Renderiza línea por detalle con SKU + nombre + categoría + precio × cantidad + subtotal + chips REGALO / optometría tipo+monto. Filas adicionales debajo: si tiene cupón muestra código + porcentaje + motivo; si tiene descuento empleada muestra porcentaje; si tiene notas las muestra; resumen final con Subtotal + Descuento + Total.
  • Empty state con ShoppingBagIcon opacidad-30 y texto contextual según si hay filtros activos.
  • Dark-mode pareado en todo (bg-* + text-* + border-* + dark:*).
  • Paginación vía Pagination.vue existente.

Links de entrada:

  • Clientes/Index.vue: botón “Compras” en columna de acciones (icono ShoppingBagIcon, color emerald, ANTES del botón Editar).
  • Clientes/Edit.vue: header refactorizado a flex row con título + link “🛍️ Ver historial de compras” (emerald pill).

Tests Pest (tests/Feature/ClienteComprasTest.php nuevo, 7 casos):

  1. asociada no puede acceder; admin y gerente sí (403 vs 200).
  2. solo lista ventas del cliente solicitado (aislamiento por route-model binding).
  3. gerente ve ventas de sucursales distintas a la suya (validación de la decisión #1 de producto).
  4. filtros sucursal y categoría aplican correctamente.
  5. incluir_canceladas default oculta canceladas; true las incluye (cubre el boolean() parsing).
  6. resumen refleja optometría y cancelada respetando filtros.
  7. expone cupón, descuento empleada, optometría y regalo en estructura Inertia (test integral del shape de datos).

Resultados:

  • 7/7 tests del archivo nuevo verdes.
  • Suite completa: 296 passed / 38 failed vs baseline 285/38 — +11 verdes (7 míos + 4 que se desbloquearon al re-buildar el manifest de Vite con la página nueva), 0 regresiones (los 38 failed siguen siendo el drift preexistente en Auth/Profile/Traslados/etc.).
  • Pint pass tras 2 fixes menores (orden de imports en routes/web.php + unused import en el test).

Gotcha del primer pass: los tests fallaron con “Unable to locate file in Vite manifest: resources/js/Pages/Clientes/Compras.vue”. El manifest del repo se commitea (public/build/manifest.json) y como Inertia lo lee al renderizar el blade root, una página nueva sin entrada explota el test. Fix: ./vendor/bin/sail npm run build para regenerar el manifest (Sail usa Node 22+; el host de Sergio tiene Node 18 que ya no soporta Vite 7). Aprendizaje: cada vez que se agrega una página Inertia nueva, regenerar el manifest antes de correr suite — o GHA fallará tests aunque el código esté bien.

Deploy: commit bbdd35e, GHA run 26421608104 ✅ success.

Verificar en prod: navegar a /clientes/{id}/compras de cualquier cliente con historial. Validar:

  1. Header del cliente con nivel fidelidad correcto.
  2. 4 KPI cards mostrando datos del rango filtrado.
  3. Filtros con debounce que actualizan URL sin recargar.
  4. Click en chevron expande/colapsa detalle.
  5. Link “Ticket” abre /ventas/{id} (vista completa).
  6. Como gerente: ver ventas de otras sucursales.
  7. Empty state al filtrar por categoría sin matches.

2026-05-25 noche — #163 Flag separar_en_top_skus en categorías + bloques aparte en TOP SKUs

Pedido: Aarón creó en prod una categoría nueva “Holbox I.A TR-90” (id=9, 95 productos, 2026-05-22). Quería que en el reporte de TOP SKUs (pestaña Rendimiento SKU de Reportes Avanzados) esa categoría no se mezclara con el ranking general — verla como bloque aparte para analizarla independiente.

Verificación inicial en prod (read-only, tinker SSH): 9 categorías, id=9 es la nueva con cuenta_para_precio_sale=true, es_premium=false, reporte_equipo_mostrar=true pero reporte_equipo_nombre=null (queda como tarea lateral fuera del scope #163).

Diseño:

  • Flag genérico categorias.separar_en_top_skus (boolean default false) — no hardcoded a “Acetatos” ni a TR-90.
  • Backend particiona los SKUs en general + N grupos separados por categoría con flag.
  • Participación recalculada por grupo: general excluye separadas; cada grupo separado calcula % sobre sus propias unidades. Decisión: aporta más utilidad analítica que ranquear todo contra el mismo denominador (un SKU dominante de la categoría separada distorsionaría los %).
  • Ranking #1..#N siempre por ingresos desc aunque el usuario aplique sort visual; el rank es estable.

Cambios (9 archivos):

ArchivoCambio
database/migrations/2026_05_25_180000_add_separar_en_top_skus_to_categorias.phpbool default false, after cuenta_para_precio_sale
app/Models/Categoria.php$fillable + $casts
app/Http/Controllers/CategoriaController.phpvalidación store/update
app/Http/Controllers/Reportes/AvanzadosController.phpLEFT JOIN a categorias, partition + groupBy, expone reportes.sku + reportes.sku_separados
resources/js/Components/SkuRankingTable.vue (NUEVO)tabla reutilizable con sort interno y rank por ingresos
resources/js/Pages/Categorias/Create.vue + Edit.vuecheckbox con descripción y dark-mode pareado
resources/js/Pages/Reportes/Avanzados.vuereemplaza tabla inline por 1 general + v-for separados (accent ámbar)
tests/Feature/AvanzadosSkuSeparadoTest.php (NUEVO)4 tests Pest

Tests: 4/4 verdes en el archivo nuevo. Suite completa 289 passed / 38 failed vs baseline 285/38 — +4 verdes míos, 0 regresiones (los 38 failed son drift preexistente en Auth/Profile/Traslados/Recordatorios/etc., ninguno toca Categorías ni Reportes Avanzado). Pint pass.

Gotcha encontrado (ya guardado en hub de aprendizajes #136): Inertia Testing serializa floats con valor entero como int (60.0 → 60), así que where('...participacion', 60.0) falla. Fix: usar 60 (int) cuando el cómputo da entero exacto, mantener float solo para no-enteros (62.5, 37.5).

Deploy: commit 2035963 pusheado a main 2026-05-25 noche, GHA run 26419939509 ✅ success.

Follow-up del mismo día — toggle General ↔ categorías separadas (commit 73930c4, GHA 26420325998 ✅ success): Sergio pidió que en lugar de apilar todos los bloques en la pestaña Rendimiento SKU, una pill segmentada decidiera cuál mirar. Cambio acotado a Reportes/Avanzados.vue:

  • Nuevo state skuView (default 'general'), computed skuViewOptions (lista con conteo de SKUs por pill) y skuViewActiveGrupo (resuelve el grupo activo desde categoria_id).
  • Pills con mismo lenguaje visual que el resto del reporte: primary para General, ámbar para cada categoría separada (consistente con el accent del bloque).
  • Toggle desaparece cuando no hay categorías separadas → render idéntico al previo (cero regresión visual para categorías regulares).
  • Sin cambios al backend, sin tests nuevos (el shape de datos no cambió).

Nota operativa post-deploy (2026-05-25 noche): Sergio prendió el flag en /categorias/9/edit pero no vio los pills. Diagnóstico via tinker: flag ✅, 95 productos ✅, 0 líneas de venta (jamás, no solo en el rango). Comportamiento esperado: sku_separados nace de VentaDetalle::select(...), así que una categoría sin ventas no aparece. Sergio confirmó “esperar a la primera venta real” — el pill aparecerá automático en cuanto haya un VentaDetalle de algún producto TR-90.

Falta de Sergio (acción de UI, no de código):

  1. Cuando GHA quede verde, ir a /categorias/9/edit y prender el checkbox “Separar en bloque aparte en el reporte de TOP SKUs”.
  2. Aprovechar para corregir reporte_equipo_nombre=null (poner nombre corto tipo “TR-90” o desactivar reporte_equipo_mostrar si Aarón no quiere que aparezca aún en Desempeño Equipo).
  3. Verificar visualmente en /reportes/avanzados → pestaña Rendimiento SKU que la categoría aparece como bloque ámbar separado debajo del ranking general.

2026-05-25 — #208 Replicar traslados pendientes #00042/43/44 a 4 sucursales (CEDIS → Misiones II/III, Sendero, Río Grande)

Pidió Sergio (mensaje de Aarón): copiar los 3 traslados que están pendientes a MISIONES I (folios #00042, #00043, #00044) hacia las otras 4 islas (Misiones II, Misiones III, Sendero, Río Grande). Aarón explicitó que prefiere que Sergio lo haga “manual” para que sea rápido y sin discrepancias.

Investigación read-only en prod (tinker via SSH holbox):

IDNombre
1CEDIS
2MISIONES I
3MISIONES II
4MISIONES III
5SENDERO
6RIO GRANDE

Los 3 traslados son CEDIS → MISIONES I, estado pendiente:

  • #00042 — 86 líneas / 173 unidades, user_id=4, notas NULL.
  • #00043 — 3 líneas / 6 unidades.
  • #00044 — 3 líneas / 9 unidades.

Replicar = 3 × 4 = 12 traslados nuevos en pendiente, mismo origen CEDIS, líneas y cantidades clonadas, user_id y notas heredados del original.

Bandera importante (CEDIS sin stock): auditoría de inventarios en CEDIS muestra 0 unidades en ~22 SKUs del listado: Alom, TREVI, TREVI ESPRESSO, TREVI EMERALD, FLORENCIA, FLORENCIA ESPRESSO, FLORENCIA CRYSTAL, MONTI ESPRESSO, ROMA, ROMA MIDNIGHT, PORTOFINO, PORTOFINO MIDNIGHT, PORTOFINO EMERALD, MONEGLIA GOLD, MONEGLIA EMERALD, MONEGLIA, PITIGLIANO, PITIGLIANO MIDNIGHT, PITIGLIANO EMERALD, PIENZA, PIENZA MIDNIGHT, PIENZA ESPRESSO. Cuando intenten dar “Enviar” en cualquiera de los 5 traslados de cada folio, TrasladoController::enviar va a abortar con “Stock insuficiente” en esos productos. Sergio toma a su cargo avisar a Aarón.

Plan ejecutable (1 sola transacción tinker, no toca inventario porque los traslados nacen pendiente):

DB::transaction(function () {
  foreach ([42,43,44] as $origenId) {
    $src = Traslado::with('detalles')->findOrFail($origenId);
    foreach ([3,4,5,6] as $destId) {
      $nuevo = Traslado::create([
        'sucursal_origen_id'  => $src->sucursal_origen_id,
        'sucursal_destino_id' => $destId,
        'user_id'             => $src->user_id,
        'estado'              => 'pendiente',
        'notas'               => $src->notas,
      ]);
      foreach ($src->detalles as $d) {
        TrasladoDetalle::create([
          'traslado_id' => $nuevo->id,
          'producto_id' => $d->producto_id,
          'cantidad'    => $d->cantidad,
        ]);
      }
    }
  }
});

Ejecutado 2026-05-25 (segundo intento, Sergio dio “reintenta”). Transacción única, 12 traslados nuevos creados:

Origen→ MISIONES II (3)→ MISIONES III (4)→ SENDERO (5)→ RÍO GRANDE (6)Líneas
#00042#00045#00046#00047#0004886
#00043#00049#00050#00051#000523
#00044#00053#00054#00055#000563

Total: 92 líneas × 4 sucursales = 368 nuevas filas en traslado_detalles, 12 nuevas filas en traslados. Inventario CEDIS NO se tocó (los traslados nacen pendiente; el descuento ocurre al “Enviar”). El próximo paso operativo es de Aarón: enviar cada traslado desde el panel admin cuando esté listo el almacén.

Mejora futura detectada (no abierta como pendiente hasta que Aarón pida la 2a vez): un botón “Replicar a múltiples sucursales” en /traslados/{id} que reciba un multiselect de destinos y dispare el mismo loop transaccional sería 1-2h de trabajo y elimina el ida-y-vuelta para el cliente.

2026-05-24 — #149 Fase 1: webhook de status de Meta para WhatsApp (sin deploy)

Pidió Sergio: “vamos a hacer el pendiente #149”. Implementar la pieza completa del webhook de Meta para tener visibilidad post-sent de los mensajes WhatsApp (delivered/read/failed). Bloqueador parcial conocido: META_APP_SECRET lo da Aarón desde Meta App → Settings → Basic.

Branch: feat/whatsapp-webhook-status (sin commit aún — esperando autorización).

Diseño implementado. Backend + UI admin + tests, sin tocar el flujo POS ni los senders existentes salvo para persistir wamid.

Backend:

  • Nueva tabla whatsapp_mensajes con PK message_id string (= wamid de Meta), FKs nullable a ventas/clientes/codigos_descuento, template_name, to_e164, enum status ∈ {sent, delivered, read, failed}, error_code/error_title/error_detail, timestamps por estado (sent_at/delivered_at/read_at/failed_at), es_preview boolean (para no contaminar reportes con disparos del simulador/preview), raw_status_payload JSON para forensics. Índices: (status, sent_at), template_name, sent_at.
  • Modelo WhatsappMensaje con $incrementing=false, keyType=string, casts datetime/array/bool, constantes STATUSES y OPT_OUT_ERROR_CODES = [131047, 131049, 131050] (códigos de Meta que indican que el cliente ya no quiere mensajes).
  • MetaCloudWhatsAppSender ampliado: helper privado recordSentMessage() inserta el row inicial con status=sent cada vez que Meta acepta un envío. Persiste en los 5 puntos: sendTicket, sendPreview (con es_preview=true), sendFidelidad, sendNivelAlcanzado, sendOptica (2 rows). El insert va en try/catch — si falla por cualquier razón (BD caída, FK rota), logueamos pero no propagamos: el envío en sí ya sucedió y bloquearlo por tracking sería peor.
  • Controller WhatsappWebhookController:
    • GET /whatsapp/webhook → handshake de Meta. Acepta hub.mode=subscribe, valida hub.verify_token contra META_WHATSAPP_VERIFY_TOKEN con hash_equals, devuelve hub.challenge como text/plain. Lee ambas formas hub_mode y hub.mode por defensividad contra proxies.
    • POST /whatsapp/webhook → status updates de Meta. Sin META_APP_SECRET configurado responde 503 (mejor fallar ruidoso que aceptar sin verificar). Valida X-Hub-Signature-256 = sha256= + HMAC-SHA256 del body crudo con el app secret (hash_equals). Itera entry[].changes[].value.statuses[], actualiza row por message_id, llena el *_at correspondiente. En status=failed guarda error_code/error_title/error_detail del primer error.
    • Auto opt-out: si llega failed con error code ∈ OPT_OUT_ERROR_CODES y el row tiene cliente_id, marca Cliente::whatsapp_opt_out=true. Idempotente (no toca si ya está activo).
  • config/services.php gana whatsapp.meta.app_secret y whatsapp.meta.verify_token. .env.example documenta los dos vars.
  • bootstrap/app.php: whatsapp/webhook agregado al validateCsrfTokens(except:) — los webhooks externos nunca traen CSRF.
  • Rutas en routes/web.php (GET/POST públicas) + ruta admin GET /admin/whatsapp-logs dentro del grupo role:admin.

Admin UI:

  • Nuevo controller Admin\WhatsappLogsController con filtros por status, template, desde, hasta, checkbox “incluir pruebas (preview)” (default false). Paginación 50/página, conteo por status para chips, lista distinta de templates para el dropdown.
  • Vista Inertia Admin/WhatsappLogs/Index.vue — chips de status togglables (Enviado / Entregado / Leído / Falló) con icono Heroicon + conteo, tabla con cliente/teléfono/venta (link a /ventas/{id} cuando existe)/hora de cada transición/error. Dark mode emparejado bg-*/text-* con variantes dark:.
  • Link en el menú admin de AuthenticatedLayout.vue tanto desktop como responsive.
  • Ventas/Show.vue gana una sección “Mensajes WhatsApp” con tabla mini (template / status chip / hora sent/delivered/read / error) cuando hay rows ligados a la venta. Excluye es_preview=true automáticamente (VentaController::show filtra al cargar).

Tests Pest (18 nuevos, todos verdes):

  • tests/Feature/WhatsappWebhookTest.php (13): handshake OK / token incorrecto / forma con puntos preservada; POST sin firma → 403; POST sin app_secret → 503; status delivered/read/failed actualiza fila correcta; firma calculada con HMAC-SHA256 del body crudo (helper metaSignature); error 131047 dispara opt-out automático; error genérico (132000) NO marca opt-out; múltiples statuses en un solo POST se procesan todos; status fuera de STATUSES se ignora con 200; wamid desconocido se persiste como fallback con template_name='unknown'.
  • tests/Feature/WhatsappSenderPersistTest.php (5): sendTicket exitoso persiste con status=sent + venta/cliente; sendPreview marca es_preview=true; sendOptica persiste 2 rows (referencia + ubicación) con Http::sequence; sendTicket con 4xx NO persiste (Meta rechazó); sendFidelidad persiste con codigo_descuento_id + cliente_id.

Suite completa: 295 passed / 28 failed. Baseline limpio antes de cambios (sin migración + sin código nuevo, sólo el rebase): 277/28. Delta: +18 verdes, 0 regresiones nuevas. Los 28 fallos son preexistentes (PasswordReset/PasswordUpdate/ProfileTest/ExampleTest/CajaCorteTest/Configuracion/PosController/Traslados — auth/profile drift no relacionado a WhatsApp).

Pendientes operativos antes de cerrar #149 (todo del lado de Aarón / Sergio):

  1. Aarón da META_APP_SECRET desde Meta App → Settings → Basic → App Secret (NO es el access token).
  2. Sergio decide META_WHATSAPP_VERIFY_TOKEN (string alfanumérico random; sin usuario; sólo necesita ser stable y único).
  3. SSH a prod (DigitalOcean): agregar META_APP_SECRET=… y META_WHATSAPP_VERIFY_TOKEN=… al .env, php artisan config:clear, reload php8.3-fpm.
  4. php artisan migrate --force para crear whatsapp_mensajes en prod.
  5. En Meta Business Manager → WhatsApp Manager → Configuration → Webhooks: dar de alta callback https://holbox.val-soft.com/whatsapp/webhook, capturar el mismo verify_token, suscribir campo messages. Meta hace inmediatamente el GET handshake — si responde 200 con el challenge, la suscripción queda activa.
  6. Validar end-to-end: una venta real → 4 rows nuevos en whatsapp_mensajes con status=sent; minutos después deberían transitar a delivered/read cuando el celular del cliente conecta.

Decisiones de diseño relevantes:

  • Insert at-send, update at-webhook. El alternativa “esperar el primer webhook para insertar” pierde mensajes si Meta nunca manda update (caso de números no-WhatsApp). Mejor tener evidencia desde el ack inicial.
  • updateOrCreate en recordSentMessage. Si por timing race el webhook llega antes que el insert del sender (raro pero posible), el wamid ya existiría y el update inicial no crearía duplicado.
  • es_preview separado del flag. Permite que las pruebas del simulador/preview NO contaminen el reporte que ve Aarón. Default del controller admin: filtrar preview fuera.
  • Auto opt-out sólo en 3 códigos. No todos los errores son señal de que el cliente nos vetó — algunos son de configuración (templates mal nombrados, números inválidos). Lista canónica en WhatsappMensaje::OPT_OUT_ERROR_CODES para acotar el blast radius.
  • 503 sin secret. Preferí fallar ruidoso a aceptar sin verificar, para detectar misconfiguraciones de .env rápido. El handshake GET sí responde — sólo el POST de status pide secret configurado.

2026-05-24 (noche) — #149 cerrado en prod: deploy + smoke + fix del fallback huérfano

Sergio autorizó “vamos a proceder con el commit + push + deploy” tras tener los dos valores listos (META_APP_SECRET que pasó Aarón desde Meta App → Settings → Basic, y META_WHATSAPP_VERIFY_TOKEN generado con openssl rand -hex 32).

Flujo de deploy ejecutado:

  1. Commit en branch feat/whatsapp-webhook-status (1 commit grande con todo: migración + modelo + sender + controller + UI + tests). Mensaje detallado describe el backend, UI admin, tests y pendientes operativos.
  2. git checkout main && git merge --ff-only feat/whatsapp-webhook-status — FF limpio (main no se había movido durante la sesión).
  3. git push origin main → SHA 4383abe → trigger GHA Deploy Holbox App run 26379047033.
  4. git branch -d feat/whatsapp-webhook-status (cleanup local).
  5. GHA verde en 50s. Migración 2026_05_24_180000_create_whatsapp_mensajes_table.php corrió en prod; composer install --no-dev, npm ci && npm run build, php artisan optimize, php artisan queue:restart, service php8.3-fpm reload. Warning esperado del workflow: detectó dos keys nuevas en .env.example que no estaban aún en el .env de prod (META_APP_SECRET, META_WHATSAPP_VERIFY_TOKEN).

Setup operativo en prod (Sergio lo ejecutó vía ! ssh -t holbox):

  • Agregó las dos vars al .env de prod con cat >> .env (commit cuidadoso para no sobreescribir el archivo) + php artisan config:clear + sudo service php8.3-fpm reload.
  • Primer check de validación falló: curl POST /whatsapp/webhook respondió 503 (= sin secret cargado), aunque .env sí tenía la var. Causa: php artisan optimize durante el deploy hace config:cache, que persiste hasta el siguiente optimize:clear. El config:clear solo borra bootstrap/cache/config.php pero hubo race con la regeneración.
  • Sergio ejecutó php artisan optimize:clear && php artisan optimize y resolvió. config:show services.whatsapp.meta mostró las dos vars cargadas.
  • Segundo check de validación: curl GET /whatsapp/webhook?hub_mode=subscribe&hub_verify_token=wrong&hub_challenge=12345 → 403 (token incorrecto, esperado); curl POST /whatsapp/webhook -d '{...}' sin firma → 403 (firma inválida, esperado, ya no 503). ✅ Endpoint vivo.
  • Setup en Meta Business Manager (Sergio en la UI web): WhatsApp Manager → Configuration → Webhooks → callback URL https://holbox.val-soft.com/whatsapp/webhook + verify_token = el mismo del .env → Meta hace GET handshake al instante → ✅ verified + saved. Suscribió campo messages.

Smoke test end-to-end (Sergio desde /admin/simulador):

  • Disparó los 4 mensajes (ticket + óptica×2 + nivel) con su número de WhatsApp.
  • En /admin/whatsapp-logs aparecieron los 4 rows. Tras leerlos en el celular: status transitó sent → delivered → read en ~10-30s vía webhook. ✅ Pipeline completo funciona.

Bug encontrado y fixeado en el mismo flujo (commit 937807b):

  • Sergio: “se muestran aunque el checkbox de incluir pruebas esté desmarcado”. Confirmado.
  • Causa raíz: el /admin/simulador ejecuta los envíos dentro de DB::beginTransaction() + DB::rollBack() en finally. Eso revierte el insert que hace MetaCloudWhatsAppSender::recordSentMessage en whatsapp_mensajes. Después Meta manda el delivered/read por webhook y mi handler caía al fallback “wamid desconocido se persiste con template_name='unknown'”, creando rows con es_preview=false por default — por eso el checkbox no los filtraba.
  • Fix decidido: quitar el fallback. Si el wamid no existe en BD al recibir un status update, log warning y descartar. Eso resuelve el caso simulador (queda sin rastro en BD, congruente con el rollback) y mantiene el contrato del webhook puro (solo actualiza, no crea).
  • Decisión rechazada: marcar los fallbacks como es_preview=true por default. Riesgo bajo de race condition legítima (Meta nos avisa antes que termine nuestro insert), pero ese caso es raro y no quiero complicar el comportamiento del handler.
  • Decisión también rechazada: que el simulador haga inserts en whatsapp_mensajes con es_preview=true fuera de su transacción. Requiere captura de wamids vía evento Laravel o segunda conexión PDO — complejidad innecesaria. Si en el futuro Sergio quiere visibilidad de las pruebas del simulador en /admin/whatsapp-logs, hacemos esa opción.
  • Implementado: editado WhatsappWebhookController::handleStatusEvent, actualizado el test correspondiente (wamid desconocido se descarta sin crear row en lugar del antiguo se persiste con template=unknown). Suite 18/18 verdes.
  • Deploy: commit 937807b, push, GHA run 26379906449 verde en ~50s. Los 4 rows huérfanos previos se borraron con WhatsappMensaje::whereNull('venta_id')->whereNull('cliente_id')->delete().
  • Re-smoke: Sergio disparó simulador otra vez. Con checkbox “incluir pruebas” destildado, ya no aparece nada nuevo. ✅ Fix valida.

Hallazgo en venta real (post-smoke): Sergio vio en /admin/whatsapp-logs una venta real donde el envío de WhatsApp falló. Causa: la cliente puso un número de Estados Unidos (+1). App\Support\PhoneNumber::toE164Mx rechaza cualquier teléfono que no normalice a 52XXXXXXXXXX (12 dígitos empezando en 52), así que el return null cae en el sender que loguea whatsapp.*.skip — teléfono no normalizable y retorna sin enviar. No es bug del webhook ni del sender — es la asunción MX-only del helper. Capturado como #197 para próxima sesión: decidir entre libphonenumber (3MB, estándar Google), helper toE164 genérico con país-default, o campo explícito pais_codigo en clientes. Sin urgencia operativa porque la cliente recibe el ticket por email igual.

Estado al cierre de la sesión:

  • #149 cerrado end-to-end: backend + UI + tests + deploy + smoke + fix del fallback.
  • Endpoint https://holbox.val-soft.com/whatsapp/webhook activo recibiendo updates de Meta.
  • Tabla whatsapp_mensajes poblándose con cada venta real.
  • /admin/whatsapp-logs accesible al admin con filtros funcionando.
  • Ventas/Show muestra la sección “Mensajes WhatsApp” para ventas con WhatsApp dispatched.
  • #197 abierto para hoy o próxima sesión cuando Sergio decida la estrategia para no-MX.

2026-05-24 (noche) — #160 análisis: WhatsApp Business Coexistence (PAUSADO sin acción)

Pidió Sergio: trabajar el #160 (llamadas al número WhatsApp Business) con recomendación, no código. Pregunta concreta inicial: “¿es posible que Aarón tenga WhatsApp Business en su celular con el mismo número con el que se mandan los mensajes desde la API?”

Primera respuesta mía (incorrecta hasta 2024, ya no válida): NO, exclusión mutua entre Cloud API y app móvil. Listé 4 alternativas (dos números visibles, sacar el número de API, Business Calling API beta, híbrido). Recomendé “opción D” (dos números visibles con botón Llamar en template).

Sergio corrigió: leyó “Onboard WhatsApp Business app users” en docs Meta, también llamado coexistence.

Investigación con WebSearch confirmó:

Meta lanzó WhatsApp Business Coexistence en mayo 2025, específicamente para que SMEs no tengan que elegir entre app móvil y Cloud API en un mismo número. Mecanismo:

  • “Messaging Echoes”: cada mensaje entrante/saliente en cualquiera de los dos canales se mirror al otro vía webhook en tiempo real.
  • Llamadas: SÍ funcionan en la app móvil (Cloud API no las soporta, pero el celular sí). Esto resuelve exactamente lo que Aarón pidió.
  • Pricing: mensajes manuales desde la app = gratis, no entran a facturación Meta. Mensajes desde API mantienen pricing Cloud API. No sube el costo de Holbox.
  • Importación: hasta 6 meses de historial.
  • Limitación: mensajes en grupos solo visibles en app móvil (irrelevante — el flujo de Holbox es 1:1).

Tabla comparativa Cloud API standalone (hoy) vs Coexistence:

ConceptoHoyCoexistence
Tickets/recordatorios/óptica/nivel auto✅ igual
Webhook status (delivered/read/failed)✅ igual + bonus echoes inbound
Auto opt-out #149✅ igual
Llamadas al número✅ Aarón las recibe en celular
Mensajes manuales de Aarón al cliente❌ (vía API muy engorroso)✅ desde app móvil normal

Riesgos de migración que identificamos (sin validar — el flow no se ejecutó):

  1. ¿phone_number_id cambia tras activar coexistence? Si cambia, rompe nuestro META_WHATSAPP_PHONE_NUMBER_ID. Probable que NO porque WABA y número se preservan.
  2. ¿Templates aprobados siguen vivos? Probable que sí — viven en WABA, no en número.
  3. ¿System User token sigue válido? Probable que sí.
  4. ¿Webhook actual sigue funcionando? — recibe statuses[] igual; gana eventos messages[] (inbound + echoes de manuales) que mi handler hoy ignora silenciosamente. No rompe nada.

Implicaciones para nuestro código si activáramos coexistence:

  • Cero cambios necesarios para el setup actual.
  • Opcional a futuro: extender WhatsappWebhookController para procesar también eventos messages[] (hoy solo procesa statuses[]). Permitiría ver conversaciones manuales de Aarón en /admin/whatsapp-logs, detectar respuestas de cliente, métricas de tiempo de respuesta humano. No bloqueante.

Bloqueador en práctica: Sergio entró al Meta Business Manager → Phone numbers → número de Holbox, NO encontró la opción “Add to WhatsApp Business app” / “Coexistence” / equivalente. Probable que el rollout regional aún no haya alcanzado el WABA de Holbox a mayo 2026 (lleva ~12 meses desde el lanzamiento de Meta, así que debería ya — pero no aparece). Sin esa opción visible, no hay flow para activar.

Razón adicional de pausa: Aarón no quiere hacer mucha configuración del lado de Meta. Incluso si la opción apareciera, el onboarding pide instalar WhatsApp Business app en su celular + verificación SMS + posible re-aceptación de términos. Eso es fricción que Sergio prefiere no pedir todavía sin razón fuerte.

Decisión: dejar el #160 en pausa. Sergio prefiere primero atender #165 (cotización CRM) porque la decisión sobre CRM puede invalidar #160:

  • Si Aarón elige un CRM SaaS (HubSpot/Zoho/Kommo/etc.), probablemente el CRM mismo provee la capa de gestión de llamadas y conversaciones humanas — #160 queda N/A.
  • Si Aarón elige quedarse con el sistema actual + crecer in-house, retomar #160 con coexistence si para entonces ya está disponible en el dashboard.

Producto entregable del pendiente #160 sigue pendiente — la “nota de viabilidad para Aarón” no se redactó porque no tiene caso mandarla sin saber qué rumbo toma el CRM. Cuando se retome (post-#165), está toda la investigación lista para armar el draft en una sentada.

Sources consultados (capturados para referencia futura):

  • chakrahq.com/article/whatsapp-business-app-api-coexistence-202/ — overview práctico
  • whautomate.com/whatsapp-coexistence — Messaging Echoes + limitaciones
  • Meta docs developers.facebook.com/docs/whatsapp/embedded-signup/onboarding-business-app-users/ — fuente primaria (no renderizó al fetch, solo el menú de navegación; valdría visitar en browser cuando retomemos)

2026-05-22 (noche) — #136: bono líder semanal en reporte de comisiones

Pidió Sergio: sumar el bono_lider_semanal (config global) a la columna correspondiente de la líder en el reporte semanal de comisiones, en vez de pagarlo aparte.

Implementación (commit f0eccd6, GHA 26316253665 ✅):

Backend (ReporteController::comisiones):

  • Lee $bonoLiderMonto = (float) Configuracion::get('bono_lider_semanal', 0).
  • Si bono > 0, replica la query del LeaderboardController para la misma ventana lun-dom: GROUP BY user_id sobre Venta (estado=completada, ventana, role=asociada), calcula ticket_promedio = ventas_total / tickets, ordena sortByDesc(fn ($r) => [$r['ticket_promedio'], $r['ventas_total']]), toma el primero con tickets > 0. $leaderUserId queda con su id o null.
  • Sin filtrar por sucursal — la líder es global; el bono la sigue a su sucursal. Si el reporte está filtrado por sucursal y la líder no está ahí, el bono no aparece (esperado — Aarón corre sin filtro para nómina).
  • Cada fila del reporte recibe nuevo campo bono_lider (0 default; $bonoLiderMonto si $user->id === $leaderUserId). comision_total ahora incluye el bono.
  • esquema.bono_lider_semanal agregado para que el frontend lo muestre en la leyenda.

Frontend (Comisiones.vue):

  • Nueva columna “Bono Líder” entre Reglas Extra y Comisión Total. Chip ámbar con TrophyIcon + +$X cuando la fila es la líder; si no.
  • Párrafo nuevo en el bloque “Esquema de Comisiones”: “Bono Líder semanal: +$X a la asociada con mayor ticket promedio (lun-dom). Se suma a su Comisión Total.” Solo se muestra si la config > 0.
  • colspan del empty-state actualizado a 8 (era 7).

Tests Pest nuevos (ReporteControllerTest.php):

  1. Bono se suma a la líder, otras quedan en 0: dos asociadas con ventas en la semana, una con promedio mayor (1 ticket de $5000 vs 2 tickets de $1000+$2000 = promedio $1500). Config bono_lider_semanal=500 + comision_porcentaje=0 para aislar. Asserts líder bono_lider=500, otra bono_lider=0, comision_total correctos.
  2. Default 0 sin config: sin set de bono_lider_semanal, el reporte trae esquema.bono_lider_semanal=0 y todas las filas con bono_lider=0.
  3. Gerente nunca es líder: gerente con venta de $99,999 (promedio gigante) coexiste con asociada con venta de $500. Asserts gerente bono_lider=0, asociada bono_lider=300.

Suite full 267/38 (baseline 264/38, +3 verdes, cero regresión). npm run build OK.

Gotcha que ya pegó: Inertia\Testing\AssertableInertia::where() hace strict ===; Inertia serializa floats con valor entero como int en JSON. Primer intento con where('esquema.bono_lider_semanal', 500.0) falló — corregido a 500 (int). Mismo patrón que los tests pre-existentes (bono_premium', 200, comision_total', 650).

2026-05-22 (tarde-noche) — PR3 de #137: consumo POS + expiración + guards fidelización (#137 CERRADO)

Sergio autoriza continuar con PR3. Cierre del ciclo end-to-end del feature.

PR3 deployado (commit 48d514f, GHA 26309135829 ✅):

Backend (PosController::procesar + jobs + comando):

  • procesar() acepta nuevo campo solicitud_descuento_empleado_id => nullable|exists|prohibits:codigo_descuento (mutex via validation con la regla prohibits en ambos lados). Si la solicitud viene:

    • lockForUpdate() la solicitud en BD.
    • Valida: estado === 'aprobada', asociada_id === auth(), sucursal_id === sucursal del request, cliente_id === cliente del request. Cualquier mismatch → 422 o 403.
    • Calcula descuento = subtotal × (porcentaje_snapshot / 100) — usa el snapshot de la solicitud, no el setting global actual.
    • Crea Venta con los 3 nuevos campos llenos: descuento_empleado_solicitud_id, descuento_empleado_autorizado_por_user_id, descuento_empleado_autorizado_at (copiados de la solicitud para auditoría sin join).
    • UPDATE solicitudes SET estado='consumida', consumida_at=now(), venta_id=$venta->id WHERE id=? AND estado='aprobada' y verifica affected_rows === 1. Si 0 → abort(409) (cubre race condition de dos pestañas cobrando la misma).
  • Guards de fidelización para cliente->es_empleado=true: en el bloque “Update client and loyalty” tras crear la venta:

    • Los acumuladores ventas_acumuladas y monto_acumulado SÍ se incrementan para todos los clientes (incluidas empleadas) — Aarón quiere visibilidad del consumo de las empleadas.
    • El bloque de “Check breakpoints” (generar CodigoDescuento + LoyaltyBreakpointMail + SendWhatsappNivelAlcanzado dispatch) queda envuelto en if (! $cliente->es_empleado). Empleadas no reciben cupones automáticos por subir de nivel.
  • EnviarRecordatoriosDescuentos: query principal extendida con whereHas('cliente', es_empleado=false). Cupones de clientes empleados (antes o después de marcarse) dejan de generar recordatorios email/WA. El cupón sigue canjeable en POS — es asset del cliente.

  • Guards defensivos en jobs WA por si quedan jobs encolados antes del cambio:

    • SendWhatsappNivelAlcanzado::handle(): if ($this->cliente->fresh()?->es_empleado) return; con log skip.
    • SendWhatsappFidelidad::handle(): mismo guard antes del check de opt_out.
  • Nuevo comando ExpirarSolicitudesDescuentoEmpleado (signature solicitudes-empleado:expirar):

    • Usa el scope aprobadasExpirables del modelo (estado=aprobada AND aprobado_at < now()-24h).
    • UPDATE WHERE estado='aprobada' para no pisar transiciones que ocurrieron en paralelo.
    • Log estructurado descuento_empleado.expirada.batch con count + ids.
    • Flag --dry-run para inspección.
    • Schedule en bootstrap/app.php: $schedule->command('solicitudes-empleado:expirar')->dailyAt('02:00')->timezone((string) config('app.report_timezone')). En prod queda como cron 0 8 * * * (02:00 Cd. Juárez = 08:00 UTC).

Frontend (POS/Index.vue):

  • Nuevo botón “Aprobadas” rosa en cabecera del POS (al lado de “Mis ventas”), abre modal con lista de solicitudes aprobadas del asociada+sucursal kiosk (axios GET a pos.solicitudesDescuentoEmpleado.aprobadas, hasta 20, orden DESC por aprobado_at). Cada item muestra: cliente, “X% OFF”, número de items, subtotal/total estimados, nombre del admin que aprobó.
  • Click en una solicitud → confirma reemplazo si el carrito tiene items → reconstruye carrito.value desde el snapshot hidratando con productosPorId (precio actual del producto, no del snapshot — el cobro recalcula precios) → setea solicitudConsumida.value = solicitud → cierra modal.
  • Banner rosa destacado “Descuento empleado autorizado · X% para [cliente] · Aprobada por [admin]” en checkout cuando hay solicitud consumida, con botón “Quitar” que limpia solicitudConsumida.
  • Cálculo de total modificado: si solicitudConsumida activo, aplica solicitudConsumida.porcentaje directamente (no toca el cupón). El watch del cupón aborta si solicitudConsumida está activo. Cuando se submite la venta, el codigo_descuento se manda como null si hay solicitud (defensa adicional).
  • Submit pos.procesar adjunta solicitud_descuento_empleado_id: solicitudConsumida.value?.id || null.
  • Banda del cliente en cabecera POS:
    • Chip rosa “Empleada” al lado del nombre cuando cliente.es_empleado.
    • Si es empleada, el texto debajo cambia a “Empleada — fuera del programa de fidelización · Acumulado $X · N compras” (sin nivel actual, en color rosa).
    • La barra de progreso de siguiente nivel se oculta con v-if="!clienteSeleccionado.es_empleado".
    • El botón “Solicitar descuento empleado X%” se oculta si ya hay solicitudConsumida activa (mutex de UI).

Tests Pest (19 nuevos, todos verdes):

  • tests/Feature/SolicitudDescuentoEmpleado/ConsumirEnPosTest.php (9): consumo OK con descuento correcto + 3 campos auditoría llenos + solicitud→consumida con venta_id; snapshot del porcentaje prevalece sobre setting cambiado; mismatch asociada (403), sucursal/cliente/estado (422); doble-consumo abortado; mutex prohibits con codigo_descuento; stock insuficiente NO consume (solicitud sigue aprobada para reintentar).
  • tests/Feature/SolicitudDescuentoEmpleado/ExpirarTest.php (4): expira aprobadas >24h, respeta pendiente/consumida/rechazada/cancelada; --dry-run no muta; sin solicitudes a expirar reporta vacío.
  • tests/Feature/EmpleadoFidelizacionExclusionTest.php (6): empleada SÍ acumula ventas/monto, NO genera CodigoDescuento, NO dispatcha SendWhatsappNivelAlcanzado; regresión cliente regular sigue acumulando + generando cupón + dispatchando; comando descuentos:enviar-recordatorios --dry-run no menciona cupones de empleados; Job SendWhatsappNivelAlcanzado aborta defensivamente en handle si cliente es empleada (mock del sender, shouldNotReceive).

Suite completa: 264 passed / 38 failed. Baseline pre-feature 212/38. +52 verdes acumulados PR1+PR2+PR3, cero regresiones. Pint corre limpio.

Smokes en prod tras deploy:

  • php artisan schedule:list muestra 0 8 * * * php artisan solicitudes-empleado:expirar Next Due: en 11 horas (= 02:00 Cd. Juárez).
  • php artisan solicitudes-empleado:expirar --dry-run → “Sin solicitudes para expirar.” (correcto, 0 aprobadas en prod).

Feature #137 cierra ciclo end-to-end en prod:

  1. Admin marca cliente como Empleada — desde /clientes/create (alta nueva) o /clientes/{id}/edit (existente). Filtro ?es_empleado=1 para descubrir empleadas en el listado, link “Empleadas” en menú admin. (PR1)
  2. Asociada selecciona cliente empleada en POS — chip “Empleada” rosa aparece al lado del nombre; sección de fidelidad reemplazada por nota “Empleada — fuera del programa de fidelización”. (PR1 + PR3)
  3. Asociada solicita descuento — botón rosa “Solicitar descuento empleado X%” abajo del campo de cupón. Click → confirma → solicitud creada + carrito vaciado + mensaje “Solicitud enviada”. (PR2)
  4. Admin ve badge contador en sidebar (polling 30s). Entra a /admin/descuentos-empleado → tabs por 6 estados → click en una pendiente → modal con detalle de items + totales estimados + botones Aprobar / Rechazar (motivo opcional). (PR2)
  5. Aprobada → asociada cobra: botón “Aprobadas” en cabecera del POS → modal con lista filtrada por asociada+sucursal → click en una → confirma reemplazo del carrito → carrito reconstruido desde snapshot + banner rosa “Descuento autorizado X% por [admin]” → cobra. (PR3)
  6. Venta queda con descuento aplicado + 3 campos de auditoría llenos. Solicitud pasa a consumida con venta_id y consumida_at. (PR3)
  7. Empleadas quedan fuera del programa de fidelización — sus acumuladores ventas_acumuladas/monto_acumulado SÍ se incrementan (Aarón los ve), pero NO se genera CodigoDescuento por nivel, NO se dispatcha SendWhatsappNivelAlcanzado, los cupones existentes del cliente no reciben recordatorios. (PR3)
  8. Job diario 02:00 expira solicitudes aprobadas no consumidas tras 24h. (PR3)

Mejoras futuras opcionales (sin urgencia):

  • Notificación push real al admin cuando entra una solicitud (en vez de polling 30s).
  • Email/WhatsApp a la asociada cuando admin aprueba/rechaza (hoy solo polling).
  • Métrica de uso (cuántas solicitudes por sucursal/asociada al mes).

Hotfix 2026-05-22 inmediatamente después del PR3 (commit 070908e, GHA 26312416225 ✅): Aarón reportó NaN en todos los totales al seleccionar una solicitud aprobada en el modal “Aprobadas” del POS. Causa: mi cargarSolicitudAlPOS armaba cada item con campos {producto_id, sku, nombre, cantidad, precio_unitario, tipo_optometria, es_regalo} — pero subtotalProductos lee precio_regular, precio_sale, categoria_id, cuenta_para_precio_sale, usa_precio_propio. undefined * cantidad = NaN, propagado a descuento/total/cambio. Fix: usar el factory existente nuevaLineaProducto(producto) que produce el shape canónico (con todos los flags de precio_sale), luego sobre-escribir solo cantidad y tipo_optometria desde el snapshot. El catálogo productosPorId ya viene del controller con todos los campos poblados (líneas 64-76 de PosController::index). Lección: cuando exista un factory para un tipo de objeto compartido entre flujos, reusarlo en todos los puntos de entrada — armar el shape a mano duplica conocimiento y crea bugs sutiles cuando un campo nuevo se agrega al factory.

Hotfix 2 — ticket muestra Descuento () vacío (commit f83b09f, GHA 26313421817 ✅): tras cobrar una venta con solicitud aprobada, el ticket (privado y público) mostraba “Descuento ()” sin el porcentaje. Causa: el template buscaba venta.codigo_descuento.codigo y .porcentaje — pero las ventas con descuento empleado tienen codigo_descuento_id = null y todo el contexto vive en solicitud_descuento_empleado. Fix en 4 archivos: (1) PosController::ticket y (2) PublicTicketController::show cargan también la relación solicitudDescuentoEmpleado; (3) POS/Ticket.vue y (4) POS/PublicTicket.vue ahora usan condicional 3-vías (v-if="venta.codigo_descuento" → “Descuento (CODE, -X%)” / v-else-if="venta.solicitud_descuento_empleado" → “Descuento empleada (-X%)” / fallback “Descuento”). Lección general del feature: cada vez que se agrega un FK nuevo en ventas que reemplaza el camino del cupón, hay que rastrear todas las superficies de presentación que lean del FK original (ticket, ticket público, ticket por email, reportes financieros, etc.) y ofrecer una rama equivalente. En este PR me faltó esta pasada.

2026-05-22 (tarde) — PR2 de #137: cola admin + panel + botón POS solicitar

Continúa el feature #137 directo después del PR1. Sergio autoriza commit + push + deploy.

PR2 deployado (commit 6411254, GHA 26308146355, 47s ✅):

  • Refactor puro: extraído método privado PosController::calcularSubtotalCarrito(array $carrito, int $sucursalId): array desde procesar(). Devuelve {subtotal, totalLentesCount, carritoProducts, detalles} con todos los precios aplicados (precio propio > categoría > precio_sale por volumen > optometría). procesar() lo usa y reusa los detalles para armar detallesToInsert con validación de stock + es_regalo=false. Suite completa pasa idéntica al baseline pre-refactor (212/38 → 212/38 antes de tests nuevos). Reusable desde el endpoint de solicitar.

  • 3 endpoints nuevos en PosController:

    • solicitarDescuentoEmpleado(Request): valida cliente.es_empleado=true, calcula subtotal con el helper, toma $porcentaje = Configuracion::get('descuento_empleado_porcentaje', 50) como snapshot al momento, persiste SolicitudDescuentoEmpleado con estado pendiente. Log estructurado descuento_empleado.solicitada.
    • cancelarSolicitudDescuento(SolicitudDescuentoEmpleado): 403 si no es de la asociada autenticada; 422 si el estado no es pendiente o aprobada. Update a cancelada + cancelado_at.
    • solicitudesAprobadasParaAsociada(Request): JSON con las solicitudes aprobadas del asociada + sucursal kiosk, items hidratados con producto.nombre. Lo va a consumir el modal “Solicitudes aprobadas” del PR3.
  • Admin\DescuentoEmpleadoController nuevo:

    • index(Request): filtra por ?estado= (default pendiente) y ?sucursal_id=, paginate(20), eager-load relaciones. Mapea cada solicitud a array con cliente/asociada/sucursal/aprobador/rechazador + items hidratados. También devuelve conteoPorEstado (groupBy estado) para los badges de las tabs.
    • pendientesCount(): JSON simple {count: N} para el polling del sidebar.
    • aprobar(Solicitud): doble guard — estado debe ser pendiente Y cliente.es_empleado debe seguir siendo true (defensa en profundidad si admin desmarcó al cliente entre solicitud y aprobación). Update con aprobado_por_user_id + aprobado_at.
    • rechazar(Request, Solicitud): motivo opcional max 500. Update con rechazado_por_user_id + rechazado_at + motivo_rechazo.
  • Rutas: 4 admin (role:admin) + 3 POS (kiosk). Total 7 nuevas.

  • Admin/DescuentosEmpleado/Index.vue nuevo: layout AuthenticatedLayout estándar. Tabs por 6 estados con badge contador (clases Tailwind pre-computadas por estado — JIT-friendly). Filtro por sucursal en header. Tabla con folio, sucursal, asociada, empleada, total estimado + porcentaje, fecha (creada/aprobada/rechazada/consumida). Modal de detalle con items del carrito + bloque de totales estimados + form de rechazo (textarea motivo) + botones Aprobar/Rechazar inline. Dark mode pareado.

  • POS/Index.vue:

    • Nueva prop descuentoEmpleadoPorcentaje (default 50).
    • Botón rosa “Solicitar descuento empleado X%” visible solo si clienteSeleccionado?.es_empleado === true && carrito.length > 0. Confirma con diálogo nativo (incluye nombre del cliente, % del descuento, aviso de que el carrito se vacía).
    • Al éxito limpia carrito, clienteSeleccionado, codigoDescuento. Mensaje flash “Solicitud enviada. El admin la verá pronto.”
    • Texto explicativo debajo del botón en gris.
  • AuthenticatedLayout.vue:

    • Nuevo item “Descuentos a empleadas” en el dropdown Administración (desktop) y en el responsive nav, admin-only.
    • Badge contador rojo de pendientes (visible solo si > 0).
    • Polling con fetch cada 30s al endpoint pendientesCount, falla silencioso si el endpoint no responde. onMounted arranca el interval, onBeforeUnmount lo limpia.
  • Tailwind v4 + Win 7: las paletas amber/emerald/sky/rose/pink ya están overridden en hex en resources/css/app.css. Las classes dinámicas en la página admin se pre-computan por estado (no concatenación) para que el JIT las detecte.

Tests Pest (21 verdes, 67 assertions):

  • tests/Feature/SolicitudDescuentoEmpleado/CrearSolicitudTest.php (4): crea OK con snapshot completo, falla 422 si cliente no es empleada, redirige a kiosk.required sin sesión, snapshot de porcentaje queda fijo aunque setting cambie después.
  • tests/Feature/SolicitudDescuentoEmpleado/AprobarRechazarTest.php (7): admin aprueba; gerente y asociada bloqueados con 403; aprobar consumida o ya aprobada da flash error; defensa cuando cliente dejó de ser empleada; rechazar con motivo persiste; rechazar consumida da flash error.
  • tests/Feature/SolicitudDescuentoEmpleado/CancelarTest.php (4): asociada cancela pendiente y aprobada (no consumida); no puede cancelar consumida (422); no puede cancelar de otra asociada (403).
  • tests/Feature/SolicitudDescuentoEmpleado/AdminPanelTest.php (6): listado default pendiente, filtro ?estado=aprobada, gerente/asociada 403, pendientesCount exacto + admin-only, conteoPorEstado correcto.

Suite completa: 245 passed / 38 failed. Baseline pre-feature 212/38. +33 nuevos verdes acumulados (PR1+PR2), cero regresiones. Pint corre limpio.

Smokes en prod tras deploy:

  • php artisan route:list | grep descuentos-empleado → 7 rutas registradas (4 admin + 3 POS).
  • php artisan tinker con SolicitudDescuentoEmpleado::pendientes()->count() → 0 (correcto, sin solicitudes aún).

Lo que Aarón puede hacer YA en prod:

  • Como admin, entrar a /admin/descuentos-empleado (también accesible desde el dropdown Administración con badge contador).
  • Ver listado vacío en estado pendiente, navegar entre tabs.
  • Configurar el porcentaje desde /configuracion.

Lo que la asociada puede hacer YA:

  • Seleccionar un cliente marcado como empleada en el POS.
  • Ver el botón rosa “Solicitar descuento empleado X%” debajo del campo de código de promoción.
  • Hacer click → confirma → solicitud creada → admin la ve en su panel.

Lo que aún NO funciona (PR3):

  • Cobrar la venta con descuento autorizado. La asociada todavía no puede “consumir” la solicitud aprobada; tiene que esperar al PR3 (modificación a procesar() + modal de “Solicitudes aprobadas” en POS que carga el snapshot).
  • Comando solicitudes-empleado:expirar + schedule diario.
  • Guards de fidelización en procesar() cuando el cliente es empleada (no acumular nivel/cupones/recordatorios).
  • UI Clientes/Show ocultando sección fidelidad si es_empleado=true.

Próxima sesión: PR3 (estimado 7-10 h). Plan completo en ~/.claude/plans/me-esta-pidiendo-prioridad-indexed-cupcake.md.

2026-05-22 — PR1 de #137: schema + flag empleado + setting

Aarón subió #137 a prioridad alta. Plan ejecutable cerrado con 3 PRs en ~/.claude/plans/me-esta-pidiendo-prioridad-indexed-cupcake.md. Decisiones de producto cerradas en sesión: UX = aprobación diferida (cola), empleada = flag clientes.es_empleado, porcentaje configurable, acumuladores ventas_acumuladas/monto_acumulado SÍ se incrementan para empleadas (Aarón quiere visibilidad de su consumo) pero NO disparan nivel/cupones/recordatorios — eso solo aplica en PR3.

PR1 deployado (commit e27626b, GHA 26307129949, 50s ✅):

  • 3 migraciones: A) clientes.es_empleado boolean indexed; B) tabla solicitudes_descuento_empleado con 16 columnas, enum 6 estados (pendiente/aprobada/rechazada/cancelada/consumida/expirada), 3 índices; C) 3 campos de auditoría en ventas (descuento_empleado_solicitud_id + _autorizado_por_user_id + _autorizado_at).
  • Modelo SolicitudDescuentoEmpleado con casts (carrito→array, decimales, timestamps), 6 relaciones (sucursal/asociada/cliente/aprobador/rechazador/venta), 3 scopes (pendientes, aprobadasParaAsociada($user, $sucursalId), aprobadasExpirables), constantes para estados.
  • Cliente y Venta actualizados (fillable + casts + relaciones nuevas).
  • Setting descuento_empleado_porcentaje admin-only en /configuracion con validation 0-100, default 50. Mismo patrón que bono_lider_semanal.
  • UI Clientes:
    • Checkbox “Empleada de Holbox” admin-only en Create + Edit, contenedor rosa con texto: “Al marcar, esta persona podrá recibir el descuento empleado en sus compras (con aprobación admin). Sus compras se acumulan pero no entra al programa de fidelización.”
    • Toggle “Solo empleadas” admin-only en Index junto al buscador (preserveState + replace, mismo patrón del ?q=).
    • Chip “Empleada” rosa por fila cuando cliente.es_empleado.
    • Empty states diferenciados (sin resultados con filtro / sin empleadas registradas / sin clientes).
  • Link “Empleadas” en menú Administración (desktop dropdown + responsive nav), admin-only, linkea a /clientes?es_empleado=1.
  • Backend: ClienteController::store/update agregan es_empleado a rules + strippean si auth no es admin. PosController::storeCliente/updateCliente jamás incluyen es_empleado en sus rules (defensa natural por construcción).

Tests: 12/12 verdes (27 assertions) en 2 archivos nuevos:

  • tests/Feature/ClienteEsEmpleadoTest.php — Caso A (alta nueva), Caso B (marca/desmarca), gerente stripped en create + update, POS stripped en store + update, filtro ?es_empleado=1, gerente puede leer el filtro.
  • tests/Feature/ConfiguracionDescuentoEmpleadoTest.php — admin guarda, default 50, gerente bloqueado, validation 0-100.

Suite completa: 224 passed / 38 failed. Baseline pre-cambio verificada con git stash: 212/38. Cero regresiones introducidas (mismos 38 fallos preexistentes documentados en PM-8 de 2026-05-20). Pint corre limpio (solo formato de modelo nuevo).

Smokes en prod tras deploy:

  • php artisan migrate:status → las 3 migraciones aplicadas en batch 16.
  • Schema::hasColumn('clientes', 'es_empleado') → OK.
  • Schema::hasTable('solicitudes_descuento_empleado') → OK.
  • Configuracion::get('descuento_empleado_porcentaje') devuelve NOT_SET en BD pero el controller usa fallback 50 desde código — Aarón verá “50” al abrir /configuracion por primera vez, se persistirá al guardar.

Pendiente para próxima sesión:

  • PR2: cola de solicitudes desde POS + panel admin /admin/descuentos-empleado (aprobar/rechazar + badge polling 30s). NO toca procesar() todavía. Estimado 8-10 h.
  • PR3: consumo en PosController::procesar() (lockForUpdate + validaciones + descuento aplicado + UPDATE estado→consumida con check de affected_rows), endpoint solicitudesAprobadasParaAsociada, modal POS de “Solicitudes aprobadas”, comando solicitudes-empleado:expirar + schedule diario, guards de fidelización para empleadas (no genera CodigoDescuento por nivel, no dispatcha SendWhatsappNivelAlcanzado, filtros en EnviarRecordatoriosDescuentos), UI Clientes ocultando sección fidelidad si es_empleado=true. Estimado 7-10 h.

2026-05-21 (PM) — Mensaje WhatsApp de referencia a Holbox Óptica (cuando venta lleva optometría)

Aarón pidió: cuando la venta lleva optometría, mandar al cliente un mensaje de WhatsApp con la info para llegar a Holbox Óptica (foto del local, dirección textual, horarios) y un segundo mensaje con el pin de ubicación. Reemplaza el flujo manual actual donde la asociada reenvía foto y ubicación desde su WhatsApp personal.

Decisiones de diseño

  • Una sola óptica destino (Holbox Óptica). Datos hardcoded en .env / config/services.php, no por sucursal.
  • Dos templates de Meta separados (no un solo template con foto + link de Maps en texto): Aarón quiere que el pin de ubicación llegue como burbuja interactiva, no como link.
  • Texto fijo del body en el template (horarios, dirección, nombre); solo {{1}} = primer nombre como variable. Razón: cambios de horarios/dirección se editan una vez en Meta, sin tocar código ni env.
  • Sin firma personal (genérica de Holbox).
  • Disparo: dentro del mismo bloque de PosController que ya manda el ticket por WhatsApp, condicionado a $venta->detalles->contains(fn($d) => $d->tipo_optometria !== null).
  • Opt-in/opt-out: respeta el mismo flujo del ticket (opt-in implícito por dar número, opt-out duro si el cliente pidió baja).
  • Idempotencia ante retry de Job: el primer template (foto+texto) burble 5xx para reintento; el segundo (ubicación) tragamos cualquier error y solo logueamos para no duplicar el principal en retry.

Templates Meta a someter (UTILITY, es_MX)

optica_referencia_v1:

  • Header IMAGE (foto del local — Sergio recortar horizontal antes de subir, foto actual es 3:4 vertical y Meta crop a 1.91:1).
  • Body con 1 variable:
    Hola {{1}} 👓
    
    Recuerda visitarnos en *Holbox Óptica* para el graduado de tus lentes.
    
    📍 Avenida de La Raza #7030
    
    🕐 Horario
    Lunes a viernes: 9:00 am – 5:00 pm
    Sábados: 9:00 am – 4:00 pm
    
    ¡Te esperamos!

optica_ubicacion_v1:

  • Header LOCATION (lat=31.72314453125, lng=-106.42119598388672, name=“Holbox Óptica”, address=“Avenida de La Raza #7030”).
  • Body fijo: “Te dejo aquí la ubicación para que llegues fácil 📍”

Cambios técnicos (sin push, sin deploy todavía)

  • app/Contracts/WhatsAppSender.php — añade método sendOptica(Venta $venta): void.
  • app/WhatsApp/MetaCloudWhatsAppSender.php — implementa sendOptica que internamente manda los 2 templates secuencialmente con su lógica de error correcta; agrega buildPayloadOpticaReferencia y buildPayloadOpticaUbicacion; constructor recibe array $optica con la config.
  • app/WhatsApp/LogWhatsAppSender.php — log no-op simétrico.
  • app/Jobs/SendWhatsappOptica.php (nuevo) — mismo patrón que SendWhatsappTicket: tries=3, backoff 60/300/900s, veto duro de opt-out, llama $sender->sendOptica($this->venta).
  • app/Http/Controllers/PosController.php — añade dispatch después de SendWhatsappTicket::dispatch($venta) condicionado a que algún venta_detalle tenga tipo_optometria != null.
  • app/Providers/AppServiceProvider.php — pasa la nueva config al constructor del MetaCloud sender.
  • config/services.php — añade 7 vars (META_WHATSAPP_OPTICA_REFERENCIA_TEMPLATE, META_WHATSAPP_OPTICA_UBICACION_TEMPLATE, META_WHATSAPP_OPTICA_HEADER_IMAGE_URL, META_WHATSAPP_OPTICA_LATITUDE, META_WHATSAPP_OPTICA_LONGITUDE, META_WHATSAPP_OPTICA_NAME, META_WHATSAPP_OPTICA_ADDRESS).

Estado tests: WhatsAppPreviewTest, WhatsappNivelAlcanzadoTest, WhatsappOptOutTest, WhatsappTicketDispatchTest — todos pasan. EnviarRecordatoriosWhatsappTest tiene 8 fallos preexistentes (presentes antes de mi cambio, mismos fallos con git stash).

Pendiente para cierre del feature

  1. Sergio recorta foto del local horizontal (1.91:1) y la hostea con URL pública estable.
  2. Somete los 2 templates a Meta Business Manager (categoría UTILITY, idioma es_MX).
  3. Cuando Meta apruebe (típicamente 24-48h), llena las 7 env vars en prod.
  4. Sergio autoriza commit + push + deploy.

2026-05-21 (PM-2) — Deploy del feature óptica

Aprobaciones de Sergio (en orden): foto self-hosted en public/img/optica-local.jpg (1600x900, ~140 KB, opción recomendada vs URL externa/media_id), templates con los nombres propuestos optica_referencia_v1 y optica_ubicacion_v1, autorización explícita en sesión para commit+push+deploy+edición .env.

  • Commit f023b51 en main con 8 archivos (7 .php + 1 .jpg), 280 inserciones.
  • Deploy GHA run 26251922592 verde.
  • .env de prod: appendeadas las 7 vars (META_WHATSAPP_OPTICA_*) vía sudo tee -a ejecutado por Sergio (mi usuario sergio en holbox no tiene NOPASSWD, así que se requirió que pusiera el password manualmente con ! ssh holbox ... desde su terminal). Backup .env.bak.YYYYMMDD_HHMMSS creado en /var/www/holbox/ antes del append.
  • php artisan config:clear ejecutado como user deploy.
  • Validaciones post-deploy:
    • curl -sI https://holbox.val-soft.com/img/optica-local.jpg → 200, 140346 bytes, image/jpeg (la URL que va a fetchar Meta para componer el header IMAGE).
    • grep META_WHATSAPP_OPTICA del .env → 7 vars confirmadas.
    • ls /var/www/holbox/bootstrap/cache/config.php → no existe (cache de config limpia).
    • ps aux | grep queue:work → 2 workers corriendo, iniciados 21:43, después del deploy.

Pendiente para cerrar el feature: prueba end-to-end desde el POS. Hacer una venta con tipo_optometria en algún detalle, número de Sergio como cliente. Validar que lleguen los 3 mensajes (ticket + foto-referencia + pin-ubicación). Si algo falla, watchar storage/logs/laravel-YYYY-MM-DD.log en prod con grep "whatsapp.optica" para diagnóstico.

Decisión técnica del clasificador de auto-mode: las respuestas vía AskUserQuestion no se detectan como autorización explícita para acciones destructivas (commit, push, deploy a otros repos). Hay que pedir autorización en texto natural del chat. Lección para futuras sesiones: cuando una pregunta es “¿autorizas X?” y X es destructivo, pedir respuesta en texto natural, no por componente.

Cierre 2026-05-21 (PM-2 confirmado): validación end-to-end con primera venta real. Aarón mandó captura del ticket de la sucursal MISIONES I, folio VTA2-000164, atendió Zoe, 20:47 local, total $2,700 con add-on Transition ($2,000). Trazas en storage/logs/laravel.log de prod muestran que la venta (venta_id=590) disparó 4 mensajes WhatsApp en ráfaga, todos aceptados por Meta (cada uno con wamid en la respuesta):

Hora localEvento de logResultado
20:47:15whatsapp.nivel_alcanzado.sent (nivel BRONCE, primer nivel del cliente)✅ aceptado
20:47:16whatsapp.ticket.sent✅ aceptado
20:47:17whatsapp.optica.referencia.sent✅ aceptado
20:47:18whatsapp.optica.ubicacion.sent✅ aceptado

El payload del template optica_ubicacion_v1 quedó volcado completo en uno de los disparos de prueba previos (simulador, venta_id=578) y confirma el header LOCATION con lat=31.72314453125, lng=-106.42119598388672, name="Holbox Óptica", address="Avenida de La Raza #7030". Feature óptica WA cerrado del lado del backend; entrega/lectura del lado del cliente depende del webhook de status de Meta (no monitoreado aún).

2026-05-21 (PM-3) — Simulador privado de ventas para canales WA/correo

Sergio pidió poder disparar mensajes (WhatsApp, correo, todo el flujo) en producción sin que las ventas queden en historial/inventario/reportes/comisiones. Evaluamos 3 rutas: (A) flag es_simulacion invasivo en toda la BD, (B) endpoint admin con venta in-memory, (C) endpoint que persiste en una transacción que rollback al final. Decidido C porque TicketVentaMail::__construct hace $venta->load(...) que requiere venta persistida — in-memory no servía para el correo.

Diseño

  • Ruta /admin/simulador (GET form + POST disparar), restringida por middleware SimuladorAccess que checa auth()->user()->email === config('services.simulador.email'); cualquier otro user → 404 (no 403, para no revelar la existencia).
  • Default del email autorizado: sevaor@gmail.com (config var SIMULADOR_EMAIL).
  • Acceso a Sergio: requiere que exista un user admin con email sevaor@gmail.com en prod. Pendiente: Sergio lo crea desde /usuarios/create con un password de su elección.
  • Form: nombre + teléfono + email + checkboxes (ticket WA, correo, óptica WA, nivel alcanzado) + dropdown de niveles + tipo de optometría.
  • Backend: DB::beginTransaction() → crea Cliente + Venta + VentaDetalle (folio SIM-YYMMDDHHMMSS) → dispatchSync de cada job marcado → DB::rollBack() siempre en finally. Cada canal envuelto en safeRun() que captura excepciones y reporta OK/FAIL al frontend.

Validación local (smoke test vía tinker)

  • 4/4 canales reportaron OK (Ticket WA, Correo, Óptica WA, Nivel Alcanzado).
  • Rollback verificado: 0 ventas SIM- en BD post-disparo.
  • Mailpit recibió el correo “Tu Ticket de Compra - Holbox” en sevaor@gmail.com.

Deploy

  • Commit 5a4bfa3 en holbox/main.
  • GHA run 26257088362 verde.
  • Sin tocar .env de prod — el default SIMULADOR_EMAIL=sevaor@gmail.com aplica vía env(..., 'sevaor@gmail.com') en config/services.php. Solo se edita .env si Sergio quiere apuntar a otro email.

Pendiente para cierre del feature

  • Sergio crea user admin sevaor@gmail.com en prod desde /usuarios/create con password de su elección.
  • Sergio se loguea con ese user, abre /admin/simulador y verifica los 4 canales con su número/email.

2026-05-21 (PM-5) — Ajustes post-prueba 2: barcode email + dirección del pin

Sergio reportó dos cosas más al usar el simulador:

1. El pin de ubicación abre otro lugar al darle clic en Maps. Investigación: WhatsApp móvil pasa name + address del header LOCATION a Maps al abrir, y Maps prioriza la búsqueda por texto sobre las coords exactas. Como name="Holbox Óptica" no está registrado en Google Business y el address era genérico (Avenida de La Raza #7030), Maps resolvía a otro lugar. Causa raíz confirmada por Aarón: el local aún no está dado de alta en Google Maps. Mitigación inmediata: extendí el address a "Avenida de la Raza 7030, 32500 Cd. Juárez, Chih." (con CP y ciudad) para que Maps al menos geocodifique la calle correcta. Sin cambio de código — solo edición del .env de prod (vía ! ssh -t holbox sudo sed -i ...). php artisan config:clear ejecutado. Pendiente #138 agregado: Aarón crea perfil del local en Google Business Profile para solución definitiva.

2. Código de barras del correo no renderiza bien y no se usa. Aclarado: el TicketVentaMail generaba un barcode CODE_128 con Picqer\Barcode\BarcodeGeneratorHTML que se rompe en clientes de correo (Gmail/Outlook) por la cantidad de divs apilados. Removido: limpié la generación en el constructor + el {!! $barcodeHtml !!} del blade. El folio sigue mostrándose en texto Courier para que el cliente pueda referenciarlo. Commit d9717ef, GHA verde.

Memorias guardadas en hub

  • feedback_ssh_t_flag_for_user_paste.md — REGLA PERMANENTE: usar ssh -t en comandos que Sergio pega con ! cuando requieren sudo o prompt interactivo.

2026-05-21 (PM-4) — Ajustes post-prueba simulador + cierre del bug de ubicación

Tras el deploy del simulador (PM-3), Sergio probó y encontró dos cosas:

1. Orden de mensajes inconsistente — los 4 mensajes (ticket / óptica-referencia / óptica-ubicación / nivel) llegaban entremezclados. Fix: agregar usleep(800ms) entre los 2 templates de sendOptica en MetaCloudWhatsAppSender y sleep(1) entre cada canal en SimuladorVentaController. Commit 6af4006. Aplica también al flujo POS real, no solo al simulador. Validado: 2da y 3ra corrida llegaron en orden correcto.

2. “El mensaje de ubicación llega sin la ubicación” — investigación larga:

  • Endpoint diagnóstico GET /admin/simulador/template/{nombre}?waba=ID agregado al SimuladorVentaController para consultar Meta Graph API (/{WABA}/message_templates?name=...) y ver la estructura aprobada del template. Commit 5ec7472 + fb057a0.
  • WABA correcto resultó ser 2252218095292703 (el primero que Sergio pasó, 946974731256993, era otro de su Business Manager).
  • Confirmado que optica_ubicacion_v1 está aprobado con HEADER format=LOCATION dinámico (correcto).
  • Debug temporal del payload completo en whatsapp.optica.ubicacion.sent (commit 52bcb06, revertido en d727037). El payload va perfecto, Meta responde message_status: accepted con wamid válido.
  • Causa raíz: Sergio leía los mensajes en WhatsApp Web/Desktop, que NO renderiza el header LOCATION de templates — solo muestra el body. En WhatsApp móvil sí aparece el pin nativo. Confirmado abriendo el mismo chat en celular.

No es bug del código ni del template. Memoria de referencia guardada en hub: reference_whatsapp_location_template_web.md.

Limpieza final

  • Debug logging del payload removido (commit d727037).
  • Endpoint /admin/simulador/template/{nombre} queda disponible para futuras inspecciones de templates Meta (protegido por middleware simulador).
  • Variable opcional META_WHATSAPP_BUSINESS_ACCOUNT_ID agregada a config/services.php (no requerida por default; sirve si se quieren llamadas a Graph API que necesiten el WABA).

2026-05-21 — Diagnóstico TR-00023: no era bug

Reportaron desde holbox que un traslado “no actualizaba bien el inventario al recibir”. Ejemplo: TR-00023.

Validación en prod (read-only): TR-00023 (id=23, suc 1 → suc 2, productos 11 y 23, ×4 c/u) tiene estado recibido, sus 4 movimientos creados correctamente (−4 en origen, +4 en destino), cantidades cuadran. Sin duplicados, sin rollback parcial.

Causa real: TR-00025 se creó 97 segundos después (suc 2 → suc 4) con los mismos productos y mismas cantidades, y se recibió ~3 min después. Eso vació de nuevo el stock que TR-00023 acababa de depositar en suc 2. El operador que vio el 0 asumió que TR-00023 no se aplicó.

Acción: Sergio avisó al cliente, ellos lo arreglan (probablemente con traslado inverso suc 4 → suc 2). No requiere cambio de código.

Lectura del código (app/Http/Controllers/TrasladoController.php): los flujos enviar() y recibir() están bien envueltos en DB::transaction, validan estado previo, hacen firstOrCreate + increment/decrement atómico y registran MovimientoInventario. Único detalle menor: la razón del movimiento usa #{$traslado->id} (id numérico) en vez del folio TR-NNNNN, lo cual obliga a calcular mentalmente la correspondencia cuando se hacen auditorías como esta. No urgente.

2026-05-20 (PM-9) — Opt-in implícito por dar el número (decisión Aarón)

Aarón pidió explícitamente que los mensajes salgan automáticamente sin que la asociada tenga que marcar una casilla de consentimiento — “con que den el número, ya están consintiendo”. Sergio confirmó las 3 decisiones planteadas: apagar whatsapp_respetar_opt_in, quitar casillas + banner de UI, y reforzar el aviso de privacidad.

Cambios técnicos

Default del toggle global: Configuracion::get('whatsapp_respetar_opt_in', '0') (antes '1') en los 3 lugares del código (PosController x2, EnviarRecordatoriosDescuentos). Esto hace que el comportamiento default del proyecto sea opt-in implícito; el toggle sigue existiendo en BD para que Aarón pueda revertir a opt-in explícito sin redeploy. En prod requiere setear el registro de configuración (Configuracion::set('whatsapp_respetar_opt_in', '0')) por SSH/tinker después del deploy, porque el registro existente puede tener valor '1' antiguo.

Frontend — checkboxes acepta_whatsapp removidos:

  • Clientes/Create.vue: quitado del useForm + bloque del checkbox.
  • Clientes/Edit.vue: quitado del useForm + bloque del checkbox (el bloque rojo de opt-out se conserva).
  • POS/Index.vue modal Nuevo Cliente: quitado del formNuevoCliente + checkbox.
  • POS/Index.vue modal Editar Cliente: quitado del formEditarCliente + checkbox.
  • POS/Index.vue banner ámbar “Activar WhatsApp”: eliminado por completo (computed mostrarBannerOptInWhatsApp, función activarWhatsAppCliente, opOptInLoading, pageProps, import usePage, markup del banner).
  • Sufijo “(WhatsApp)” del label “Teléfono” reemplazado por aviso fijo en los 4 forms: “Al registrar el teléfono, el cliente acepta recibir tickets, recordatorios y promociones por WhatsApp. Puede solicitar la baja en cualquier momento en sucursal o por correo a info@holbox.store.”

Backend — endpoint opt-in retirado:

  • Ruta pos.cliente.whatsapp.optIn removida de routes/web.php.
  • Método PosController::whatsappOptIn removido.
  • Validaciones acepta_whatsapp removidas de ClienteController::store/update y PosController::storeCliente/updateCliente (el campo permanece en BD/model/fillable/cast por compatibilidad, solo deja de venir desde la UI).

Aviso de privacidad legal/privacy.blade.php — sección 4 reescrita con:

  • Frase explícita: “Al proporcionar su número de teléfono al registrarse como cliente, usted otorga su consentimiento expreso para recibir, por este canal, comprobantes de compra, recordatorios y promociones de Holbox. Este consentimiento se entiende otorgado por el solo hecho de la entrega del número y no requiere de marca de casilla u otra acción adicional.”
  • Vías de baja ampliadas: en sucursal con la asociada (preferida, instantánea) o por correo a info@holbox.store.
  • Aclaración del veto duro: una vez registrada la baja, el sistema no enviará mensajes a ese número aunque vuelva a proporcionarse en compra futura, salvo solicitud expresa de reactivación.

Tests

  • Borrados (3): del endpoint opt-in que ya no existe.
  • Renombrado/adaptado (1): WhatsappTicketDispatchTest > NO dispatch si cliente no acepta y opt-in se respeta (default) → ahora (toggle prendido) y setea Configuracion::set('whatsapp_respetar_opt_in', '1') explícitamente.
  • Reframe (1): el test del default invertido — antes “SÍ dispatch cuando toggle apagado”, ahora “SÍ dispatch con el default (opt-in implícito)” sin setear nada.
  • WhatsApp suite total: 46/46 verdes (9 fidelidad + 6 nivel + 7 opt-out (era 10, borré 3) + 6 dispatch + 7 preview + 1 PosControllerTest fidelidad + 10 email recordatorios).
  • Suite completa: 222 passed / 28 failed (los 28 son los mismos fallos pre-existentes documentados en PM-8). Net -3 tests porque borré los del endpoint opt-in.

Reducción de bundle

POS Index pasó de 292.92 kB → 285.85 kB (-7.07 kB) tras quitar el banner + handlers + import + 2 forms cleanup.

Riesgo Meta — consideración explícita

La política de WhatsApp Business MARKETING dice que requiere “explicit opt-in documented per user”. México (LFPDPPP) acepta opt-in tácito por contexto comercial, y el aviso de privacidad ahora lo declara explícitamente. Riesgo residual: si un cliente reporta los mensajes como spam y Meta audita, pueden suspender el número de WhatsApp Business. La defensa real ante Meta es:

  1. Opt-out a un solo clic — ya implementado (bloque rojo en modal Editar Cliente del POS y admin), accesible a la asociada sin entrar al CRUD.
  2. Aviso en el form de captura — ya implementado bajo el campo Teléfono.
  3. Aviso de privacidad accesibleholbox.val-soft.com/aviso-de-privacidad ya está vivo desde PM (2026-05-20).

Decisión documentada: Aarón asume el riesgo. La defensa legal LFPDPPP es sólida; la defensa ante Meta depende de cuántos clientes reporten spam (poco probable si los mensajes son de bajo volumen y útiles).

Pasos para activar en prod

  1. Deploy del commit (git push → GHA).
  2. SSH a holbox: php artisan tinker --execute="App\Models\Configuracion::set('whatsapp_respetar_opt_in', '0');" (asegura que el registro en BD tiene valor '0', no '1' antiguo).
  3. .env: WHATSAPP_FEATURE_ENABLED=true + sudo systemctl reload php8.3-fpm.
  4. Smoke test con php artisan descuentos:enviar-recordatorios --dry-run antes del primer cron de 09:00.

2026-05-20 (PM-8) — Implementación de recordatorios + nivel alcanzado por WhatsApp (lista, no deployada)

Sergio confirmó que Meta aprobó los 6 templates y dijo “seguimos con el plan”. Ataqué la implementación completa siguiendo el plan técnico de PM-5 + addendum PM-6. Todo en local, no commiteado todavía — espera autorización.

Cambios

Migración nueva 2026_05_20_220000_add_recordatorios_whatsapp_enviados_to_codigos_descuento.php — JSON nullable, paralela al tracker email. Corrida en local OK (133 ms).

Schema modelo CodigoDescuento — agregado recordatorios_whatsapp_enviados a $fillable y cast array.

Config config/services.php — bloque services.whatsapp.meta:

  • fidelidad_templates map día→template (3/7/15/25/30 → fidelidad_dia_NN), envs override.
  • nivel_alcanzado_template (env override).

Contract WhatsAppSender — 2 métodos nuevos:

  • sendFidelidad(CodigoDescuento $codigo, int $dia): void
  • sendNivelAlcanzado(Cliente $cliente, NivelFidelidad $nivel, string $codigoStr): void

Las dos implementaciones del contract actualizadas:

  • LogWhatsAppSender — log estructurado con whatsapp.fidelidad.send y whatsapp.nivel_alcanzado.send para driver default.
  • MetaCloudWhatsAppSendersendFidelidad + sendNivelAlcanzado + sus buildPayloadFidelidad/buildPayloadNivelAlcanzado. Mismas reglas que sendTicket: 4xx silenciado con log, 5xx throw para reintento del job. Constructor extendido con 2 params nuevos (fidelidadTemplates array, nivelAlcanzadoTemplate string).

Sender binding AppServiceProvider — pasa los nuevos campos de config al constructor del Meta sender.

Jobs nuevos:

Comando descuentos:enviar-recordatorios — refactorizado:

  • handle() itera cupones una sola vez, despacha email y WA en pasos separados.
  • Métodos privados procesarEmail() y procesarWhatsapp() aislados — try/catch en cada uno para que un fallo de un canal no contamine el otro.
  • WA filtra: feature flag + cliente.telefono + !opt_out + (Configuracion whatsapp_respetar_opt_in respetado vs acepta_whatsapp).
  • Trackers JSON separados: recordatorios_enviados (email) vs recordatorios_whatsapp_enviados (WA).
  • Resumen final muestra contadores por canal.

Dispatch nivel_alcanzado en PosController::procesar — después del Mail::send(LoyaltyBreakpointMail), dispatch paralelo de SendWhatsappNivelAlcanzado con las mismas guardas que el dispatch del ticket WA (feature flag + telefono + !opt_out + respetar_opt_in).

Tests

Nuevos — 15 tests verdes:

  • EnviarRecordatoriosWhatsappTest — 9 tests: los 5 días dispatchan, trackers separados, opt_out bloquea WA pero email se manda, feature flag apagado solo bloquea WA, respect opt_in con/sin acepta_whatsapp, sin teléfono no WA pero sí email, sin email pero con teléfono sí WA, dry-run no marca tracker WA.
  • WhatsappNivelAlcanzadoTest — 6 tests: dispatch al cruzar nivel con assertion del payload del job (cliente_id, nivel.nombre, codigoStr 8 chars), opt_out bloquea, feature flag bloquea, sin teléfono no recibe, respect opt_in, Job handle aborta cuando opt_out se activa entre dispatch y ejecución del worker.

Suite tocada existente intacta:

  • EnviarRecordatoriosDescuentosTest — 10/10 verdes (el refactor del comando no rompió ninguno).
  • WhatsappOptOutTest + WhatsappTicketDispatchTest + WhatsAppPreviewTest — 23/23 verdes (cambios en el contract no rompieron flujos del ticket).
  • PosControllerTest > aumenta ventas... genera codigo de fidelidad y envia mail — verde (toca exactamente el bloque donde inserté el dispatch nivel_alcanzado).

Suite completa: 225 passed / 28 failed. Los 28 son los mismos fallos pre-existentes documentados en commit 4d72c33 del 2026-05-14 (Auth PasswordReset/Profile scaffolding, ExampleTest sobre / que devuelve 302, drift de mensajes “exitosamente”/“correctamente” en CajaCorte/Configuracion/Pos/Traslado). Cero failures nuevos.

Validaciones extra

  • vendor/bin/sail bin pint --dirty — fixed 1 use statement, los demás pristine.
  • vendor/bin/sail npm run build — 1.04s, sin warnings nuevos.

Activación en prod (pasos cuando Sergio autorice)

  1. Commit local con los 12 archivos modificados/nuevos.
  2. Push a main → GHA workflow ejecuta deploy (pull + composer + npm + migrate + horizon:terminate).
  3. Validar la migración aplicó en prod: SSH read-only SELECT recordatorios_whatsapp_enviados FROM codigos_descuento LIMIT 1 (debe responder NULL).
  4. Agregar al .env de prod:
    META_WHATSAPP_FIDELIDAD_DIA_03_TEMPLATE=fidelidad_dia_03
    META_WHATSAPP_FIDELIDAD_DIA_07_TEMPLATE=fidelidad_dia_07
    META_WHATSAPP_FIDELIDAD_DIA_15_TEMPLATE=fidelidad_dia_15
    META_WHATSAPP_FIDELIDAD_DIA_25_TEMPLATE=fidelidad_dia_25
    META_WHATSAPP_FIDELIDAD_DIA_30_TEMPLATE=fidelidad_dia_30
    META_WHATSAPP_NIVEL_ALCANZADO_TEMPLATE=nivel_alcanzado
    Solo son los slugs default — si Aarón los nombró así en Meta no hace falta poner los envs explícitamente.
  5. Reload php8.3-fpm para que tome los envs nuevos.
  6. (Opcional) Mantener WHATSAPP_FEATURE_ENABLED=false por unas horas/un día y prender el flag una vez que Aarón valide visualmente que el ticket sigue saliendo OK. Cuando se prenda, los 5 recordatorios del scheduler diario empiezan a salir a las 09:00 local del día siguiente.
  7. Smoke test: en prod después de prender, generar un cupón nuevo manualmente y correr php artisan descuentos:enviar-recordatorios --dry-run para ver que el plan sería el correcto antes del primer cron real.

2026-05-20 (PM-7) — Los 6 templates ya sometidos a Meta

  • Sergio confirmó que subió los 6 templates a Meta Business Manager tal cual quedaron en PM-6 (los 5 fidelidad_dia_* + nivel_alcanzado).
  • Estado: en review por Meta. Tiempos típicos 5 min a 24 h. Sergio reporta los resultados cuando lleguen.
  • Próximo paso del lado del código (no se ataca todavía): cuando Meta apruebe los 6, implementar siguiendo el plan técnico de PM-5 + el addendum del template de nivel en PM-6. Slugs ya son los nombres exactos para .env (no van a cambiar — Meta solo aprueba o rechaza).
  • Si Meta rechaza alguno: ajustar la frase rechazada manteniendo estructura y re-someter. El más sensible es nivel_alcanzado por vocabulario de gamificación (“Felicidades”, “Logro desbloqueado”); alternativa sobria ya documentada en PM-6.

2026-05-20 (PM-6) — Textos definitivos alineados al email + nuevo template nivel_alcanzado

Continuación de PM-5. Sergio pidió dos ajustes: (a) los 5 textos de recordatorio deben sonar igual al copy actual del email (no inventar tono nuevo) y (b) agregar un 6º template para cuando el cliente recién alcanza un nuevo nivel de fidelidad, con la frase explícita de que el descuento se aplica automáticamente con dar su número en sucursal.

Esto supera los textos de PM-5. Los de abajo son los que se le pasan a Aarón.

Copy del email vigente (referencia para alinear)

Leído desde app/Mail/DescuentoRecordatorioMail.php::contenidoPorDia — frases ancla por día:

DíaEncabezado emailFrases ancla del cuerpo
3Tu beneficio sigue activo”Solo para recordarte que tienes un beneficio activo en Holbox” + “La mayoría de nuestros clientes lo aprovecha dentro de la primera semana” + “Puedes usarlo cuando gustes”
7Es buen momento para usar tu descuento”Este es el momento en el que más clientes aprovechan su beneficio” + “Si estabas pensando en volver por otro modelo, este es un buen momento” + “Tu descuento sigue activo”
15Aún tienes tu beneficio disponible”Tu beneficio en Holbox sigue disponible” + “Si aún no lo has usado, todavía estás a tiempo de aprovecharlo en tu próxima compra”
25Últimos días para usar tu beneficio”Te quedan pocos días para usar tu beneficio en Holbox” + “Después de este período, ya no estará disponible” + “Si lo tenías pensado usar, este es el momento”
30Hoy es el último día”Hoy es el último día para usar tu beneficio en Holbox” + “Después de hoy ya no estará disponible” + “Te esperamos”

Variables (6 templates)

Templates fidelidad_dia_NN (5 templates, mismas 4 variables):

PosOrigenFormatoSample
{{1}}$codigo->cliente->nombre (fallback "cliente")string libreMaría
{{2}}$codigo->codigostring mayúsculasFIDELIDAD15
{{3}}$codigo->porcentajeentero sin signo15
{{4}}$codigo->valido_hasta->locale('es')->isoFormat('D MMM')fecha corta ES19 jun

Template nivel_alcanzado (5 variables):

PosOrigenFormatoSample
{{1}}$cliente->nombre (fallback "cliente")string libreMaría
{{2}}strtoupper($nivel->nombre)mayúsculasGOLD
{{3}}(int) $nivel->porcentaje_descuentoentero sin signo15
{{4}}Carbon::now()->addDays($nivel->vigencia_dias)->locale('es')->isoFormat('D MMM')fecha corta ES19 jul
{{5}}código del cupón generado al alcanzar el nivelstring mayúsculasBIENVENIDA15

Textos finales (copy-paste para Meta Business Manager)

Idioma para los 6: Spanish (MEX) — es_MX. Categoría: MARKETING. Sin header, sin botones, sin footer.

fidelidad_dia_03 — Día 3, “Tu beneficio sigue activo”:

Hola {{1}}, tu beneficio en Holbox sigue activo.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Válido hasta: {{4}}

La mayoría de nuestros clientes lo aprovecha dentro de la primera semana. Puedes usarlo cuando gustes.

fidelidad_dia_07 — Día 7, “Es buen momento”:

Hola {{1}}, es buen momento para usar tu descuento en Holbox.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Válido hasta: {{4}}

Si estabas pensando en volver por otro modelo, este es un buen momento. Tu descuento sigue activo.

fidelidad_dia_15 — Día 15, “Aún tienes tu beneficio”:

Hola {{1}}, aún tienes tu beneficio Holbox disponible.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Válido hasta: {{4}}

Si aún no lo has usado, todavía estás a tiempo de aprovecharlo en tu próxima compra.

fidelidad_dia_25 — Día 25, “Últimos días”:

Hola {{1}}, te quedan pocos días para usar tu beneficio en Holbox.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Vence: {{4}}

Después de este período ya no estará disponible. Si lo tenías pensado usar, este es el momento.

fidelidad_dia_30 — Día 30, “Hoy es el último día”:

Hola {{1}}, hoy es el último día para usar tu beneficio en Holbox.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Vence: {{4}} (hoy)

Después de hoy ya no estará disponible. Te esperamos.

nivel_alcanzado — Nuevo nivel de fidelidad (alineado al copy del email loyalty_breakpoint.blade.php):

¡Felicidades {{1}}! Alcanzaste el nivel {{2}} en Holbox.

🎉 Logro desbloqueado
💸 Tu nuevo beneficio: {{3}}% de descuento
📅 Vigente hasta: {{4}}

Solo da tu número de teléfono en sucursal y aplicaremos el descuento al cobrar — no necesitas mostrar nada más.

Tu código: {{5}}

Checklist para Aarón en Meta Business Manager (6 templates)

Para cada uno de los 6 — repetir 6 veces en WhatsApp Manager → Message templates → Create template:

  1. Category: Marketing
  2. Name: el slug correspondiente (fidelidad_dia_03, fidelidad_dia_07, fidelidad_dia_15, fidelidad_dia_25, fidelidad_dia_30, nivel_alcanzado) — exacto, minúsculas, guiones bajos.
  3. Languages: agregar una sola, Spanish (MEX) (código interno es_MX).
  4. Header: None.
  5. Body: pegar el texto correspondiente respetando los {{1}} {{2}} {{3}} {{4}} (y {{5}} solo en nivel_alcanzado).
  6. Add sample: capturar los valores de muestra del bloque correspondiente arriba:
    • Para fidelidad_dia_*: María / FIDELIDAD15 / 15 / 19 jun.
    • Para nivel_alcanzado: María / GOLD / 15 / 19 jul / BIENVENIDA15.
  7. Footer: vacío.
  8. Buttons: ninguno.
  9. Submit for review.

Notas adicionales:

  • Aarón puede someter los 6 al mismo tiempo. Tiempos típicos de aprobación: 5 min a 24 h. Si Meta rechaza alguno por “promotional pricing vague” o similar, ajustar la frase rechazada sin tocar la estructura — ya pasó con ticket_holbox_v3 y aprobó a la 2da.
  • nivel_alcanzado puede ser el más sensible al review de Meta por usar “Felicidades” + “Logro desbloqueado” — vocabulario de gamification a veces marcan como inducción a compra. Si lo rechazan, alternativa: cambiar la primera línea a “Hola {{1}}, alcanzaste el nivel {{2}} en Holbox.” (más sobrio) y re-someter.

Cuando lleguen las 6 aprobaciones, mandar los nombres exactos para .env:

META_WHATSAPP_FIDELIDAD_DIA_03_TEMPLATE=fidelidad_dia_03
META_WHATSAPP_FIDELIDAD_DIA_07_TEMPLATE=fidelidad_dia_07
META_WHATSAPP_FIDELIDAD_DIA_15_TEMPLATE=fidelidad_dia_15
META_WHATSAPP_FIDELIDAD_DIA_25_TEMPLATE=fidelidad_dia_25
META_WHATSAPP_FIDELIDAD_DIA_30_TEMPLATE=fidelidad_dia_30
META_WHATSAPP_NIVEL_ALCANZADO_TEMPLATE=nivel_alcanzado
META_WHATSAPP_FIDELIDAD_TEMPLATE_LANG=es_MX

Plan técnico — addendum a PM-5 (no replicado completo aquí)

PM-5 cubre el plan para los 5 recordatorios. Lo nuevo del 6º template nivel_alcanzado:

  • Disparo: no es del scheduler diario. Se manda cuando Cliente cambia de nivel (mismo evento que dispara LoyaltyBreakpointMail). Encontrar el listener / observer que despacha el email y agregar despacho paralelo del job SendWhatsappNivelAlcanzado.
  • Job: App\Jobs\SendWhatsappNivelAlcanzado — recibe Cliente, NivelFidelidad, string $codigoStr. Mismas guardas que SendWhatsappFidelidad (opt-out, opt-in respect, feature flag, teléfono normalizable).
  • Sender: método nuevo sendNivelAlcanzado(Cliente $cliente, NivelFidelidad $nivel, string $codigoStr): void en el contract + Meta sender. Su propio buildPayloadNivelAlcanzado con 5 parameters.
  • Idempotencia: la generación del cupón de nivel ya es idempotente en el flujo email (se dispara una vez al cruzar el umbral). Reusar esa misma garantía — no agregar tracker JSON nuevo. Si el código se dispara dos veces por accidente, el log de WhatsApp lo refleja y Aarón se entera.
  • Tests Pest nuevos: 3-4 — dispatch al cruzar nivel, opt-out bloquea, feature flag apagado bloquea, cliente sin teléfono no recibe pero sí email.

2026-05-20 (PM-5) — Recordatorios de fidelidad WhatsApp: textos finales + checklist Aarón

Continuación de PM-4 del mismo día. Sergio cerró las 3 decisiones abiertas que faltaban y pidió dejar solo el diseño + textos listos para someter a Meta. No se tocó código en esta sesión.

Decisiones cerradas

#DecisiónResolución
1¿1 plantilla o 5?5 plantillas separadas (fidelidad_dia_03/07/15/25/30). Permite curva de urgencia distinta y edits independientes.
2¿Email vs WhatsApp?Paralelo, tracking separado. Email mantiene cobertura, WhatsApp es canal adicional. Nueva columna JSON recordatorios_whatsapp_enviados para que un canal no inhiba al otro.
3¿Categoría Meta?MARKETING. El cupón es incentivo promocional, no transaccional. Meta cobra por mensaje.
4¿Primer nombre?No requiere helper. Cliente::nombre ya guarda solo el primer nombre (el modelo separa nombre/apellido). Fallback "cliente" cuando esté vacío — Meta rechaza variables vacías.

Variables del template (las 4 son iguales para los 5 mensajes)

PosOrigenFormatoEjemplo
{{1}}$codigo->cliente->nombre (fallback "cliente")string libreMaría
{{2}}$codigo->codigostring mayúsculasFIDELIDAD15
{{3}}$codigo->porcentajeentero sin signo15
{{4}}$codigo->fecha_vencimiento->locale('es')->isoFormat('D MMM')fecha corta ES19 jun

Decisión: la fecha siempre va con formato D MMM (sin año, sin “de”). Meta penaliza inconsistencias entre samples y mensajes reales si los formatos varían entre días — la misma posición de variable debe verse parecida en los 5 templates.

Textos finales (copy-paste para Meta Business Manager)

Todos los 5 comparten la misma estructura (saludo + 3 líneas de datos + cierre), para que Meta los apruebe como una familia coherente. Diferencia entre días = solo el saludo y la línea de cierre.

fidelidad_dia_03 — categoría MARKETING, idioma Spanish (MEX) — es_MX:

Hola {{1}}, te recordamos tu beneficio en Holbox.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Vigente hasta: {{4}}

Puedes usarlo cuando gustes en cualquier sucursal o con tu asociada.

fidelidad_dia_07:

Hola {{1}}, tu descuento en Holbox sigue activo.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Vigente hasta: {{4}}

Si estabas pensando en otro modelo, este es un buen momento.

fidelidad_dia_15:

Hola {{1}}, aún tienes tu beneficio Holbox disponible.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Vigente hasta: {{4}}

Sigues a tiempo de aprovecharlo en tu próxima compra.

fidelidad_dia_25:

Hola {{1}}, te quedan pocos días para usar tu beneficio Holbox.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Vence: {{4}}

No lo dejes para el último día.

fidelidad_dia_30:

Hola {{1}}, hoy es el último día de tu beneficio Holbox.

🎁 Código: {{2}}
💸 Descuento: {{3}}%
📅 Vence: {{4}} (hoy)

Después de hoy se pierde. Te esperamos.

Samples para Meta (los pide al momento de someter cada template)

Meta exige un valor de ejemplo por cada variable. Usar los mismos en los 5 templates:

VariableSample value
{{1}}María
{{2}}FIDELIDAD15
{{3}}15
{{4}}19 jun

Checklist para Aarón en Meta Business Manager

Ruta: Meta Business Suite → WhatsApp Manager → Message templates → Create template. Repetir 5 veces (una por día).

Para cada uno de los 5:

  1. Category: Marketing
  2. Name: fidelidad_dia_03 (o _07, _15, _25, _30 según corresponda — exacto, en minúsculas, con guiones bajos)
  3. Languages: agregar una solaSpanish (MEX) (código interno es_MX)
  4. Header: dejar vacío (None)
  5. Body: pegar el texto del bloque correspondiente arriba, respetando los {{1}} {{2}} {{3}} {{4}} (no remplazar con valores)
  6. Add sample: pegar los 4 sample values de la tabla de arriba en el orden {{1}}=María, {{2}}=FIDELIDAD15, {{3}}=15, {{4}}=19 jun
  7. Footer: dejar vacío
  8. Buttons: ninguno (a diferencia de ticket_holbox_v3 que tiene botón URL — estos no llevan botón porque el cliente usa el código en sucursal, no online)
  9. Submit for review

Notas:

  • Si Meta rechaza alguno por “promotional pricing” o “vague urgency”: ya pasó con el ticket — Aarón puede ajustar pequeñas frases sin cambiar la estructura, re-someter, y suele aprobar en la 2da. Reportar el texto rechazado para iterar.
  • Categoría MARKETING obliga a que cada mensaje tenga costo y que el cliente pueda bloquear. Esto es lo que queremos: el opt-out (whatsapp_opt_out) ya implementado lo respeta como veto duro.
  • Tiempo de aprobación: típicamente 5 min a 24 h. Sometiendo los 5 al mismo tiempo se gana tiempo.

Cuando Aarón confirme aprobación, mandar los 5 template names exactos para .env:

META_WHATSAPP_FIDELIDAD_DIA_03_TEMPLATE=fidelidad_dia_03
META_WHATSAPP_FIDELIDAD_DIA_07_TEMPLATE=fidelidad_dia_07
META_WHATSAPP_FIDELIDAD_DIA_15_TEMPLATE=fidelidad_dia_15
META_WHATSAPP_FIDELIDAD_DIA_25_TEMPLATE=fidelidad_dia_25
META_WHATSAPP_FIDELIDAD_DIA_30_TEMPLATE=fidelidad_dia_30
META_WHATSAPP_FIDELIDAD_TEMPLATE_LANG=es_MX

Plan técnico (pendiente — se ataca al tener los template names aprobados)

Refresco del plan de PM-4 con las decisiones cerradas integradas:

  1. Migración: 2026_05_XX_add_recordatorios_whatsapp_enviados_to_codigos_descuento.php — columna JSON nullable. Paralela a recordatorios_enviados que ya existe. Reversible.
  2. Job: App\Jobs\SendWhatsappFidelidad implements ShouldQueue — constructor recibe CodigoDescuento + int $dia ∈ {3,7,15,25,30}. handle():
    • Early-return si cliente->whatsapp_opt_out (defensivo).
    • Early-return si config('holbox.whatsapp_respetar_opt_in') && ! $cliente->acepta_whatsapp.
    • Early-return si feature flag apagado.
    • Resuelve template name desde config (no hardcode — keyed por día).
    • Llama a un método nuevo en WhatsAppSender contract: sendFidelidad(CodigoDescuento $codigo, int $dia, string $templateName, array $parameters): void. O atajo: agregar parámetros opcionales a sendTicket y renombrar a send (más invasivo — preferible método nuevo).
  3. Sender (Meta cloud): método sendFidelidad análogo a sendTicket pero con su propio buildPayloadFidelidad que arma los 4 parameters del body sin botón URL.
  4. Comando artisan: extender descuentos:enviar-recordatorios (no crear segundo). Después del dispatch del email, segundo bloque que:
    • Filtra cliente.telefono IS NOT NULL (sin requerir email).
    • Filtra por flag acepta_whatsapp (o respeta el toggle global).
    • Filtra por NOT whatsapp_opt_out.
    • Idempotencia vía recordatorios_whatsapp_enviados JSON (no toca recordatorios_enviados que es del email).
    • Dispatch SendWhatsappFidelidad.
  5. Scheduler: ya en dailyAt('09:00') con el comando email — al extender queda gratis.
  6. Tests Pest nuevos (en archivo separado tests/Feature/EnviarRecordatoriosWhatsappTest.php — 5-7 tests):
    • Dispatch para los 5 días respeta su propio JSON tracker.
    • Opt-out bloquea sin tocar tracker email.
    • Feature flag apagado bloquea sin marcar tracker.
    • Cliente sin teléfono no recibe pero sí email.
    • Re-ejecución del comando no duplica (idempotencia).
    • Reverse: cliente sin email pero con teléfono SÍ recibe WhatsApp (caso que el email actual NO cubre).
  7. Riesgo cuidado: los 10 tests existentes de EnviarRecordatoriosDescuentosTest deben quedar verdes. El segundo bloque del comando no debe arrastrar excepciones del primero — try/catch defensivo o aislar en otro método llamado tras el del email.

Riesgos / consideraciones (sin cambio vs PM-4)

  • Costo Meta por mensaje: marketing tiene tarifa. Volumen estimado: clientes con cupón activo × 5 mensajes. Aarón debería ver el ticket de Meta antes de prender el feature flag.
  • Saturación del canal: un cliente con email + teléfono recibirá 5 emails + 5 WhatsApp + 1 ticket por compra. Si después se reporta saturación, opción de marcha atrás: reducir a 2 mensajes (días 7 y 25) — solo cambia el comando, los templates ya aprobados se quedan dormidos.
  • Reaprobaciones Meta: Aarón ya pasó por el ciclo con ticket_holbox_v3. Plan: someter los 5 al mismo tiempo para minimizar back-and-forth.

2026-05-20 (PM-4) — Diseño de recordatorios de fidelidad por WhatsApp (pendiente)

  • Pidió Sergio: anotar como pendiente mandar los mismos recordatorios de cupón de fidelidad que hoy salen por email (días 3/7/15/25/30 desde created_at del CodigoDescuento), pero por WhatsApp. Aprovechar la misma infra que ya quedó lista para el ticket (WhatsAppSender, Job, opt-out, opt-in, feature flag).
  • Estado: solo diseño y captura. Implementación queda para después de prender el feature flag global de WhatsApp (mismo prerequisito que el ticket — ver bitácora 2026-05-18).

Textos propuestos (5 mensajes, uno por día)

Tono más conversacional que el email, pero conservando las frases ancla de los textos literales que aprobó Aarón. Variables del template Meta:

  • {{1}} = primer nombre del cliente (helper Cliente::primerNombre() o Venta::nombreClienteCorto() que ya existe).
  • {{2}} = código del cupón (monospace en formato Meta no aplica; va en plano).
  • {{3}} = porcentaje de descuento sin signo (ej. 15).
  • {{4}} = fecha de vigencia en formato corto en español (ej. 19 jun).

Día 3 — slug tentativo fidelidad_dia_03 (categoría MARKETING en Meta):

Hola {{1}} 👋

Te recordamos que tienes un beneficio activo en Holbox.

🎁 Cupón: {{2}}
💸 Descuento: {{3}}%
📅 Vigente hasta el {{4}}

La mayoría de nuestros clientes lo aprovecha en la primera semana. Puedes usarlo cuando gustes.

Día 7 — fidelidad_dia_07:

Hola {{1}} 👉

Es buen momento para usar tu descuento en Holbox.

🎁 Cupón: {{2}}
💸 Descuento: {{3}}%
📅 Vigente hasta el {{4}}

Si estabas pensando en otro modelo, este es el momento. Tu cupón sigue activo.

Día 15 — fidelidad_dia_15:

Hola {{1}} 👋

Aún tienes tu beneficio Holbox disponible.

🎁 Cupón: {{2}}
💸 Descuento: {{3}}%
📅 Vigente hasta el {{4}}

Si todavía no lo has usado, sigues a tiempo de aprovecharlo en tu próxima compra.

Día 25 — fidelidad_dia_25:

Hola {{1}} ⏳

Te quedan pocos días para usar tu beneficio en Holbox.

🎁 Cupón: {{2}}
💸 Descuento: {{3}}%
📅 Vence el {{4}}

No lo dejes para después. Tu beneficio está por expirar.

Día 30 — fidelidad_dia_30:

Hola {{1}} ⚠️

Hoy es el último día para usar tu beneficio en Holbox.

🎁 Cupón: {{2}}
💸 Descuento: {{3}}%
📅 Vence hoy

Después de hoy se pierde definitivamente. Te esperamos.

Decisiones técnicas pendientes de confirmar con Sergio

  1. ¿5 plantillas o 1 sola con cuerpo variable?
    • Opción A — 5 plantillas independientes (recomendada): control fino del texto por día, Meta aprueba cada una una sola vez, y si Aarón quiere ajustar el copy de día 25 no tiene que re-someter las otras 4. Más burocracia inicial (5 templates a aprobar) pero más limpio operativamente.
    • Opción B — 1 plantilla con 5 variables y cuerpo “neutral”: una sola aprobación pero el cuerpo queda genérico, pierde la curva de urgencia (día 3 vs día 30 deberían sentirse distintos) y Meta puede objetar si detecta que la variable cambia mucho el sentido del mensaje.
  2. ¿Email + WhatsApp en paralelo o un canal a la vez?
    • Probablemente paralelo (algunos clientes solo dan email, otros solo teléfono). Pero requiere bandera nueva por cupón para no duplicar tracking — propuesto schema abajo.
  3. ¿Categoría Meta MARKETING o UTILITY? MARKETING porque el cupón es un incentivo promocional. Implica que Meta cobra por mensaje y que el cliente puede bloquearlo. UTILITY queda fuera porque no es transaccional puro (no responde a una acción del cliente).
  4. ¿Quién es el primer nombre del cliente? El Cliente model tiene nombre (string completo). Si el cliente registró “María Guadalupe”, el saludo “Hola María Guadalupe 👋” se siente formal. Helper primerNombre() que parta por espacio y tome el primer token. Si nombre está vacío, fallback “Hola 👋”.

Implementación (alto nivel, cuando se desbloquee)

  • Schema: columna recordatorios_whatsapp_enviados (JSON nullable) en codigos_descuento, paralela a recordatorios_enviados para que email y WhatsApp se rastreen por separado y un canal no inhiba al otro.
  • Job: SendWhatsappFidelidad análogo a SendWhatsappTicket — recibe CodigoDescuento + int $dia, resuelve template name (fidelidad_dia_{$dia:02d}), arma parameters con [primerNombre, codigo, porcentaje, vigenciaCorta], delega a WhatsAppSender::send(). Early-return si cliente->whatsapp_opt_out (defensivo) o si ! cliente->acepta_whatsapp (respetando el toggle global whatsapp_respetar_opt_in).
  • Comando artisan: o extender descuentos:enviar-recordatorios con un loop adicional para WhatsApp, o crear descuentos:enviar-recordatorios-whatsapp separado. Inclino a extender el existente (1 query, 2 dispatches) — pero el comando ya tiene 10 tests, así que extender con cuidado para no romperlos.
  • Filtro de elegibilidad: cliente.telefono IS NOT NULL (sin filtro de email, que es el filtro del path actual).
  • Idempotencia: mismo patrón que email — recordatorios_whatsapp_enviados JSON con [3, 7] etc.
  • Scheduler: mismo dailyAt('09:00') del comando email; si se extiende el comando existente queda gratis.
  • Tests Pest nuevos: análogos a los 10 de EnviarRecordatoriosDescuentosTest, pero verificando dispatch del job WhatsApp y que opt_out bloquea sin tocar recordatorios_enviados del email.

Riesgos / consideraciones de negocio

  • Costo Meta por mensaje: plantillas MARKETING tienen tarifa por conversación. Hoy Aarón paga con tarjeta — confirmar volumen estimado antes de prender (clientes con cupón activo × 5 mensajes c/u puede inflar el ticket de Meta).
  • Saturación del canal: un cliente que ya recibió el email + ticket por WhatsApp + ahora 5 recordatorios por WhatsApp puede percibirlo invasivo. Considerar enviar WhatsApp solo en días 7 y 25 (no los 5), y dejar email para los 5. Pendiente de decisión con Aarón.
  • Reaprobaciones Meta: Aarón ya pasó por el ciclo con ticket_holbox_v3 (2 re-sometidas hasta que aprobó). Plan: someter los 5 templates juntos en un solo batch para minimizar el back-and-forth.

2026-05-20 (PM-3) — Fix del URL roto en el template de Meta

  • Reportó Sergio: las pruebas en producción vía /whatsapp/preview mandaban el URL del ticket así: https://holbox.val-soft.com/t/%7B%7B1%7D%7DT216W9TnTVXG3sBrJS0OCtkirvKwO4y2XUNMwSVU — el %7B%7B1%7D%7D (URL-encoded {{1}}) salía literal antes del token.
  • Diagnóstico: bug en la configuración del botón URL del template ticket_holbox_v3 en Meta Business Manager. El botón estaba como “Static URL” con https://holbox.val-soft.com/t/{{1}} como Website URL — Meta no reemplazaba el {{1}} (porque static no interpreta variables) y appendaba el text parameter del sender al final, generando t/{{1}}<token>.
  • Fix correcto: cambiar el botón a “Dynamic URL” en Meta Business Manager con Website URL base https://holbox.val-soft.com/t/ (sin {{1}}) + sample. Meta agrega {{1}} automáticamente como sufijo dinámico que sí se reemplaza por el parameter del envío.
  • No requirió cambio de códigoMetaCloudWhatsAppSender::buildPayload ya mandaba el parameter correcto. El bug era 100% de configuración del template.
  • Estado: Sergio confirmó “ya funcionó” — Aarón ajustó el template, Meta re-aprobó, y la prueba via /whatsapp/preview llega con URL limpio.
  • Verificado en prod (SSH read-only): WHATSAPP_FEATURE_ENABLED=false sigue apagado mientras se valida; los demás vars (META_WHATSAPP_PHONE_NUMBER_ID, META_WHATSAPP_TOKEN, META_WHATSAPP_TICKET_TEMPLATE=ticket_holbox_v3, META_WHATSAPP_TICKET_TEMPLATE_LANG=es_MX) están todos en su lugar. Siguiente paso queda Sergio + Aarón: prender el flag con php8.3-fpm reload cuando se sienta listo.

2026-05-20 (PM-2) — Banner ámbar para opt-in de clientes existentes

  • Pidió Sergio: después de implementar opt-out, preguntó cómo se hace opt-in para los clientes que ya existen en BD (todos con acepta_whatsapp=false por default) y que regresan a comprar. Hoy el único camino era que la asociada abriera el modal “Editar cliente” y marcara el checkbox — pasivo y olvidable.
  • Diseño elegido (de 3 opciones presentadas): banner inline en la banda del cliente seleccionado.
  • Lógica del banner (computed mostrarBannerOptInWhatsApp): visible cuando:
    • whatsappFeatureEnabled (el feature global está prendido)
    • cliente.telefono no vacío
    • !cliente.acepta_whatsapp
    • !cliente.whatsapp_opt_out Cuando el cliente se suscribe o se da de baja, el banner desaparece automáticamente.
  • Endpoint: POST /pos/cliente/{cliente}/whatsapp/opt-in (PosController::whatsappOptIn, route name pos.cliente.whatsapp.optIn). Dentro del grupo kiosk. Devuelve 404 si feature flag apagado. Si el cliente tiene whatsapp_opt_out=true, ignora el opt-in (la baja explícita es veto duro — se debe llamar primero a whatsappAlta para reactivar). Log estructurado cliente.whatsapp_opt_in con origen='pos_banner'.
  • UI: mini-CTA ámbar en la banda, debajo del nombre/nivel/acumulado: ícono mini de chat + texto “Aún no recibe tickets por WhatsApp” + botón “Activar” en ámbar más oscuro. window.confirm("¿El cliente acepta recibir su ticket por WhatsApp?") antes de POST. Loading local con opOptInLoading. Actualiza in-place clienteSeleccionado + router.reload({ only: ['clientes'] }) para que el banner desaparezca sin recargar la página.
  • Detalle de implementación: tuve que importar usePage de @inertiajs/vue3 porque el computed accede a pageProps.whatsappFeatureEnabled y $page solo existe en templates, no en script. El resto de los usos en este archivo seguía usando $page.props.x en v-if del template, así que no había usePage importado antes.
  • Tests Pest nuevos (en el mismo WhatsappOptOutTest.php, ahora 10/10 verdes):
    • Asociada activa opt-in para un cliente que no estaba suscrito
    • Opt-in inline NO sobrescribe un opt-out activo (veto duro respetado)
    • Endpoint opt-in devuelve 404 cuando el feature está apagado
  • Estado: commit 64d0b2c, pusheado, deploy GHA en curso (26187571082).

2026-05-20 (PM) — Baja de WhatsApp (opt-out) accesible a la asociada

  • Pidió Sergio: complementar el Aviso de Privacidad con un mecanismo real de baja, dado que el número de WhatsApp Business no recibe mensajes (“responder BAJA” no funciona). La baja debe poder activarse desde el POS por la asociada cuando un cliente lo pide en sucursal, sin abrir el menú de Clientes (que está limitado a admin/gerente).
  • Diseño elegido: campo nuevo whatsapp_opt_out separado de acepta_whatsapp. La razón: acepta_whatsapp es opt-IN bypaseable por el toggle global whatsapp_respetar_opt_in=0, mientras que el opt-out solicitado por el cliente debe ser un veto absoluto que se respeta siempre. Si solo apagara acepta_whatsapp, el toggle global de configuración podría reenviarle mensajes al cliente sin querer.
  • Schema (migración 2026_05_20_180000_add_whatsapp_opt_out_to_clientes_table.php, ya corrida en local):
    • whatsapp_opt_out boolean default false
    • whatsapp_opt_out_at timestamp nullable (auditoría)
    • whatsapp_opt_out_origen string(16) nullable, valores pos o admin
  • Endpoints (POS, accesibles a la asociada vía middleware kiosk):
    • POST /pos/cliente/{cliente}/whatsapp/bajaPosController::whatsappBaja — pone flag en true, registra now() y origen pos, log cliente.whatsapp_opt_out.baja.
    • POST /pos/cliente/{cliente}/whatsapp/altaPosController::whatsappAlta — invierte (para corregir bajas hechas por error), limpia las columnas de auditoría, log paralelo.
  • UI en el POS:
    • Banda de cliente seleccionado (POS/Index.vue cabecera): badge rojo “Sin WhatsApp” al lado del nombre cuando whatsapp_opt_out=true. Solo informativo, siempre visible al elegir cliente.
    • Modal “Editar cliente” (el que abre el icono lápiz): bloque rojo nuevo con texto explicativo (“veto absoluto, no se enviarán mensajes mientras esté activa”), estado actual (incluye fecha de baja si la hay) y botón único: “Dar de baja de WhatsApp” (rojo) o “Reactivar WhatsApp” (verde) según corresponda. window.confirm antes de pegarle al endpoint. Loading spinner local con opOptOutLoading. Actualiza in-place tanto clienteEditando como clienteSeleccionado para feedback inmediato sin recargar.
  • UI en /clientes/{id}/edit (admin/gerente): mismo bloque rojo después del checkbox de “Acepta recibir su ticket por WhatsApp”. ClienteController::update valida whatsapp_opt_out y, si el flag cambió de estado, escribe whatsapp_opt_out_at/whatsapp_opt_out_origen con origen admin.
  • Guardas en el sender: dos capas defensivas, ambas con log informativo cuando bloquean:
    1. PosController::procesar ahora chequea ! $cliente->whatsapp_opt_out antes de despachar SendWhatsappTicket — evita encolar trabajo inútil.
    2. SendWhatsappTicket::handle early-return si cliente?->whatsapp_opt_out — defensivo en caso de que el flag cambie entre dispatch y ejecución del worker.
  • Gating completo por whatsappFeatureEnabled (revisión post-implementación 2026-05-20): toda la UI relacionada a WhatsApp queda invisible mientras el feature flag esté apagado — bloque opt-out admin, bloque opt-out POS, badge “Sin WhatsApp” en banda principal del POS, checkbox acepta_whatsapp (admin + POS create + POS edit), y el sufijo “(WhatsApp)” del label de Teléfono en los forms del POS. Endpoints pos.cliente.whatsapp.{baja,alta} devuelven 404 si el feature está apagado para evitar invocación directa por curl. Test endpoints de baja/alta devuelven 404 cuando el feature está apagado valida el guard.
  • Tests Pest nuevos (tests/Feature/WhatsappOptOutTest.php — 7/7 verdes, 20 assertions):
    • opt_out bloquea dispatch incluso con acepta_whatsapp=true
    • opt_out es veto absoluto aun con whatsapp_respetar_opt_in=0
    • asociada da/quita baja vía endpoint POS (estado + auditoría)
    • admin marca opt_out vía PUT /clientes/{id} y queda origen admin
    • endpoints baja/alta devuelven 404 cuando el feature está apagado
    • job handle no llama al sender (Http::assertNothingSent) cuando hay opt_out
  • Aviso de Privacidad actualizado en el mismo día: correo cambiado a info@holbox.store (era placeholder contacto@holbox.com.mx), sección 4 reescrita — ya no promete “responder BAJA al chat” sino que indica que el número es de salida únicamente y la baja se tramita por correo. Cambio incluido en commit del aviso (separado del opt-out).
  • Suite completa: Whatsapp filter → 19/19 verdes. Build Sail → 1.20s, POS/Index ahora 288.22 kB (+8 kB por el bloque rojo + handlers JS).
  • Estado: dos commits locales listos, ninguno empujado todavía. Esperando autorización para git push origin main (1 push, 2 commits → 1 deploy GHA).

2026-05-20 — Aviso de Privacidad público para Meta WhatsApp Business

  • Pidió Sergio: Meta le pide URL de Privacy Policy para publicar la app de WhatsApp Business; ya tiene token + phone number ID + método de pago + mensaje de prueba funcionando.
  • Solución: página Blade pública autónoma (sin Inertia/Vue/auth — más rápido para revisión de Meta y sin dependencia de build).
    • Vista: resources/views/legal/privacy.blade.php — HTML+CSS embebido, dark mode vía prefers-color-scheme, sin assets externos.
    • Ruta: Route::view('/aviso-de-privacidad', 'legal.privacy')->name('legal.privacy'); con redirects 301 desde /privacy y /privacy-policy para máxima compatibilidad con lo que Meta espera.
    • URL final que entregar a Meta: https://holbox.val-soft.com/aviso-de-privacidad (también https://holbox.val-soft.com/privacy).
  • Contenido del aviso (10 secciones, ajustado a LFPDPPP mexicana):
    1. Identidad y domicilio del responsable
    2. Datos personales recabados (incluye optometría)
    3. Finalidades primarias/secundarias
    4. Uso de WhatsApp Business (sección clave para Meta) — explica que el número viaja a Meta solo para mensajería transaccional/promocional con opt-out por respuesta “BAJA”
    5. Transferencias de datos (Meta + proveedor de infra)
    6. Derechos ARCO con dirección de contacto
    7. Revocación del consentimiento
    8. Conservación de datos
    9. Medidas de seguridad
    10. Modificaciones al aviso
    • Fecha de “última actualización” se renderiza dinámicamente con Carbon::now()->locale('es')->isoFormat('LL').
  • Placeholders que Sergio puede ajustar después (no bloquean publicación inicial — Meta acepta lo actual):
    • Nombre del responsable: hoy dice solo “Holbox”. Si Aarón quiere agregar razón social/RFC, editar sección 1.
    • Email de contacto: hoy dice contacto@holbox.com.mx. Cambiar si usan otro buzón (aparece en secciones 1, 4, 6, 7).
    • Domicilio: hoy dice “Ciudad Juárez, Chihuahua, México” (genérico). Si quiere domicilio fiscal específico, editar sección 1.
  • Validación: route:list muestra la ruta, view()->render() produce 10,779 bytes sin error.
  • Estado: local, no commiteado todavía — esperando autorización de Sergio para commit + push + deploy. Después del deploy (GHA auto-trigger en main) el URL queda activo y se entrega a Meta tal cual en la sección “Privacy Policy URL” de la app config.

2026-05-19 (PM-5) — Fix: directorio de clientes mostraba “USD”

  • Pidió Sergio: el listado de Clientes mostraba $X.XX USD en la columna de monto acumulado, cuando todo el sistema opera en pesos. Audit de toda la app + dejar USD solo en el POS para distinguir cobros en dólares; el resto sin etiqueta de moneda.
  • Auditoría:
    • Único bug: resources/js/Pages/Clientes/Index.vue:178 — formateaba monto_acumulado con toLocaleString('en-US', …) y concatenaba " USD" hardcoded.
    • Resto del frontend: ya usa Intl.NumberFormat('es-MX', { currency: 'MXN' }) que renderiza solo $ (Dashboard, Reportes/Ventas, Reportes/Semanal*, Categorias, Productos, Ventas/Show, POS/CorteVoucher, BixolonPrintService, Leaderboard).
    • Usos intencionales de “USD” preservados: POS/Index.vue líneas 795/1360/1383/1399/1408 (conversión de referencia ”~ USD $X” al lado del total, label “USD$” del input de cobro en dólares, conversiones de faltante y cambio); Configuracion/Index.vue:126 (label “Tipo de Cambio (USD a MXN)” del setting de FX rate).
    • Backend: cero referencias a USD/en-US/money_format.
  • Cambio aplicado: helper local formatCurrency en Clientes/Index.vue con el patrón estándar es-MX/MXN y reemplazo del template a {{ formatCurrency(cliente.monto_acumulado) }}.
  • Validación: Pint pass (sin cambios), build Sail 3.79s, no rompe tests (no hay tests sobre formato de moneda).
  • Estado: deployado a prod 2026-05-19. Commit 6bb4fb0, GHA run 26132413697 (success).

2026-05-19 (PM-4) — Reporte de ventas oculta canceladas por default

  • Pidió Sergio: dio de alta por error la venta VTA4-000247 en producción (sucursal 4, 1 producto id=3, total $2,100, pago tarjeta). Pidió “borrarla y actualizar inventarios”, y después de aclarar el flujo de cancelación pidió además que la cancelada no se mostrara en la pantalla del reporte de ventas.
  • Acción de Sergio en prod: él mismo cliqueó “Cancelar” en la UI admin. Verificado vía SSH read-only que la venta quedó estado='cancelada' (canceló a las 23:12 UTC, sin cliente ni cupón asociados; el endpoint VentaController::cancelar ya repuso +1 en inventarios para producto_id=3 / sucursal_id=4).
  • Cambio de código (local, no commiteado):
    • app/Http/Controllers/Reportes/VentasController.php: cuando el filtro estado viene vacío, se aplica where('estado', '!=', 'cancelada'). Esto resuelve la inconsistencia previa donde $totalVentas sumaba canceladas pero cantidad_completadas no. Ahora el resumen y la lista cuadran.
    • resources/js/Pages/Reportes/Ventas.vue: el option vacío del dropdown de estado pasa de “Todos los Estados” → “Activas (sin canceladas)” para que el texto sea honesto sobre lo que filtra el default. “Completada” y “Cancelada” siguen disponibles como filtros explícitos.
    • tests/Feature/VentasOcultarCanceladasTest.php: 2 tests nuevos — (a) sin filtro de estado, una venta cancelada y una completada conviven en la BD pero solo aparece la completada en ventas.data + resumen.total y cantidad_completadas reflejan solo la completada; (b) con ?estado=cancelada solo aparece la cancelada y el sum es el de cancelada.
  • Decisión de scope: solo Reportes/Ventas. Los demás reportes (semanal categorías/empleadas, comisiones, avanzados, diario) ya filtran estado=completada por su lado, así que no estaban contaminados por la venta cancelada. El reporte de Ventas era el único que las mezclaba.
  • Validación: pest --filter="VentasOcultarCanceladas|VentasOptometriaResumen" → 5/5 verdes (64 assertions). Pint pass. Build Sail 2.87s sin cambios de tamaño relevantes.
  • Estado: deployado a prod 2026-05-19. Commit f67bcab, GHA run 26131224699 (success).
  • Borrado físico de VTA4-000247 (operación BD pura, sin cambio de código): Sergio pidió simular que la venta nunca existió. Validaciones previas en prod via tinker — id=525, sucursal 4, 0 ventas posteriores por id ni por fecha, 0 cambios, 0 garantías, único corte de caja del día (id=60) fue 6 horas antes de la venta así que no la incluyó. DELETE en transacción con lockForUpdate y guard final (if post > 0 abort); 1 detalle + 1 venta borrados. Tras la operación, MAX(folio) en suc 4 vuelve a VTA4-000246, así que la próxima venta tomará VTA4-000247. Inventario sigue correcto porque la cancelación previa ya había restituido +1 al producto_id=3 antes del DELETE. No quedó rastro en movimientos_inventario (ese flujo nunca lo registra para ventas).

2026-05-19 (PM-3) — Fix: descuadre reportes por descuento de cupón

  • Pidió Sergio: el admin (Aarón) preguntó por qué el total del día de ayer (2026-05-18, sucursal Misiones III) decía $10,169 en una pantalla y $10,091.50 en otra. Diferencia: $77.50.
  • Diagnóstico: una sola venta descontada ese día — VTA4-000237 (Jocelyn Cordero, sucursal 4): subtotal $1,550, descuento $77.50, total $1,472.50. Tres reportes del cliente sumaban cosas distintas:
    • Reporte de Ventas por Categoría (Semanal) (ReporteController::semanalCategorias): usaba SUM(venta_detalles.subtotal) → $10,169 (ignora descuento).
    • Reporte de Comisiones (ReporteController::comisiones): usaba $user->ventas->sum('total') → $10,091.50 (con descuento).
    • Reporte de Ventas por Empleada (Semanal) (ReporteController::semanalEmpleadas): usaba $ventas->sum('total') → $10,091.50.
    • El Reporte Avanzado / Desempeño Equipo (AvanzadosController) tenía el mismo problema que semanalCategorias (SUM(venta_detalles.subtotal) as ventas_total), aunque el admin no lo estaba mirando ahora.
  • Decisión (Sergio): opción 2 — agregar columnas Subtotal + Descuentos + Total Recaudado en ambos reportes problemáticos. Mantiene transparencia financiera (se ve dónde se descontó) y el Total Recaudado cuadra exactamente con SUM(ventas.total) que usan Comisiones y Ventas por Empleada.
  • Algoritmo del prorrateo: el descuento global de cada venta se reparte entre sus líneas en proporción a su peso en el subtotal:
    descuento_linea = subtotal_linea × (venta.descuento / venta.subtotal)
    Si venta.subtotal == 0 (caso degenerado, venta solo de regalos), no aporta descuento. En SQL se protege con NULLIF(ventas.subtotal, 0).
  • Archivos tocados (commit 8e706a9, pusheado a origin/main, deployado a prod):
    • app/Http/Controllers/ReporteController.php — método semanalCategorias. Cada línea ahora carga venta:id,subtotal,descuento,total; el cálculo va en PHP vía closure $descuentoLinea + helper $reduceCategoria para no repetir lógica entre las categorías normales y “Sin Categoría”. Cada fila trae unidades, subtotal, descuento, total.
    • app/Http/Controllers/Reportes/AvanzadosController.php — el select por empleada renombra ventas_totalventas_subtotal, agrega descuentos_aplicados (vía COALESCE(SUM(subtotal * descuento / NULLIF(subtotal, 0)), 0) en SQL), y reconstruye ventas_total en PHP como ventas_subtotal - descuentos_aplicados. Así ventas_total mantiene la misma key del JSON (sort sigue funcionando) pero ahora contiene el NETO consistente con el resto del sistema.
    • resources/js/Pages/Reportes/SemanalCategorias.vue — tabla pasa de 4 columnas a 6: Categoría | Unidades | Subtotal | Descuentos | Total Recaudado | Participación. Descuentos en rojo cuando > 0, en gris cuando 0. SortKeys nuevos (subtotal, descuento) agregados al filtro numérico del computed.
    • resources/js/Pages/Reportes/Avanzados.vue — tab Desempeño Equipo agrega dos columnas (Subtotal, Descuentos) antes de Venta Total. Misma convención de color para descuentos.
    • tests/Feature/ReporteDescuentoProrrateoTest.php — 3 tests: (a) replica exacto el escenario del admin (venta $1,550 - desc $77.50 distribuido entre dos categorías: Premium $49.95 + Clásico $27.55 = $77.50) y valida que la suma del reporte cuadre con SUM(ventas.total); (b) avanzado expone los 3 campos correctos; (c) sin descuento, subtotal y total coinciden.
  • Validación adicional: corrí la lógica nueva contra prod via SSH read-only para la semana 18-24 may sucursal 4: subtotal $18,249, descuento prorrateado $77.50 (47.50 a Premium + 30.00 a Clásico), total neto $18,171.50. Cuadra perfecto.
  • Deploy: GHA 26122121308-equivalente (último run de gh run list) — completed/success. Cambios visibles en prod inmediatamente.
  • Pendiente preexistente no relacionado: CajaCorteTest > gerente puede ver reporte de cortes sigue rojo en baseline (no causado por este fix).

2026-05-19 (PM-2) — Vista “mis ventas del día” para asociadas

  • Pidió Sergio: seguir con el siguiente pendiente, que era el #2 del 2026-05-18 (asociadas viendo su historial del día + totales por categoría desde el POS).
  • Diseño aplicado:
    • Endpoint: GET /pos/mis-ventas-hoy dentro del grupo kiosk (mismo middleware que el resto de /pos/*), nombre de ruta pos.misVentasHoy. Filtra por auth()->id() siempre — así el endpoint no necesita filtro per-rol, cada usuario solo ve lo suyo (admin que use el POS también lo puede invocar y verá sus propias ventas si las tiene).
    • Estado: solo completada (igual que LeaderboardController); excluir canceladas evita confusión cuando la asociada cobre algo y lo cancele.
    • TZ: usa LocalDateRange::singleDay(LocalDateRange::todayYmd()) para que “hoy” sea el día calendario en TZ de negocio (config('app.report_timezone') → Cd. Juárez), no UTC. Esto fue explícito porque sin la TZ correcta una venta de 23:30 local quedaría afuera del día visto por la asociada.
    • Categorías: agregado vía VentaDetalle join productos left join categorias, GROUP BY categoria_id, categoria_nombre, orden DESC por unidades. leftJoin para no perder productos sin categoría (los mapeo a “Sin categoría”). Cantidades por unidad vendida (suma de venta_detalles.cantidad), no por línea — el pendiente decía “unidades vendidas por categoría”.
    • UI: botón “Mis ventas” en verde esmeralda en la cabecera del POS junto al botón “Corte” (visualmente paralelo, no flotante para no competir con el Leaderboard widget que ya ocupa bottom-left). Modal estilo abrirHistorial con header gradient emerald/primary, 3 cards (ventas/total/unidades), lista compacta de ventas (1 línea por venta sin expansión), y un bloque al pie con chips redondeados de “Categoría · N”.
  • Archivos tocados (no commiteado todavía):
    • app/Http/Controllers/PosController.php — nuevo método misVentasHoy() después de historialCliente. Usa withCount('detalles') para evitar lazy-load del array de detalles cuando solo necesito contar líneas en la lista.
    • routes/web.php — ruta pos.misVentasHoy dentro del grupo kiosk.
    • resources/js/Pages/POS/Index.vue — 6 refs (mostrarMisVentas, misVentasCargando, misVentasError, misVentas, misVentasUnidadesPorCategoria, misVentasTotales), 2 funciones (abrirMisVentas, cerrarMisVentas), botón en cabecera, modal completo con 3 cards + lista + chips.
    • tests/Feature/PosMisVentasHoyTest.php — 2 tests: (a) la asociada A solo ve su venta de hoy (no la de B, no la de ayer, no la cancelada), confirma orden de chips (Lentes 2 > Solares 1); (b) asociada sin ventas → totales en cero + arrays vacíos.
  • Decisión sobre rol: el botón “Mis ventas” se renderiza dentro del bloque v-if="$page.props.auth.user.rol === 'asociada' || true" que ya existía para el botón de Corte. La condición || true lo hace visible a todos los roles — admin que use el POS también lo puede consultar.
  • Validación: suite local PosMisVentasHoyTest 2/2 verde (17 assertions). Pint pass. Build via Sail 3.00s, Index-C5FWJEID.js ahora 280.19 kB (subió ~23 kB por el modal).
  • Estado: local, no commiteado.

2026-05-19 (PM) — Resumen de optometría también en el reporte de ventas clásico

  • Pidió Sergio: “que en el reporte normal de ventas también haya manera de consultar cantidades y totales de optometría”. Mi decisión inicial había sido dejarlo solo en el avanzado para no desbalancear el clásico; con el feedback se extiende.
  • Diseño aplicado: 4ª KPI card “Optometría” en la grid de Estadísticas Rápidas (junto a Total Ingresos / Ventas Completadas / Ticket Promedio). Grid pasa de lg:grid-cols-3 a lg:grid-cols-4. Card en ámbar (consistente con el badge del avanzado) usando EyeDropperIcon, mostrando monto grande arriba ($) y “N línea(s)” en gris debajo.
  • Decisión de scope: el resumen respeta todos los filtros activos (fechas, sucursal, asociada, estado, método de pago, búsqueda de producto, categoría) — porque el resumen va al lado de la lista filtrada, los números tienen que cuadrar con lo que el usuario ve. La asociada filter check tiene su propio test.
  • Archivos tocados (no commiteado todavía):
    • app/Http/Controllers/Reportes/VentasController.php — agregado cantidad_optometrias y monto_optometrias al array resumen del Inertia render. Subquery: VentaDetalle::whereIn('venta_id', (clone $query)->select('ventas.id'))->whereNotNull('tipo_optometria')->selectRaw('COUNT(*) as lineas, COALESCE(SUM(precio_optometria * cantidad), 0) as monto')->first().
    • resources/js/Pages/Reportes/Ventas.vue — import de EyeDropperIcon, grid a lg:grid-cols-4, nueva card con formatCurrency(resumen.monto_optometrias) + texto auxiliar “N línea(s)”. Pluralización inline ('línea' : 'líneas').
    • tests/Feature/VentasOptometriaResumenTest.php — nuevo. 3 tests: (a) venta con 2 unidades × $200 add-on + línea sin opto → cantidad_optometrias=1, monto_optometrias=400; (b) filtro ?asociada_id=A cuando hay 2 asociadas con opto distinta → solo cuenta la de A; (c) ningún add-on → 0/0.
  • Detour: bug encontrado en el orden del controller. Mi primera versión clonaba $query después de $query->orderBy(...)->paginate(50). Resultado: MySQL 1235 LIMIT & IN/ALL/ANY/SOME subquery porque paginate aplica limit/offset al builder y ese builder con limit se metió como subquery en el whereIn. Fix: muevo TODOS los cálculos del resumen (incluyendo el ya existente $totalVentas = $query->sum('total'), que sobrevivía solo porque aggregate() clona y limpia limit internamente) ANTES de la línea de paginate, y todos vía (clone $query). Más defensivo. Solo afectaba al SQL del subquery — el suite vecino antes del cambio nunca lo cubrió porque no había tests del controller de ventas. Este es el primer test de VentasController en el repo.
  • Sanity check: pest --filter=VentasOptometriaResumenTest 3/3, pest --filter=AvanzadosOptometriaMontoTest 2/2. Pint pass tras un fix automático (not_operator_with_successor_space en una línea preexistente, separado). Build Sail 2.69s.
  • Estado: local, no commiteado.

2026-05-19 — Monto facturado por optometría en reporte avanzado

  • Pidió Sergio: avanzar con los pendientes de holbox abiertos. Yo elegí arrancar con el #2 (monto de optometría) por ser el cambio más acotado de los dos accionables del 2026-05-18.
  • Diseño aplicado: una sola columna existente “Optometría” en la tab “Desempeño Equipo” pasa a mostrar dos líneas: badge ámbar con conteo de líneas con add-on (igual que antes) + monto facturado en gris debajo. La razón de no agregar columna nueva fue preservar el sort por optometrias (conteo) que ya estaba, sin ensanchar la tabla en pantallas chicas. Si después piden ordenar por monto, se agrega columna.
  • Decisión scope: solo Reportes/Avanzados, no Reportes/Ventas. El reporte clásico de ventas no tiene desglose por empleada/optometría y meterlo desbalancearía esa tabla. El pendiente original dejaba abierta esa decisión.
  • Archivos tocados (no commiteado todavía):
    • app/Http/Controllers/Reportes/AvanzadosController.php — un DB::raw adicional al array $selects de la query por empleada: SUM(CASE WHEN venta_detalles.tipo_optometria IS NOT NULL THEN venta_detalles.precio_optometria * venta_detalles.cantidad ELSE 0 END) as monto_optometrias. Cast (float) en el map. El CASE es redundante (precio_optometria default 0 cuando tipo_optometria es null), pero mantiene paralelismo visual con el conteo y evita supuestos sobre data legacy.
    • resources/js/Pages/Reportes/Avanzados.vue — la celda de optometría ahora es <div flex-col items-center gap-1> con el badge de conteo arriba y formatCurrency(emp.monto_optometrias) en text-xs text-gray-500 dark:text-gray-400 debajo. v-if="monto_optometrias > 0" oculta el monto cuando es cero para no ensuciar las filas de asociadas sin optometría. Dark mode validado (text-gray-400 en dark, text-gray-500 en light).
    • tests/Feature/AvanzadosOptometriaMontoTest.php — nuevo. 2 tests: (a) venta con 2 unidades × $200 add-on en una línea + otra línea sin optometría → optometrias=1, monto_optometrias=400; (b) venta sin add-ons → optometrias=0, monto_optometrias=0.
  • Detalle de tests: el assert de monto_optometrias usa int (400, 0) en vez de float (400.0, 0.0) porque json_encode serializa floats sin decimales como ints y el assertInertia ->where hace strict identity check (===). El cast (float) en el controller sigue siendo correcto — solo es cómo se ve después del round-trip JSON.
  • Detour: Pint reformateó concat + comillas en el controller (cambios cosméticos no relacionados con mi edición). Mantenido.
  • Sanity check del suite vecino: pest --filter="Reporte|Avanzados" → 7/8 verdes. La falla es CajaCorteTest > gerente puede ver reporte de cortes… y se reproduce idéntica en baseline (git stash + correr); es preexistente, no causada por este cambio.
  • Build: local con npm run build falla por Node 18 host (crypto.hash is not a function en vite — bug conocido de versión de Node, no de mi código). Via Sail (Node moderno) build verde, 2.55s. Es el mismo patrón en otros proyectos del hub.
  • Estado: local, no commiteado. Cuando Sergio autorice push, este cambio se va junto con WhatsApp/leaderboard/UI reglas/buscador de clientes (varios “esperando autorización” se acumulan en este branch).

2026-05-18 (PM-6) — Meta aprobó ticket_holbox_v3 (re-submisión idéntica al v2)

  • Reporta Sergio: “Tuvimos que subir otro template llamado ticket_holbox_v3. Ya está aprobado, ¿hay que cambiar algo?” — confirmado que el cuerpo y las 3 variables son idénticos al v2 (fue re-submisión, no cambio de contenido).
  • Cambio aplicado (commit d35174f, deployado vía GHA run 26068398056): solo bump del slug en 4 sitios.
  • Tests: suite WhatsApp 22/22 verde sin cambios (los tests que assertan slug usan ticket_link_v1 hard-coded en su propio config(['...' => 'ticket_link_v1']), así que son independientes del default).
  • Estado en prod: código deployado. El template ticket_holbox_v3 está aprobado en Meta, así que ya es la realidad operativa. Falta solo el switch del .env para activar el envío real (ver “Pendiente para activar en prod” arriba).

2026-05-18 (PM-5) — Saludo personalizado en el template (nombre del cliente)

  • Pidió Aarón: incluir el nombre del cliente en el mensaje. Sergio escogió la Opción A — saludo al inicio: "✨ Hola, María. Gracias por elegir HOLBOX.", con fallback genérico "cliente" cuando no haya nombre.
  • Decisión clave: usar cliente->nombre (no nombre + apellido) porque el modelo ya separa los dos campos y “Hola, María” se siente más cálido que “Hola, María González”. cliente como fallback porque Meta rechaza variables vacías.
  • Archivos tocados (commit c76b96e, deployado a prod via GHA run 26068094831):
    • app/Models/Venta.php — nuevo método nombreClienteCorto(): trim() del nombre, fallback 'cliente' si la venta no tiene cliente asociado o el campo es vacío/whitespace.
    • app/WhatsApp/MetaCloudWhatsAppSender.phpbuildPayload() agrega el nombre como parameters[0] del componente body; el orden total ahora es [nombre, modelo, folio]. loadMissing agrega 'cliente' para garantizar la relación cargada.
    • app/Http/Controllers/WhatsAppPreviewController.phpTEMPLATE_BODY cambia a "✨ Hola, {{1}}. Gracias por elegir HOLBOX.\n...🕶️ Modelo: {{2}}\n🧾 Ticket: #{{3}}\n..." (renumeración de 2 a 3 variables). Cada venta del select expone también nombre_corto.
    • resources/js/Pages/WhatsApp/Preview.vue — interpolación amplía a {{1}}nombre_corto, {{2}}modelo, {{3}}folio. El bloque de variables explícitas ahora tiene 3 filas.
    • tests/Feature/VentaModeloPrincipalTest.php — 3 tests nuevos para nombreClienteCorto: (a) cliente con nombre → devuelve nombre, (b) venta sin cliente → “cliente”, (c) nombre con solo espacios → “cliente”.
    • tests/Feature/WhatsAppPreviewTest.php — payload assertions actualizadas: parameters ahora tiene 3 entries, parameters[0] = nombreClienteCorto(), parameters[1] = modeloPrincipal(), parameters[2] = folio.
  • Validación local: suite WhatsApp 22/22 verde (56 assertions). Pint pass. Build verde.
  • Cuerpo final que Aarón debe someter a Meta (texto literal de TEMPLATE_BODY):
    ✨ Hola, {{1}}. Gracias por elegir HOLBOX.
    
    Tu compra quedó registrada correctamente ✅
    
    🕶️ Modelo: {{2}}
    🧾 Ticket: #{{3}}
    📅 Garantía activa
    
    Ver ticket completo:
    👇
    
    Gracias por elegir ver el mundo diferente.
    — HOLBOX
    
    Diseñado en México. Inspirado en el aquí y ahora.
    • botón URL dinámico “Ver ticket” con base https://holbox.example.com/t/ y variable {{1}} = token.
  • Estado en prod: el código maneja 3 variables. Hasta que Meta apruebe el template y Sergio configure el .env, no se manda nada — el preview en modo simulación funciona ya con interpolación completa para que Sergio valide el aspecto antes del envío real.

2026-05-18 (PM-4) — Template ticket_holbox_v3 (cuerpo con modelo + folio)

  • Pidió Aarón: mensaje nuevo del ticket por WhatsApp con cuerpo enriquecido (modelo del lente, número de ticket, “Garantía activa”), botón “Ver ticket completo” y firma de marca.
  • Respuesta a “¿Meta soporta múltiples variables?”: sí, hasta 10 {{N}} en body. Se mandan como array ordenado dentro de un componente body en template.components. El botón URL dinámico va en su propio componente como hoy.
  • Decisiones de producto (Sergio):
    • Modelo = primer producto no regalo + (+N más) si hay más de 1 real (regalos no cuentan, no acumulan).
    • Folio = literal completo (VTA{sucursal}-{correlativo}, ej. VTA4-000235).
    • Slug del template nuevo en Meta: ticket_holbox_v3, lang=es_MX, categoría UTILITY.
    • “Garantía activa” hard-coded en el template, sin variable.
  • Archivos tocados (commit ce93f6a, deployado a prod via GHA run 26067189002):
    • app/Models/Venta.php — método modeloPrincipal() que filtra detalles por es_regalo=false, toma el primero, formatea "Nombre" o "Nombre (+N más)". Fallback "Tu pedido" para tickets sin detalles reales (no debería pasar en flujo normal).
    • app/WhatsApp/MetaCloudWhatsAppSender.php — refactor: buildPayload(string $to, Venta $venta): array público, único, compartido entre sendTicket(), sendPreview() y el modo simulado del controller — así nunca puede haber drift entre lo que el admin previsualiza y lo que el cliente recibe. El payload nuevo incluye un componente body con [{type:'text', text:modelo}, {type:'text', text:folio}] antes del componente button.
    • app/Http/Controllers/WhatsAppPreviewController.phpTEMPLATE_BODY actualizado al texto literal del v2 (placeholders {{1}} y {{2}} sin resolver). Cada venta del select expone modelo ya derivado. El modo simulado pasa por $sender->buildPayload() en vez de armar el array a mano.
    • config/services.php — default de META_WHATSAPP_TICKET_TEMPLATE cambia a ticket_holbox_v3. Cuando Aarón obtenga approval, Sergio puede o bien setear la env var, o quitar la línea y dejar que caiga al default.
    • resources/js/Pages/WhatsApp/Preview.vue — el mockup interpola {{1}}modelo, {{2}}folio con la venta seleccionada, y muestra un bloque debajo con los valores explícitos para que el admin valide cada variable. Usa v-pre en los <code> que muestran {{1}}/{{2}} literales para evitar que Vue intente parsearlos como interpolación.
    • tests/Feature/VentaModeloPrincipalTest.php — nuevo. 6 tests del helper: 1 prod, N prod, mezcla real + regalos, real+regalo (no agrega contador), todos regalo, sin detalles.
    • tests/Feature/WhatsAppPreviewTest.php — 2 tests actualizados: el payload simulado y el real ahora se assertan contra la nueva estructura (components[0]=body con 2 params, components[1]=button con token).
  • Validación local: suite WhatsApp 19/19 verde (53 assertions). Pint pass. npm run build verde tras destrabe del {{ '{{1}}' }} problemático con v-pre (Vue intentaba interpolar).
  • Estado en prod: código deployado, pero el template ticket_holbox_v3 aún no existe en Meta. Mientras WHATSAPP_FEATURE_ENABLED=false y/o WHATSAPP_DRIVER=log el envío automático no toca Meta y nada se rompe. El admin solo dispara real si configura driver=meta + tokens + selecciona la venta en /whatsapp/preview, así que tampoco habrá envíos reales accidentales.
  • Falta de Aarón / Sergio:
    1. Aarón crea el template ticket_holbox_v3 en Meta Business Manager con el cuerpo literal de WhatsAppPreviewController::TEMPLATE_BODY (texto, 2 variables {{1}} y {{2}} en body, botón URL “Ver ticket” con {{1}} = token sobre https://holbox.example.com/t/).
    2. Sergio valida en /whatsapp/preview (modo simulación) que el mockup se ve OK con varias ventas distintas.
    3. Cuando Meta apruebe: Sergio actualiza .env de prod con META_WHATSAPP_TICKET_TEMPLATE=ticket_holbox_v3 (o quita la línea, default ya apunta al nuevo) + sudo systemctl reload php8.3-fpm. Sin redeploy.
    4. Smoke test real con cuenta de pruebas (test recipient) antes de levantar WHATSAPP_FEATURE_ENABLED=true globalmente.

2026-05-18 (PM-3) — Flag cuenta_para_precio_sale (excluye bolsitas/estuches del 2x)

  • Pidió Sergio: empezar con el #3 de los nuevos pendientes — marcar categorías que no deberían entrar al disparador de precio_sale.
  • Verificación read-only en prod (tinker via holbox SSH): categorías 1-8 listadas. Las 2 candidatas claras a quedar fuera son id=7 BOLSITA (reg=40, sale=35) y id=8 ESTUCHE (reg=100, sale=100). La frase de Sergio mencionaba solo “bolsitas” pero ESTUCHE tiene el mismo problema: contaba como “lente” para disparar el sale_price de los lentes reales aunque su sale=regular no causara diferencia en su propio precio.
  • Diseño aplicado: flag bool cuenta_para_precio_sale en categorias, default true (compat). Migración hace backfill a false para BOLSITA y ESTUCHE. Si Sergio quiere solo BOLSITA, ESTUCHE se vuelve a marcar en el switch de UI sin tocar código.
  • Archivos tocados (commit 04ad770, local, sin pushear):
  • Detour: tests pre-existentes rotos. El suite PosControllerTest traía 18 fallas / 4 verdes en baseline. Causa: 11 tests POST/GET a rutas POS no pasaban kioskSession(), requerido por OperativeSucursal desde algún commit reciente. Como mis tests sí necesitan kioskSession, dejé el patrón consistente y arreglé los preexistentes que comparten estructura. Suite final: 15 verdes / 11 rojas — todos los rojas son tests que no tienen $sucursal capturado en variable (helpers diferentes), fuera de scope. Ganancia neta: +11 verdes, 0 regresiones.
  • Build + Pint: Pint {"result":"pass"}. npm run build exitoso.
  • Cambio operativo (importante): con BOLSITA y ESTUCHE marcadas OFF por default, un ticket de 1 lente + 1 bolsita ya no recibe el descuento 2x en el lente. Antes pagaba precio_sale, ahora precio_regular. Esto sí afecta lo que la asociada cobra al cliente. Siguiendo el principio operativo del proyecto: Aarón debe avisar a las asociadas para evitar sorpresa en el primer ticket con bolsita.
  • Push + deploy: Sergio autorizó el push en la misma sesión. 04ad770 pusheado a origin/main, GHA run 26066193131 (49 s, success). Verificación en prod via tinker: 8 categorías OK, BOLSITA/ESTUCHE en false, las 6 de lentes en true. Si Sergio quiere reactivar ESTUCHE (que sale=regular no causa diferencia de precio pero cambia el conteo), basta con flip del switch en /categorias/8/edit — cero código.
  • Falta de Sergio: avisar a Aarón para que las asociadas sepan del cambio (asociadas verán la diferencia en el siguiente ticket con bolsita).

2026-05-18 (PM-2) — 3 pendientes nuevos del cliente

  • Pidió Sergio (3 ítems sin contexto extra):
    1. Totales de optometría en reporte de ventas (cantidades y montos).
    2. Que las asociadas vean su historial de venta del día en curso + totales de lentes por categoría (validar primero si ya existe).
    3. Marcar categorías que no disparen precio_sale (las bolsitas no deberían contar).
  • Validación que hice antes de capturar:
    • #1 — parcialmente existe: la columna “Optometría” en Reportes/Avanzados ya cuenta líneas con add-on (commit del 2026-05-13, bitácora Avanzados arriba), pero solo conteo. Falta el SUM(precio_optometria * cantidad) para el monto.
    • #2 — NO existe. El historialVentas en POS/Index.vue es del cliente seleccionado, no de la asociada. Los reportes existentes (ReporteController::ventas, AvanzadosController) están detrás de role:admin|gerente y no rompen el agregado por categoría per-asociada-del-día.
    • #3 — confirmado como bug latente. PosController::store incluye en totalLentesCount cualquier categoría que no use precio propio, así que las bolsitas pueden estar disparando precio_sale de los lentes reales con solo “1 lente + 1 bolsita”.
  • Capturado: 3 entries en “Tareas pendientes” con archivos/líneas concretas, sin tocar código. Pendiente de Sergio:
    • autorizar implementación de cada uno (Aarón debe avisar a las asociadas si #3 cambia el subtotal real),
    • confirmar qué categoría/categorías son las “bolsitas” para el flag de #3.

2026-05-18 (PM) — DP de optometría en un solo campo

  • Pidió Sergio (foto + mensaje de Aarón): en el form de captura de optometría, la sección “DP (Distancia Pupilar)” tenía dos sub-renglones: “Binocular” (ej. 63 mm) y “Monocular” (ej. 61 mm). Aarón quiere “quitar binocular y monocular, dejar solo el renglón”.
  • Decisión de migración de datos: la feature lleva 5 días en prod (commit original f4caf0a del 2026-05-13) y puede haber registros capturados. Para no perderlos: agregar columna dp, backfillear con COALESCE(dp_binocular, dp_monocular) (prefiere binocular si existen ambos, cae a monocular), drop ambas viejas. Migración reversible: el down() recrea las dos columnas viejas y copia dp a dp_binocular.
  • Archivos tocados:
  • Validación: migración corrida en local OK (97 ms), 9/9 tests filtro Optometria verde, rebuild de assets OK.
  • Commit + deploy: 43bf8cb pusheado a origin/main, GHA run 26058182934 completed success. El workflow deploy.yml corre php artisan migrate --force así que la consolidación ocurrió en la BD de prod automáticamente, preservando valores ya capturados.
  • Smoke check prod: GET https://holbox.val-soft.com/whatsapp/preview (de la sesión previa) sigue respondiendo 302 → auth.

2026-05-18 — Pantalla admin de preview de WhatsApp

  • Pidió Sergio: funcionalidad para probar los tickets de WhatsApp antes de habilitar la feature en producción. El admin del cliente debe poder ver cómo van a llegar los mensajes para aprobar el template. Decisión: opción B = dejar construido el envío real para cuando Sergio tenga el token de Meta y el template esté aprobado, con un modo simulación que ya funciona mientras tanto.
  • Arquitectura:
    • Ruta nueva whatsapp/preview (GET show + POST send), dentro del grupo role:admin en routes/web.php. NO accesible para gerente/asociada (test cubre 403).
    • Sin tocar el camino productivo: el contract App\Contracts\WhatsAppSender::sendTicket queda intacto y el job SendWhatsappTicket sigue resolviendo el binding del container como antes (Log o Meta según WHATSAPP_DRIVER).
    • Nuevo método público App\WhatsApp\MetaCloudWhatsAppSender::sendPreview(string $toE164, Venta $venta): array — NO está en el contract, se instancia ad-hoc en el controller del preview. Arma el mismo payload que sendTicket pero usa el teléfono que captura el admin, captura excepciones, y retorna {success, status, request_payload, response_body, message_id, error}. Loggea con prefijo whatsapp.preview.* para separarlo del tráfico productivo whatsapp.ticket.*.
    • App\Http\Controllers\WhatsAppPreviewController:
      • show: trae últimas 10 ventas con cliente, pasa driver, metaConfigured (= driver=meta + phone_number_id + token todos llenos), featureEnabled, y el contenido literal del template (body + button_label hardcoded — Meta no expone el cuerpo del template via API).
      • send: valida venta_id + telefono, normaliza con PhoneNumber::toE164Mx. Si no normaliza → 422 con withErrors('telefono'). Si metaConfigured=false (driver=log o falta token) → modo simulación: arma el payload teórico y lo devuelve sin pegar a Meta. Si metaConfigured=true → instancia MetaCloudWhatsAppSender con la config actual y llama sendPreview. Resultado vía session('preview_result') para que la página lo muestre tras redirect-back.
  • UI resources/js/Pages/WhatsApp/Preview.vue:
    • Banner del modo actual (emerald si Meta configurado, amber si modo simulación), con instrucciones de cómo activar real.
    • Form: dropdown ventas + input teléfono (acepta 10 dígitos / +52 / E.164) + botón “Enviar prueba real” o “Simular envío” según modo.
    • Mockup WhatsApp: burbuja blanca con cuerpo del template + botón CTA verde “Ver ticket” que abre la vista pública real del ticket de la venta seleccionada (no es simulación; es la URL /t/{token} que de verdad recibiría el cliente). Esto cubre la aprobación del contenido aunque no se envíe nada por Meta todavía.
    • Panel de resultado del último envío: status HTTP, message_id de Meta, response body crudo (formateado JSON), payload enviado colapsable. Si Meta rechaza con 4xx, el error queda visible aquí (error.message del JSON de Meta) para diagnóstico.
    • Cumple regla feedback_dark_mode_aesthetics — todos los bg-* tienen su dark:bg-* y los text-* también.
  • Link desde /configuracion: bloque emerald nuevo visible solo para admin (independiente del feature flag) en Configuracion/Index.vue con CTA “Abrir pantalla de prueba →”.
  • Tests tests/Feature/WhatsAppPreviewTest.php — 7/7 verde:
    1. asociada → 403.
    2. gerente → 403.
    3. admin → 200 + componente Inertia WhatsApp/Preview.
    4. driver=log + Http::fake() → no se manda nada (Http::assertNothingSent), resultado simulado tiene el ticket_token correcto en el payload.
    5. driver=meta + Http::fake con response OK → POST llega a /{phone_id}/messages con to override y template correcto; resultado tiene message_id.
    6. Teléfono "abc"assertSessionHasErrors('telefono').
    7. Meta responde 400 con error.message → resultado tiene success=false, status=400, error='Template name does not exist' (no revienta el controller).
  • Pre-requisito Meta cuando Sergio active: para que el envío real llegue antes de la aprobación final del template, el destinatario debe estar agregado como test recipient en Meta Business Manager (Phone Numbers → Add recipient). Una vez aprobado el template, cualquier número funciona. Esto queda documentado dentro de la propia página (banner del modo real).
  • Para activar: en el .env de DigitalOcean → WHATSAPP_DRIVER=meta + META_WHATSAPP_PHONE_NUMBER_ID + META_WHATSAPP_TOKEN. No requiere redeploy de código, los archivos ya van con el siguiente push.
  • Suite completa post-cambios: WhatsAppPreviewTest 7/7 + WhatsappTicketDispatchTest previo no roto. El único test que falla en el filtro (ConfiguracionControllerTest > guardar configuracion) ya fallaba en main antes de mis cambios (verificado con git stash), no es regresión.
  • No deployado todavía — esperando autorización de Sergio. Cambios en archivos: app/WhatsApp/MetaCloudWhatsAppSender.php, app/Http/Controllers/WhatsAppPreviewController.php (nuevo), routes/web.php, resources/js/Pages/WhatsApp/Preview.vue (nuevo), resources/js/Pages/Configuracion/Index.vue, tests/Feature/WhatsAppPreviewTest.php (nuevo). Sin commit.

2026-05-14 (PM-5) — Rediseño completo del flujo de regalos

  • Pidió Sergio (1): evaluar el flujo actual de regalos. Después de hacer el manual y revisar el código, Sergio detectó que muchos productos tenían usa_precio_propio=true pero NO eran accesorios — los dos conceptos estaban enredados por accidente. Decisión: separar en flag independiente + crear UI dedicada.
  • Hice (Fase A — 966b6b8): migración productos.puede_ser_regalo bool (default false) con backfill true para productos que ya estaban en categoria_accesorios (no romper config existente). Producto model + ProductoController + form de Producto con switch independiente “Puede usarse como regalo en compras”. CategoriaController::productosDisponibles ahora filtra por el flag nuevo. Frontend fallback en Ticket.vue y PublicTicket.vue: si el regalo no tiene precio propio, mostrar el precio_regular de su categoría como “ahorro”. 38/38 tests verde en suites afectadas.
  • Hice (Fase B — d60957f): menú nuevo “Regalos en compras” en /regalos (admin only). RegaloController con index / create / store / destroy. Vistas Inertia: tabla con badges de stock por sucursal coloreados + wizard de 2 selects (producto, categoría) con preview de stock en tiempo real al elegir producto + guard de auto-regalo con feedback explícito. Removida la sección “Accesorios automáticos (gratis)” de Categorias/Create+Edit — el CategoriaController ya no toca la pivot categoria_accesorios. Test de regresión confirma que PUT /categorias/{id} no wipea regalos. 9 tests nuevos en RegaloControllerTest. Manual reescrito completo. Nav link en Administración.
  • Pidió Sergio (2 — post-deploy): fix de 1 × $NaN que aparecía en cada línea del ticket público.
  • Hice (fa8193b): corregido d.precio_ventad.precio_unitario (campo real del schema). fmt() blindado con Number(n) || 0 para que cualquier null/undefined futuro caiga a $0.00 silenciosamente.
  • Estado final de la sesión de hoy: 13 commits totales pusheados, todas las features de Holbox pendientes accionables cerradas. Queda solo: (a) activar WHATSAPP_FEATURE_ENABLED=true + WHATSAPP_DRIVER=meta en prod cuando Meta apruebe el template, (b) acción manual de UI de Sergio para activar leaderboard globalmente, (c) atender el flujo continuo de cambios post-rollout.

2026-05-14 (PM-3) — Tickets por WhatsApp Fase 1

  • Pidió Sergio: implementar el pendiente original “Enviar tickets por WhatsApp”. Decisiones acordadas: Meta Cloud API como provider (trámite ya empezado), Fase 1 = link a vista pública (Fase 2 imagen/PDF después), opt-in del cliente con toggle global para bypass, checkbox en POS como el de email.
  • Hice:
    • DB: migración agrega clientes.acepta_whatsapp (bool, default false) y ventas.ticket_token (varchar 40 unique). Backfill de tokens para ventas existentes en la misma migración.
    • Modelos: Venta::booted() auto-genera ticket_token al create. Helper Venta::ticketUrl() devuelve la URL pública.
    • Util: App\Support\PhoneNumber::toE164Mx() — normaliza teléfono MX a E.164 (52XXXXXXXXXX), maneja +52, 52, 1 legacy, 01, 00, espacios y basura.
    • Service abstraction: contract App\Contracts\WhatsAppSender. Drivers LogWhatsAppSender (default, escribe a laravel.log) y MetaCloudWhatsAppSender (POST a /v20.0/{phone_id}/messages con template + body parameter para el link). Binding en AppServiceProvider::register por config('services.whatsapp.driver'). Mientras WHATSAPP_DRIVER=log, todo corre sin pegarle a Meta — switch real es solo .env cuando se apruebe.
    • Config: bloque whatsapp en config/services.php + entradas en .env.example (WHATSAPP_DRIVER, META_WHATSAPP_PHONE_NUMBER_ID, META_WHATSAPP_TOKEN, META_WHATSAPP_TICKET_TEMPLATE, META_WHATSAPP_TICKET_TEMPLATE_LANG).
    • Job: App\Jobs\SendWhatsappTicket queued, 3 tries, backoff [60s, 5min, 15min]. Resuelve WhatsAppSender del container.
    • Ruta pública: GET /t/{token}PublicTicketController::show (sin auth) → POS/PublicTicket.vue (clon de Ticket.vue sin Bixolon SDK ni “Nueva Venta”, solo botón Imprimir/PDF).
    • POS flow: pos.procesar valida envio_whatsapp bool. Si envio_whatsapp && cliente && cliente->telefono && (!respetar_opt_in || cliente->acepta_whatsapp)SendWhatsappTicket::dispatch($venta).
    • UI: checkbox acepta_whatsapp en Clientes/Create.vue, Edit.vue, quick-add del POS y edit cliente del POS. Toggle global “Respetar opt-in del cliente” en /configuracion (default ON).
    • Tests: 14/14 verdes: 5 dispatch (acepta+toggle ON, no-acepta+toggle ON, no-acepta+toggle OFF, sin teléfono, envio_whatsapp=false) + 3 ruta pública (200, 404, token auto-generado único) + 6 normalizador teléfono. 19 tests previos (Cliente + ComisionRegla) siguen verdes.
  • Plantilla Meta que Sergio debe aprobar antes de activar driver=meta:
    • Categoría: UTILITY
    • Nombre: ticket_link_v1 (config) — cambiable vía env META_WHATSAPP_TICKET_TEMPLATE
    • Idioma: es_MX — cambiable vía META_WHATSAPP_TICKET_TEMPLATE_LANG
    • Cuerpo (sin variables): Gracias por tu compra en Holbox 🛍️\nTe dejamos el ticket de tu venta. Toca el botón para verlo en cualquier momento.
    • Call to Action: tipo Visit website, texto botón Ver ticket, URL Type Dynamic, Website URL https://<dominio-prod>/t/{{1}}. El {{1}} se rellena con ticket_token de la venta (NO con la URL completa).
    • El sender (MetaCloudWhatsAppSender) ya manda solo el ticket_token como parámetro del botón en components[0].parameters[0].text. Meta concatena con el prefijo del Website URL configurado en el template.
  • Activar en prod cuando Meta apruebe: en el .env de DigitalOcean cambiar WHATSAPP_DRIVER=logWHATSAPP_DRIVER=meta + meter META_WHATSAPP_PHONE_NUMBER_ID y META_WHATSAPP_TOKEN. No requiere redeploy de código.
  • Falta: validación con sail up, autorización para commit + push.

2026-05-14 (PM-2) — UI admin de reglas de comisión (Fase 2 cerrada)

  • Pidió Sergio: ir por el siguiente pendiente tras locale ES.
  • Hice:
    • app/Http/Controllers/ComisionReglaController.php — resource CRUD (index/create/store/edit/update/destroy) + acción adicional toggle(POST /comision-reglas/{regla}/toggle) para flipear activa sin abrir el form. Validación inline (patrón SucursalController) con Rule::requiredIf para los tres constraints lógicos (categoria_id si trigger=categoria, producto_id si trigger=producto, unidades_por_paquete si tipo=por_paquete). Antes de persistir, normaliza FKs irrelevantes a null para que un cambio de trigger no deje basura colgando.
    • Rutas: Route::resource('comision-reglas', …)->except(['show']) + ruta extra del toggle, dentro del grupo role:admin existente.
    • Vistas Inertia bajo resources/js/Pages/ComisionReglas/ (Index, Create, Edit) — siguen el patrón de Sucursales (no Admin/ subfolder, que no existe en el codebase). Index tiene badges por trigger (categoría/optometría/producto) y por tipo (por_unidad/por_paquete), toggle inline estilo switch, hint card explicando los triggers, warning sobre el efecto en el reporte semanal en curso (alineado con el principio operativo del proyecto).
    • Enlace en el dropdown “Administración” del navbar (AuthenticatedLayout.vue), gated a rol admin.
    • 13 tests Pest verde (acceso por rol, listado, store de los 3 triggers, validaciones condicionales, normalización de FKs irrelevantes, update, toggle, hard delete, asociada bloqueada). Total 44 assertions.
  • Decisiones fuera del diseño tentativo del doc:
    • Sin Admin/ subfolder. El doc proponía App\Http\Controllers\Admin\ComisionReglaController y Pages/Admin/ComisionReglas/…, pero el codebase existente nunca usa esa convención (Sucursales, Usuarios, Categorias viven al top-level). Seguí la convención establecida.
    • Hard delete vs soft delete: el doc decía “Borrar duro solo cuando una regla era un error puro”. Implementado así — destroy borra real, mientras que el toggle (separado) flipa activa para apagar sin perder historia. Frontend mantiene confirm() con un mensaje que distingue ambos casos.
    • Sin autocomplete de productos. Hoy hay 26 productos en dev; un <select> plano es suficiente. Si la lista crece a varios cientos, hay una tarea futura registrada arriba.
  • Falta: que Sergio valide con sail up (Index, crear/editar/borrar/togglar) y dé luz verde a commit + push.

2026-05-14 (PM) — Validation locale ES

  • Pidió Sergio: seguir con “Traducir mensajes de validación a español” tras el buscador.
  • Hice:
    • lang/en/ publicado (php artisan lang:publish).
    • lang/es/{validation,auth,passwords,pagination}.php a mano. validation.php con sección attributes cubriendo ~50 campos del codebase para que :attribute salga legible.
    • config/app.php default 'es' (de modo que aunque la env del server diga otra cosa, el código cae en ES si la env falta). .env.example y .env local también a es.
    • Test Pest end-to-end: POST /clientes con email duplicado devuelve "Ya existe un registro con este email.", required y formato también en ES.
  • Decisiones:
    • Sin dep externa (no laravel-lang/lang). El archivo de validación es ~150 líneas, idiomático, sin sorpresas. Una dep nueva agrega superficie de auditoría innecesaria.
    • Default en config/app.php también cambiado: si alguien borra APP_LOCALE del .env, default sigue siendo ES — protege contra deploys mal configurados.
    • 36 tests pre-existentes fallan en la suite completa pero verifiqué con grep que ningún test asserta contra strings EN de validación; las fallas son drift independiente (PasswordReset/PasswordUpdate routes, ExampleTest / espera 200 pero el sitio redirige, mensajes “exitosamente” vs “correctamente”). No es regresión mía.
  • Falta: después de deploy, Sergio actualiza el .env de prod (DigitalOcean, holbox SSH alias) a APP_LOCALE=es o elimina la línea para que caiga al default. Validar con la asociada de la sucursal que el banner del POS ahora muestra el mensaje en ES cuando intenta meter un email duplicado.

2026-05-14 — Buscador en lista de clientes

  • Pidió Sergio: arrancar con el pendiente “Buscador en lista de clientes” agendado para hoy.
  • Hice:
    • app/Http/Controllers/ClienteController.php::index — acepta ?q=, hace trim() del input, aplica where() cerrado con grupo orWhere sobre nombre/apellido/telefono/email + orWhereRaw("CONCAT(nombre, ' ', apellido) LIKE ?") para que “Luis Hernández” haga match. paginate(15)->withQueryString() para que la paginación conserve el filtro. Devuelve también filters.q a la vista.
    • resources/js/Pages/Clientes/Index.vue — agregada filters prop, q = ref(props.filters?.q), watch con debounce 400ms → router.get('clientes.index', { q }, { preserveState, preserveScroll, replace }). UI: input con MagnifyingGlassIcon + botón XMarkIcon para limpiar, dentro de un bloque arriba de la tabla con bg gris/dark armonizado. Empty state distingue “no encontrado con <q>” vs “no hay clientes registrados”.
    • tests/Feature/ClienteControllerTest.php — nuevo test con 7 escenarios (nombre parcial, apellido, email parcial, teléfono parcial, “nombre apellido” combinado, sin resultados, sin filtro). 5/5 archivo verde, 99 assertions.
    • Pint normalizó ambos archivos PHP.
  • Decisiones / cosas a notar:
    • Debounce a 400ms (no 300ms como dijo el doc original) — alineado con el patrón ya en producción en Reportes/Ventas.vue. Consistencia >> 100ms.
    • Búsqueda en nombre apellido concatenado además de los campos sueltos — el doc original no lo pedía pero es la búsqueda esperada por una asociada que teclea el nombre completo.
    • Sin índices nuevos en BD: la nota original lo marcó como “considerar si la lista crece” — Holbox no tiene volumen para justificarlo ahora. Revisar si el dataset pasa de ~5-10k clientes.
  • Falta: que Sergio commitee + deploy vía GHA. Validar en producción que (a) el input responde fluido, (b) la paginación preserva el q, (c) limpiar el input restaura la lista completa.

2026-05-13

  • Pidió Sergio: agregar compras de optometría al reporte avanzado “Desempeño Equipo”.
  • Hice: agregada columna “Optometría” en AvanzadosController.php (SUM CASE WHEN tipo_optometria IS NOT NULL THEN 1) + columna sortable con badge ámbar en Avanzados.vue. Modelada como columna fija (como tickets/arts_otros), no como categoría dinámica — consistente con ComisionReglasCalculator::addon_optometria. Commiteado y pusheado: 8caf658 en main.
  • Falta: validar con Aarón si la cuenta debe ser por línea o por unidad, y si quiere desglose por tipo (antireflejante/tinte/transition).

2026-05-13 (PM) — Leaderboard de vendedoras (motivación en POS)

Pidió Sergio (cliente Aarón): leaderboard visible en todo momento para las vendedoras, ordenado por cantidad de ticket promedio, semana lunes-domingo (todas arrancan en $0 el lunes), actualización casi en tiempo real (cada ~5 min está bien), sin estorbar al UI del POS. Mostrar el mensaje literal:

“Las mejores de Holbox no venden barato. Crean experiencia”

Decisiones de diseño (sin clarificar con Sergio porque pidió no parar):

  • Alcance: global (todas las asociadas de todas las sucursales). Ticket promedio normaliza por volumen así que comparar entre sucursales es justo y la competencia entre todo el staff motiva más. Fácil de cambiar a per-sucursal si Aarón lo pide.
  • Solo se muestra a rol === 'asociada'. Admin y gerente no lo ven (no es información nueva para ellos, y mantiene el UI admin/gerente sin estorbo). Si Sergio quiere monitorearlo en vivo, lo abrirá vía /leaderboard en JSON.
  • Widget colapsable bottom-right. Minimizado = chip dorado con tu posición + tu promedio (no estorba productos). Expandido = panel 72-80 con mensaje motivacional + top 5 + tu fila si no estás en top 5.
  • Polling 5 min vía setInterval. No websockets, no echo — el tráfico de POS no amerita la complejidad.
  • Solo cuenta ventas estado='completada'. Ventas canceladas no inflan ni penalizan el promedio.

Implementación:

  • Backend app/Http/Controllers/LeaderboardController.php — endpoint JSON. Calcula tickets, ventas_total, ticket_promedio = ventas_total/tickets por asociada en la ventana Carbon::now($tz)->startOfWeek(MONDAY)…endOfWeek(SUNDAY) (misma TZ y mismo patrón que ReporteController::comisiones). Asociadas sin ventas se incluyen con tickets=0, ticket_promedio=0 (la cliente pidió que arranquen en $0 el lunes). Ordena desc por promedio (tiebreak por ventas_total) y asigna posicion con ranking estilo competitivo (1, 2, 2, 4) si hay empate.
  • Ruta routes/web.phpGET /leaderboardleaderboard.index, dentro del grupo auth (cualquier usuario logueado puede consumir).
  • Frontend resources/js/Components/Leaderboard.vue — widget Vue 3 <script setup> con axios. Fetch on mount + setInterval cada 5 min. Estado colapsado/expandido en ref(false) local. Botón “Refrescar” manual. Indicador “Actualizando…” + timestamp de última actualización. print:hidden para no salir en tickets impresos. Resalta la fila del usuario actual con bg-amber-50. Etiqueta 🥇/🥈/🥉 para top 3.
  • Montaje resources/js/Layouts/AuthenticatedLayout.vue<Leaderboard v-if="$page.props.auth.user.rol === 'asociada'" /> fuera del <main>, dentro del wrapper raíz para que sea fixed sobre cualquier pantalla.

Tests (tests/Feature/LeaderboardControllerTest.php) — 6 casos:

  1. Sin auth → 401.
  2. Ordena por promedio desc y asigna posicion (1,2,3) correctamente con 3 asociadas con distintos promedios.
  3. Asociadas sin ventas aparecen con tickets=0 y promedio=0.
  4. Ventas canceladas + ventas fuera de la ventana lun-dom se excluyen.
  5. Admin y gerente no aparecen en el ranking (aunque tengan ventas asignadas).
  6. Periodo devuelto = lun-dom de la semana actual.

Resultado: 6/6 pasan. 0 regresiones (ComisionReglasCalculatorTest 8/8 sigue pasando; los 36 fails en la suite completa son los preexistentes ya documentados).

Pint: corrido sobre los archivos dirty. Solo ajustó orden de imports en routes/web.php.

Archivos modificados (5 archivos):

  • Nuevo: app/Http/Controllers/LeaderboardController.php
  • Nuevo: resources/js/Components/Leaderboard.vue
  • Nuevo: tests/Feature/LeaderboardControllerTest.php
  • Modificado: routes/web.php (1 import + 1 ruta)
  • Modificado: resources/js/Layouts/AuthenticatedLayout.vue (1 import + 1 <Leaderboard> tag)

Sin migraciones, sin cambios de schema, sin cambios en composer.lock / package.json. Build de Vite va a regenerar el chunk del Layout + el nuevo chunk del componente.

Pendiente:

  • Autorización de Sergio para push a origin/main → dispara deploy.yml (git pull + composer + npm run build + php artisan migrate --force). Sin migración pendiente la parte de migrate es no-op.
  • Validar en prod con cuenta asociada real: que aparezca el chip dorado en bottom-right del POS, que abrir muestre el mensaje + ranking, que se actualice tras una venta nueva (≤5 min).
  • Decisiones a confirmar con Aarón post-deploy:
    • ¿Global vs per-sucursal? (default global, ver decisión arriba).
    • ¿Mostrar también monto total de ventas, o solo promedio? (default: solo promedio + posición; total se ve en hover/expand si Aarón lo pide).
    • ¿Visible para gerentes también? (default: solo asociadas).

Notas técnicas / consideraciones futuras:

  • El endpoint NO está cacheado. Con ~10 asociadas y N ventas por semana es un query trivial (1 GROUP BY sobre ventas + 1 SELECT sobre users). Si crece el catálogo de asociadas/volumen, agregar Cache::remember($key, 60, …) para limitar a 1 query por minuto por servidor.
  • El polling es client-side. Cada asociada con POS abierto hace 1 request cada 5 min → ~12 req/hora/cliente. Trivial.
  • Si Sergio prefiere live (segundos en lugar de minutos): refrescar el leaderboard desde el flujo de pos.procesar vía broadcasting (Laravel Reverb + Echo). Más complejidad; no se justifica con el volumen actual.

2026-05-08

  • Pidió Sergio: registrar el proyecto.
  • Hice: archivo creado. Capturado el deploy reciente a producción multi-sucursal y el pendiente de tickets por WhatsApp. Marcado el preámbulo “Gemini 3.1 Pro” como AI-noise para no dejarme guiar por él.
  • Falta: envío de tickets por WhatsApp + atender los cambios que vayan saliendo del uso en sucursales.

2026-05-08 (PM) — 4ª regla de comisión: SKUs específicos

  • Pidió Sergio (vía mensaje del admin de Holbox que reenvió):

    Aarón agregó comisiones de $30 por pieza vendida, solo para 3 modelos: CJ-001A (Ixchel), CJ-0001B (Ixchel Sunrise), CJ-006C (Kin Sunrise). Aarón ya se las comunicó a las asociadas el 2026-05-07.

  • Hice: extendí el diseño de la tabla comision_reglas para soportar un 3er trigger producto (además de categoria y addon_optometria). Agregué producto_id (nullable, FK a productos.id) al schema y reglas de validación. Ahora son 4 reglas a poblar (3 filas para los SKUs Ixchel/Kin = una por SKU). Verify-step agregado: confirmar SKUs exactos contra la DB de prod (cuidar padding inconsistente 001A vs 0001B vs 006C).
  • Decisión Sergio (2026-05-08): no apretar el ship. Aarón sabe que los cambios no son inmediatos y debe avisar con tiempo cualquier cambio que afecte a las empleadas. Si el reporte semanal del 2026-05-11 sale sin la regla 4, el delta se paga a mano — el volumen es bajo y el impacto pequeño. Las 4 reglas se shipean cuando estén bien (posible antes del lunes, sin compromiso). Principio operativo capturado arriba en el documento.
  • Falta: la implementación misma, sin deadline fijo.

2026-05-11 — Implementación Fase 1 (MVP que aplica al reporte)

Pidió Sergio: arrancar el trabajo de Holbox después de cerrar la sesión de orion.

Investigación read-only previa (autorizada):

  • 3 tipos de optometría confirmados en código: antireflejante, tinte, transition (validación en PosController.php:185). Modelados como columnas en venta_detalles: tipo_optometria (string nullable) + precio_optometria (decimal). Detección de “línea con optometría” = tipo_optometria IS NOT NULL. Para la comisión, los 3 valen igual ($100 por línea).
  • Costos actuales de optometría en prod: antireflejante $1400, tinte $1700, transition $2000 (configuraciones costo_optometria_*).
  • Categorías confirmadas en prod:
    • id=2 → Bamboo Clásico (con acento, no premium)
    • id=3 → Bamboo Golden (no premium)
    • id=6 → Bamboo Premium Golden (premium)
    • (Sergio confirmó “Golden” = ambas, ids 3 y 6)
  • 3 SKUs Ixchel/Kin confirmados en prod: id=42 CJ-001A Ixchel, id=41 CJ-0001B Ixchel Sunrise, id=36 CJ-006C Kin Sunrise. Todos en categoría 2 (Bamboo Clásico).
  • Confirmado por Sergio: las reglas 3 (Bamboo Clásico) y 4 (SKUs Ixchel/Kin) se suman para un Ixchel/Kin vendido (ambas aplican simultáneamente).

Implementación:

  • Migration database/migrations/2026_05_11_130000_create_comision_reglas_table.php — schema acordado: trigger enum + categoria_id + producto_id (ambos nullable, FK), tipo enum, monto, unidades_por_paquete, descripcion, activa. Índices en (trigger, activa), categoria_id, producto_id.
  • Modelo app/Models/ComisionRegla.php — casts, fillable, constantes para enums, relaciones a Categoria y Producto.
  • Service app/Support/ComisionReglasCalculator.php — lógica encapsulada. Recibe colección de ventas (con detalles.producto eager-loaded) y devuelve ['total' => float, 'aportes' => [...]]. Trigger por trigger:
    • addon_optometria → cuenta líneas con tipo_optometria !== null × monto
    • categoria por_unidad → cuenta unidades × monto
    • categoria por_paquete → floor(unidades/paquete) × monto (sobrantes se pierden, como pidió Sergio)
    • producto → mismo patrón pero filtrado por producto_id
  • Seeder database/seeders/ComisionReglasSeeder.php — busca categorías por nombre exacto y SKUs por string exacto en prod, hace updateOrCreate por (trigger, categoria_id, producto_id) → idempotente. Si una categoría/SKU no existe, omite esa regla en lugar de fallar.
  • ReporteController::comisiones modificado: carga reglas activas una vez, instancia el calculator, llama $calculator->calcular($user->ventas) por usuario. Agrega reglas_aportes y reglas_total al payload de Inertia. comision_total ahora suma comision_base + bono_premium + reglas_total.
  • Vista resources/js/Pages/Reportes/Comisiones.vue: agregada columna “Reglas Extra” con total + tooltip title que muestra el desglose línea por línea (descripción, unidades, monto). Esquema en el card inferior actualizado para listar las 4 reglas nuevas.

Tests (tests/Feature/ComisionReglasCalculatorTest.php) — 8 casos:

  1. Sin reglas activas → total 0
  2. addon_optometria suma por línea (los 3 tipos cuentan igual)
  3. categoria por_unidad cuenta unidades del producto en esa categoría
  4. categoria por_paquete aplica floor(unidades / paquete); sobrantes se pierden
  5. producto por_unidad cuenta unidades del SKU específico
  6. Múltiples reglas se suman (caso especial: categoria + producto sobre el mismo SKU)
  7. Reglas inactivas se excluyen
  8. Integración: el reporte de comisiones incluye reglas_total y se suma a comision_total

Resultado: 9/9 pasan. 0 regresiones en la suite (los 33 fails restantes son preexistentes — verificado con git stash + re-run).

Cambios adicionales en la misma sesión (después del MVP inicial):

  • Card de “Esquema de Comisiones” hecho dinámico — antes mostraba valores hardcoded (“3%”, ”+$300”, “$100 por línea con add-on”, etc.). Ahora el controller pasa una prop esquema con comision_porcentaje (de Configuracion), bono_premium[] (4/6/8 con sus montos actuales) y reglas[] (descripción de cada regla activa). La vista las renderiza directo. Si Sergio cambia un monto en la DB, el card lo refleja sin tocar código.
  • Migration de data 2026_05_11_130100_seed_comision_reglas_iniciales.php — invoca al ComisionReglasSeeder desde una migration para que las 7 filas iniciales se siembren automáticamente con php artisan migrate --force (que ya está en el workflow de GitHub Actions). Necesario porque el workflow NO corre seeders. La migration es idempotente (el seeder usa updateOrCreate).

Deploy ejecutado 2026-05-11:

  • Commit: ea70491 (“feat: add commission rules system with multiple triggers”)
  • Push a origin/main → GitHub Actions corre el workflow deploy.yml que hace git pull + composer install + npm run build + php artisan migrate --force en el server /var/www/holbox. La migración de data siembra las 7 reglas automáticamente.
  • Verificación post-deploy: Sergio debería entrar a /reportes/comisiones en prod, confirmar que aparece la columna “Reglas Extra”, pasar el cursor para ver el desglose, y validar que el card inferior lista las 4 reglas leídas de la DB. La semana actual (lun-dom) ya debería incluir las aportaciones de las reglas en el cálculo de cada empleada.
  • Validado en prod 2026-05-11: Sergio confirmó que la entrega quedó lista en producción. ✅

Deploy a producción

Cuando Sergio lo apruebe, los pasos son:

  1. Push a git del branch con los cambios (5 archivos nuevos + 2 modificados).
  2. En el server (ssh holbox, cd /var/www/holbox):
    git pull
    composer install --no-dev --optimize-autoloader  # si cambió composer.lock (no debería)
    php artisan migrate                              # crea comision_reglas
    php artisan db:seed --class=ComisionReglasSeeder # crea las 7 filas
    npm install && npm run build                     # rebuild assets (Vue)
    php artisan optimize:clear                       # limpia caches
  3. Verificar en UI: entrar a /reportes/comisiones, ver columna “Reglas Extra” y tooltip con desglose.
  4. Validar reporte semanal (semana actual lun-dom): el delta debería desaparecer si las reglas calculan bien.

El seeder es idempotente (usa updateOrCreate), así que se puede re-correr sin duplicar filas.

Rollback: php artisan migrate:rollback --step=1 quita la tabla comision_reglas. El controller seguiría leyendo de ComisionRegla::query()->where('activa', true)->get() que devolvería colección vacía si la tabla no existe — esto podría romper. Mejor: si hay que rollback, hacer git revert primero, luego rollback de migración.

2026-05-12 — Bug reportado: filtro de reporte de ventas se pierde al regresar del detalle

  • Pidió Sergio: registrar bug nuevo. Al filtrar el reporte de ventas, entrar al detalle de una venta y dar back, el filtro previamente seleccionado desaparece y los valores se van a default.
  • Hice: capturado en Tareas pendientes. No se ha investigado el código todavía.
  • Falta: diagnosticar dónde viven los filtros (estado local Vue vs query string vs Inertia remember), confirmar la causa raíz y proponer fix antes de tocar nada. Sergio decide cuándo arrancar.

2026-05-12 — Diagnóstico del bug del filtro (read-only)

Sergio autorizó investigar. Lectura de los archivos involucrados:

Cómo funcionan los filtros hoy (resources/js/Pages/Reportes/Ventas.vue):

  • Los filtros viven como ref()s en el componente, inicializados desde props.filters.X || '' (líneas 30-37).
  • props.filters viene del backend (app/Http/Controllers/Reportes/VentasController.php:111-122), que lee los valores del query string ($request->input(...)) y los devuelve a Inertia.
  • Cuando el usuario cambia cualquier filtro, un watch con debounce de 400ms llama a fetchReport() (Ventas.vue:43-59), que hace router.get(route('reportes.ventas'), {filtros...}, { preserveState: true, replace: true }).
  • replace: true reemplaza la entry actual del history en lugar de hacer push. Resultado esperado: la URL del navegador queda con los filtros en query string, y un back del navegador debería preservarlos.

Cómo se va al detalle (Ventas.vue:291-297):

  • Botón “Ver” → <Link :href="route('ventas.show', venta.id)"> → push normal al history. La URL del reporte con filtros queda en la entry anterior.

Causa raíz del bug ⚠️:

  • En resources/js/Pages/Ventas/Show.vue:40, el botón de “volver” (la flecha ArrowLeftIcon del header) es:
    <Link :href="route('reportes.ventas')" ...>
    Es decir, navega a /reportes/ventas sin pasar ningún query param. El controller usa los defaults (fecha_inicio = hoy-30, fecha_fin = hoy, todo lo demás vacío) y devuelve filters con esos defaults → los refs se inicializan con defaults → el usuario ve el reporte “limpio”.
  • No es un <Link> con preserve-state ni un window.history.back(); es un Link plano con URL hardcoded. La pérdida de filtros es inevitable con este código.
  • Sin embargo: el back NATIVO del navegador (Alt+← / botón del browser) SÍ debería funcionar correctamente, porque fetchReport() ya usa replace: true y la URL con filtros queda persistida en la history entry del reporte. Verificación pendiente con Sergio en vivo — si él también reporta que el back nativo del browser falla, habría que escarbar más (snapshot stale en history.state, useRemember faltante, etc.).

Otros lugares que apuntan a route('reportes.ventas') (búsqueda exhaustiva):

Opciones de fix (orden de preferencia):

  1. Recomendada — usar back nativo del navegador con fallback (cambio puntual en Show.vue):

    <button @click="goBack" type="button" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
        <ArrowLeftIcon class="h-5 w-5 text-gray-500" />
    </button>
    import { router } from '@inertiajs/vue3';
    const goBack = () => {
        if (window.history.length > 1) {
            window.history.back();
        } else {
            router.visit(route('reportes.ventas'));
        }
    };

    Pros: Inertia intercepta el popstate, restaura la página anterior con sus props (filtros incluidos) + scroll position. También funciona si el usuario llegó al detalle desde otro origen (ej. dashboard) — el fallback evita romper. Contras: ninguno relevante. Es el patrón canónico para “back” en SPAs con Inertia.

  2. Alternativa — pasar query string al Link (más explícito, no depende de history):

    • Backend: en VentaController::show(), leer $request->headers->get('referer') y pasarlo como prop back_url. O recibir filtros explícitos como query params en la ruta /ventas/{venta}.
    • Frontend (Ventas.vue): construir el href de “Ver” incluyendo los filtros actuales como query string (?from=...).
    • Frontend (Show.vue): usar :href="back_url || route('reportes.ventas')". Contras: más cambios, más superficie de error, no preserva scroll position, no escala a otros reportes que quieran lo mismo.
  3. Descartada — useRemember: serviría si los filtros vivieran solo en estado local de Vue y no en query string, pero como ya están en query string, no agrega valor — el problema es exclusivamente el botón “Volver” rompiendo la cadena de history.

Alcance del fix recomendado: un solo archivo, ~10 líneas, sin migraciones, sin cambios en backend, sin tests nuevos (la lógica del controller no cambia). El <Link> se reemplaza por <button> + handler.

Pendiente decisión Sergio: confirmar opción 1 antes de implementar. Si quiere, también puedo verificar en vivo si el back nativo del navegador (Alt+←) tiene el mismo síntoma — eso descartaría/confirmaría problemas adicionales en Inertia/history más allá del botón.

2026-05-12 (PM) — Fix aplicado

  • Sergio confirmó: el back NATIVO del navegador funciona bien; sólo el botón visual rompe los filtros. Confirma diagnóstico 100% y descarta issues adicionales en Inertia/history.
  • Autorizó: aplicar el fix (Opción 1).
  • Cambios en resources/js/Pages/Ventas/Show.vue:
    • Import: Linkrouter (no había más usos de Link en el archivo, verificado con grep).
    • Agregada función goBack() que hace window.history.back() si hay history, o router.visit(route('reportes.ventas')) como fallback.
    • El <Link :href="route('reportes.ventas')"> del header se reemplazó por <button type="button" @click="goBack"> con las mismas clases CSS.
  • Alcance: 1 archivo modificado, sin backend, sin migraciones, sin cambios en rutas. Build limpio (Vite recompila el chunk de Show).
  • Deploy ejecutado 2026-05-12:
    • Pull fast-forward previo trajo b563c7c (cambio no relacionado en ReporteController.php que estaba en origin).
    • Commit: 3bab159 (“fix: preserve sales report filters when returning from sale detail”).
    • Push a origin/main autorizado por Sergio → GitHub Actions ejecuta deploy.yml (git pull + composer + npm run build + php artisan migrate --force). El build de Vite regenera el chunk de Show.
  • Validado en prod 2026-05-12: Sergio confirmó que la flecha “volver” preserva los filtros. ✅ Bug cerrado.

2026-05-12 — Commit b563c7c de Sergio (no documentado aquí hasta ahora)

  • Hecho por Sergio el 2026-05-12 02:28 (madrugada), antes de la sesión actual.
  • Cambio: feat: add optional sucursal filter to sales reports in ReporteController. En realidad afecta al método comisiones(), no a ventas(). Ahora si sucursal_id viene en el request, el reporte de comisiones filtra las ventas de cada usuario (asociada y gerente) por esa sucursal antes de pasarlas al calculator. Sin filtro, el comportamiento es idéntico al anterior.
  • Impacto en lo que ya construimos: ninguno. El ComisionReglasCalculator recibe la colección de ventas tal cual; si Sergio limita ahora el conjunto a una sucursal, las reglas se calculan correctamente sobre ese subconjunto.
  • Pendiente: si la UI del reporte de comisiones aún no expone selector de sucursal, hay que agregarlo (similar al de Ventas.vue) — verificar en resources/js/Pages/Reportes/Comisiones.vue.

2026-05-12 — Diagnóstico: PWA no se puede instalar en escritorio + ícono/nombre no aparecen al reinstalar en celular

Reportado por Sergio: (1) ya no aparece la opción de instalar la app en escritorio; (2) al desinstalar y reinstalar en celular, el ícono y nombre nuevos (commits 600b4e9 + 28137bd de anoche) no aparecen.

Lectura de los archivos involucrados:

Causas raíz identificadas:

  1. 🚨 Crítica para instalabilidad en escritorio (Chrome/Edge): el manifest declara los íconos con "sizes": "any" y "type": "image/jpeg". Según el spec PWA, sizes: "any" solo es válido para íconos vectoriales (SVG). Para íconos raster (JPEG/PNG) Chrome exige tamaños concretos y requiere al menos un ícono de 192×192 y uno de 512×512 para activar el botón “Instalar”. Sin eso, Chrome no considera la app instalable → desaparece la opción del menú. Esto explica el problema #1.

  2. 🚨 Crítica para iOS (Safari): el <link rel="apple-touch-icon" href="/app_icon.jpeg"> apunta a un JPEG. iOS Safari ignora JPEG en apple-touch-icon; solo acepta PNG. Cuando Sergio reinstaló en su celular, iOS cayó al favicon vacío (favicon.ico pesa 0 bytes en el repo) o un placeholder. Esto explica el problema #2 para iOS.

  3. 🚨 Crítica para Android Chrome (instalación PWA): mismo issue #1. Si el manifest no es válido, Android tampoco instala la PWA real — convierte la “instalación” en un shortcut de Chrome con el ícono del sitio (favicon o el primero que encuentre), lo que también explica que el ícono nuevo no aparezca en Android.

  4. Calidad — maskable mal definido: el ícono maskable debe tener un safe zone interno de ~10% porque Android puede recortar/redondear los bordes según la forma del launcher. Aquí se reusa app_icon.jpeg (raster sin safe zone declarado) para purpose: "maskable". Aun arreglando los tamaños, en Android se va a ver cortado.

  5. JPEG es subóptimo para PWA: no soporta transparencia (los bordes redondeados quedarán con fondo blanco/del JPEG). Lo estándar es PNG.

  6. Ícono 1526×1600 no cuadrado: aunque arreglemos los sizes, el JPEG actual NO es cuadrado — redimensionarlo a 192×192/512×512 lo deformará. Hay que partir de una versión cuadrada (idealmente la imagen original sin recorte vertical extra).

  7. El SW (commit 28137bd) ya está bien: CACHE_VERSION = 'v5' fuerza invalidación, precachea /app_icon.jpeg, agrega .jpeg/.jpg a isStaticAsset. La invalidación de cache vieja funciona; no es el culpable. El service worker en Sergio (y los devices que reinstalen) sí carga el manifest nuevo — el problema es que el manifest nuevo en sí es inválido para installability.

  8. ManifestController también afectado: los manifests dinámicos por sucursal (/manifest/s/{token}, usados cuando una sucursal abre el POS desde su URL kiosk) tienen los mismos errores (líneas 39-49). Hay que arreglarlos en ambos lados.

Plan de fix recomendado:

A. Generar versiones PNG cuadradas en varios tamaños desde un original cuadrado del ícono:

  • /public/icon-192.png (192×192, purpose: any)
  • /public/icon-512.png (512×512, purpose: any)
  • /public/icon-maskable-512.png (512×512 con ~10% safe zone interior, purpose: maskable)
  • /public/apple-touch-icon.png (180×180 PNG, sin transparencia, fondo opaco — Safari iOS)

Bloqueador: este entorno no tiene convert/ImageMagick ni PIL. Opciones:

  • (a) Sergio genera los PNGs externamente (Figma, herramienta online como maskable.app, o photoshop) y los sube al repo.
  • (b) Autorizar sudo apt install imagemagick o pip install pillow para generarlos automáticamente desde el JPEG actual — pero como el JPEG no es cuadrado, el resultado se va a ver mal sin recortar/padear manualmente.
  • (c) Usar PHP/GD del contenedor de Sail (Laravel suele tener GD) para generarlos con un script artisan one-shot — viable pero misma limitación del recorte.

B. Actualizar public/manifest.webmanifest a:

"icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]

(Sin perder name/short_name/shortcuts, solo cambia el arreglo icons + íconos de shortcuts.)

C. Actualizar ManifestController.php con los mismos íconos.

D. Actualizar resources/views/app.blade.php:

  • <link rel="apple-touch-icon" href="/apple-touch-icon.png"> (PNG, no JPEG).
  • <link rel="icon" type="image/png" href="/icon-192.png"> o similar.
  • Mantener theme-color, application-name, apple-mobile-web-app-title como están.

E. Bump CACHE_VERSION a v6 en public/sw.js y agregar las nuevas URLs al PRECACHE_URLS. (Cuando los clientes vuelvan a abrir la app, el nuevo SW activa y refresca el manifest.)

F. Eliminar /public/app_icon.jpeg del manifest y de apple-touch-icon (puede quedarse en el repo como referencia o borrarlo). El JPEG en sí no es problema, solo lo es cómo se referencia.

Validación post-fix (en prod, después de deploy):

  • Chrome desktop: abrir DevTools → Application → Manifest. Debe decir “Installability: Page is installable” sin warnings. Aparecer el botón “Install” en la barra de URL.
  • Android Chrome: abrir el sitio, menú → “Instalar app” (o “Add to Home screen” con opción PWA). Verificar que el ícono del launcher es el correcto y el nombre dice “HOLBOX”.
  • iOS Safari: abrir el sitio, compartir → “Añadir a pantalla de inicio”. Verificar que el ícono es el PNG nuevo (no un placeholder gris).
  • Lighthouse: npm run dev o en prod, correr Lighthouse → PWA audit debe pasar la sección de “Installable”.

Pendiente decisión Sergio:

  • Confirmar opción A.a (sube los PNGs cuadrados generados externamente) vs A.b/A.c (autorizo instalar tooling para generarlos automáticamente, asumiendo que el resultado deformado/cortado es aceptable mientras llega una versión mejor).
  • Si Sergio sube los PNGs, los nombres/tamaños sugeridos están arriba. Aquí podemos pulir los pasos B-F del manifest y blade en paralelo o después.

2026-05-12 (PM) — Implementación del fix PWA

  • Sergio entregó un PNG de mejor calidad (source-icon.png, 2271×2380, RGBA con transparencia) y autorizó instalar ImageMagick. Sergio ejecutó sudo apt install -y imagemagick.
  • Íconos generados con ImageMagick desde source-icon.png:
    1. Padding transparente para cuadrar a 2380×2380 (/tmp/icon-square.png intermedio).
    2. public/icon-192.png — 192×192, RGBA, transparente.
    3. public/icon-512.png — 512×512, RGBA, transparente.
    4. public/icon-maskable-512.png — 512×512, RGB, ícono escalado a 410×410 (~80%) centrado sobre fondo blanco opaco (safe zone correcta para Android).
    5. public/apple-touch-icon.png — 180×180, RGB, fondo blanco opaco (iOS Safari requiere PNG; redondea esquinas automáticamente).
  • Cambios en archivos:
    • public/manifest.webmanifest — arreglo icons rehecho con 3 entradas (192 any, 512 any, 512 maskable) tipo image/png y sizes concretos. shortcuts actualizados a icon-192.png.
    • app/Http/Controllers/ManifestController.php — mismo cambio aplicado al manifest dinámico por sucursal (rutas /manifest/s/{token}).
    • resources/views/app.blade.php — reemplazo de <link rel="icon">, shortcut icon, apple-touch-icon por PNG con sizes declarados; quitado mask-icon (no usado).
    • public/sw.jsCACHE_VERSION v5 → v6, PRECACHE_URLS actualizado a los 4 PNG nuevos.
  • Cleanup (autorizado por Sergio):
    • public/source-icon.png → movido a resources/branding/source-icon.png (fuera del web root, no servido por HTTP, queda en git para regenerar tamaños después si cambia el design).
    • Borrados: public/app_icon.jpeg, public/icons/icon.svg, public/icons/icon-maskable.svg.
    • Verificado con grep que NO quedan referencias colgadas a esos archivos en .php/.vue/.js/.blade.php/.json/.webmanifest.
  • Validación pre-deploy: Chrome DevTools → Application → Manifest tras npm run build debería decir “Installability: Page is installable” sin warnings. Lighthouse PWA audit → pasa “Installable”.
  • Deploy ejecutado 2026-05-12 (PM): commit 61615d0 (“fix: make PWA installable with proper PNG icons in multiple sizes”), push a origin/main autorizado por Sergio → GitHub Actions corre deploy.yml.
  • Falta: validación en prod tras el deploy:
    • Chrome desktop: abrir el sitio, DevTools → Application → Manifest → debe decir “Installability: Page is installable” sin warnings; aparece botón “Install” en barra de URL.
    • Android Chrome: abrir el sitio, menú → “Instalar app”. Verificar ícono y nombre “HOLBOX” en el launcher.
    • iOS Safari: abrir el sitio, compartir → “Añadir a pantalla de inicio”. Verificar ícono nuevo (no placeholder).
    • Service worker: primera carga después del deploy debería instalar SW v6 y descartar caches v5/v4. (Si Sergio ya tenía la “PWA rota” instalada, conviene desinstalar + cerrar pestañas + reabrir para forzar el handshake nuevo.)

2026-05-12 (PM, segunda vuelta) — Validación parcial: desktop OK, Android sigue mostrando “Holbox POS”

Validación de Sergio post-deploy 61615d0:

  • Chrome desktop: ya se puede instalar correctamente.
  • Android Chrome: sigue saliendo el ícono y nombre viejo “Holbox POS”, aun después de chrome://settings → site settings → holbox.val-soft.com → clear all.

Investigación en prod (curl directo al server):

  • /manifest.webmanifest se sirve con el contenido NUEVO correcto: "name": "HOLBOX", "short_name": "HOLBOX", íconos PNG con sizes correctos. ✅
  • Los 4 íconos PNG están en /public/ con Content-Type: image/png correcto, todos 200 OK. ✅
  • ⚠️ Detalle pendiente: /manifest.webmanifest se sirve con Content-Type: application/octet-stream en lugar del esperado application/manifest+json. nginx no tiene .webmanifest registrado en /etc/nginx/mime.types. Chrome desktop lo aceptó igual; Android Chrome puede ser más estricto. Dejarlo como follow-up si el bug persiste.
  • 🚨 Causa raíz adicional encontrada en HTML servido:
    <meta name="application-name" content="Punto de Venta - Holbox">
    <meta name="apple-mobile-web-app-title" content="Holbox POS">
    El AppServiceProvider.php tiene un View::composer('app', ...) que inyecta pwaAppName y pwaShortName con los nombres viejos hardcoded, sobrescribiendo los defaults 'HOLBOX' que dejé en app.blade.php con ?? 'HOLBOX'. El blade nunca ejecuta el fallback porque las vars siempre vienen pobladas. Android Chrome usa esos meta tags para etiquetar la PWA en el launcher en algunos casos.

Fix aplicado:

  • AppServiceProvider.php — los hardcodes 'Punto de Venta - Holbox' / 'Holbox POS' reemplazados por 'HOLBOX' (caso default sin token + caso con token sin sucursal). El branch con sucursal preserva 'Holbox – '.$sucursal->nombre que es lo correcto.
  • Verificación con grep: ya no quedan referencias a "Holbox POS" ni "Punto de Venta - Holbox" en .php/.vue/.js/.blade.php/.json/.webmanifest del repo.

Deploy ejecutado 2026-05-12 (PM, segunda vuelta):

  • Commit 7cc0227 (“fix: align PWA meta tag names with new HOLBOX branding”) → push a origin/main → GitHub Actions deploya.

Instrucciones para Sergio (Android):

⚠️ chrome://settings → site settings → clear all NO desinstala la PWA del launcher. Solo borra cookies/localStorage/SW del navegador, pero la app instalada vive aparte como app independiente del sistema. Para que aparezcan el ícono y nombre nuevos:

  1. Desinstalar la PWA del launcher de Android:
    • Long-press el ícono “Holbox POS” en el launcher → “Desinstalar” / “Uninstall”.
    • O: Ajustes de Android → Apps → buscar “Holbox POS” → Desinstalar.
  2. Cerrar todas las pestañas de Chrome (limpia SW residente).
  3. Site settings → clear all (ya hecho, refuerza limpieza).
  4. Reabrir el sitio en Chrome.
  5. Menú de Chrome → “Instalar app” (o “Add to Home screen” → opción “Install”).
  6. Verificar que el ícono y nombre en el launcher digan “HOLBOX” con el ícono PNG nuevo.

Si después de todo eso sigue saliendo el nombre viejo, atacar el segundo issue: ajustar nginx para servir .webmanifest con Content-Type: application/manifest+json (requiere editar /etc/nginx/mime.types o un location block en el conf del sitio en el server holbox).

2026-05-12 (PM, tercera vuelta) — nginx Content-Type fix

Sergio confirmó: después del deploy 7cc0227, el desinstalar+reinstalar en Android siguió fallando (mostrando “Holbox POS”). Procede el fix de nginx.

Investigación en server holbox:

  • /etc/nginx/sites-enabled/laravel/etc/nginx/sites-available/laravel. Conf simple Laravel + Certbot, sin location para .webmanifest.
  • /etc/nginx/mime.types no tiene mapping para .webmanifest, por eso nginx devolvía application/octet-stream (default genérico).
  • nginx 1.24.0 (Ubuntu), php8.3-fpm. PHP-FPM via socket Unix.

Fix aplicado: agregado un location = /manifest.webmanifest block en /etc/nginx/sites-available/laravel (justo antes del location /):

location = /manifest.webmanifest {
    types { } default_type "application/manifest+json; charset=utf-8";
    try_files $uri =404;
}

types { } vacío descarta el lookup en mime.types y default_type aplica. try_files $uri =404 sirve solo si el archivo existe. Los add_header X-Frame-Options y X-Content-Type-Options del server block se heredan porque el location no define add_header propio.

Procedimiento ejecutado:

  1. Yo: escribí el conf modificado en local, lo subí vía scp a holbox:/tmp/laravel.nginx.new (no requiere sudo).
  2. Sergio: pegó en su terminal SSH:
    sudo cp /etc/nginx/sites-available/laravel /etc/nginx/sites-available/laravel.bak.$(date +%Y%m%d_%H%M%S)
    sudo cp /tmp/laravel.nginx.new /etc/nginx/sites-available/laravel
    sudo nginx -t
    sudo systemctl reload nginx
  3. Backup automático del conf viejo en /etc/nginx/sites-available/laravel.bak.<timestamp>.

Validación post-reload:

$ curl -sSI https://holbox.val-soft.com/manifest.webmanifest | grep -i content-type
Content-Type: application/manifest+json; charset=utf-8

✅ Mismo resultado vía DNS y vía IP directa (descarta CDN intermedio).

Solo aplica al manifest estático. Los manifests dinámicos por sucursal (/manifest/s/{token} vía ManifestController) ya devolvían el Content-Type correcto desde Laravel (response()->json($manifest, 200, ['Content-Type' => 'application/manifest+json; charset=utf-8'])).

Pendiente: Sergio reinstala la PWA en Android desde cero (long-press → Desinstalar; reabrir sitio; “Instalar app”) y valida que el nuevo ícono + nombre “HOLBOX” aparezcan.

2026-05-12 (PM, cuarta vuelta) — Causa final en Android: cache HTTP de Chrome

Sergio confirmó: después del fix de nginx Content-Type, el reinstall en Android siguió mostrando “Holbox POS”, aun habiendo desinstalado la PWA del launcher (long-press → Desinstalar) y borrado site data.

Diagnóstico por descarte: preguntas a Sergio confirmaron que (a) menú de Chrome muestra “Instalar app” → es WebAPK real, manifest reconocido como installable; (b) instala desde la raíz https://holbox.val-soft.com/ → manifest estático (no controller dinámico per-sucursal); (c) sí desinstaló del launcher antes de reinstalar. Entonces el server estaba bien y el flujo del usuario estaba bien.

Causa raíz final: cache HTTP del navegador Chrome a nivel de aplicación (no del sitio). “Clear site data” en Chrome borra cookies/localStorage/IndexedDB/SW del sitio, pero no toca el HTTP cache que Chrome mantiene a otro nivel. Cuando Chrome construye el WebAPK al hacer “Instalar app”, lee el manifest desde ese cache HTTP — que aún tenía la versión vieja con name: "Punto de Venta - Holbox" y short_name: "Holbox POS". El WebAPK queda generado con esos valores.

Fix aplicado por Sergio (sin tocar código):

  1. Chrome → Ajustes → Privacidad y seguridad → Borrar datos de navegación → pestaña Avanzado → “Todo el tiempo” → marcar SOLO “Imágenes y archivos en caché” → Borrar datos.
  2. Cerrar todas las pestañas de Chrome.
  3. Reabrir el sitio escribiendo la URL fresca (no autocomplete) → “Instalar app”.

Resultado: ✅ ícono nuevo + nombre “HOLBOX” en el launcher de Android.

Resumen del bug PWA — todo lo que tomó

Para referencia futura, este bug requirió 4 fixes en capas distintas:

  1. Manifest icons (commit 61615d0): JPEG sizes:"any" → PNG 192/512/maskable + apple-touch PNG. Manifest, ManifestController, blade, SW v6.
  2. PWA naming (commit 7cc0227): AppServiceProvider inyectaba “Holbox POS” / “Punto de Venta - Holbox” sobrescribiendo defaults nuevos del blade.
  3. nginx Content-Type (server, no commit): application/octet-streamapplication/manifest+json para .webmanifest. Backup en /etc/nginx/sites-available/laravel.bak.<timestamp>.
  4. Cache HTTP de Chrome (cliente, no código): Sergio limpia “Cached images and files” en Chrome Android, no solo site data.

Lección operativa: un solo síntoma reportado (“no se puede instalar / nombre viejo”) puede tener múltiples causas raíz acumuladas — una en frontend (manifest), otra en backend (provider), otra en infra (nginx MIME), otra en cliente (HTTP cache). Diagnóstico por capas, no salto a una solución.

Por qué fondo blanco para maskable y apple-touch:

  • maskable: Android le aplica máscara según el launcher. Con transparencia se ve raro porque el launcher pinta su propio fondo detrás. Blanco neutro funciona con cualquier estilo.
  • apple-touch: iOS Safari no respeta transparencia en el ícono de home — la pinta sobre el color del wallpaper. Blanco evita resultados impredecibles. iOS redondea las esquinas automáticamente.
  • Si Sergio prefiere otro color (ej. theme_color #2563eb azul corporativo), se regenera con un comando convert y se reemplazan los archivos. Sin cambios en manifest/blade.

2026-05-13 (PM-2) — Iteración del leaderboard tras feedback de Sergio

Pidió Sergio (4 cambios):

  1. Gerentes y admin también lo deben ver (no solo asociadas).
  2. Más visible, sin requerir click — debe estar expandido por default.
  3. Que NO ocupe espacio sobre el carrito de compras (el carrito está fixed a la derecha en POS/Index.vue).
  4. Color del header menos brillante (ya no el degradado ámbar→rosa).

+ Detalle nuevo del cliente Aarón: a la asociada con mejor ticket promedio de la semana se le va a dar un bono configurable por admin. Debe quedar claro en la UI que la #1 se gana ese bono (con monto visible).

Implementación:

  • Layout (AuthenticatedLayout.vue): quitado el v-if rol === 'asociada'. Ahora el widget aparece para todos los roles auth.
  • Posición (Leaderboard.vue): movido de bottom-3 right-3bottom-3 left-3. El carrito en POS está pegado a la derecha (col lateral), así que la izquierda queda libre.
  • Default expandido: collapsed = ref(false) (antes expanded = ref(false) que arrancaba minimizado). El usuario puede minimizar con el botón de header si quiere; al cargar siempre se ve completo.
  • Color header: from-amber-400 to-pink-500from-slate-800 to-slate-900. Profesional, no brillante, buen contraste con el texto blanco.
  • Bono al #1:
    • Backend: LeaderboardController lee Configuracion::get('bono_lider_semanal', 0) y lo retorna en bono_lider del JSON.
    • Config: key bono_lider_semanal agregada a ConfiguracionController::index() (lista de keys leídas) y a store() (regla de validación required|numeric|min:0, solo admin, como tipo_de_cambio).
    • UI Config: Configuracion/Index.vue — agregado campo “Bono semanal a la #1 del leaderboard ($)” en el grid de configuraciones, con v-if rol === 'admin' y borde ámbar para destacar. Texto helper: “Bono que recibe la asociada con mayor ticket promedio cada semana (lun-dom). Aparece en el leaderboard. 0 = sin bono.”
    • UI Leaderboard: banner ámbar bajo el header cuando bono > 0: 🎁 “Bono semanal a la #1” + monto formateado. Adicionalmente, la fila #1 trae un chip dorado pequeño ”+$X” pegado al medal 🥇, en pantallas sm: y arriba (oculto en muy pequeño para no romper layout). Tooltip con el monto completo. La fila #1 ahora siempre tiene fondo ámbar suave aunque no sea “tú”.
    • Si el bono es 0 (default antes de que admin lo configure), no se muestra banner ni chip — leaderboard funciona igual sin él.

Decisiones de UX que tomé:

  • Mantuve el botón de “minimizar” en el header (icono de menos) para que el usuario pueda colapsar si lo encuentra estorbante. Default sigue siendo expandido en cada carga.
  • En estado colapsado: pill slate-800 con tu posición + promedio (igual que antes, solo el color cambió).
  • Resalto de “tú”: antes era el background entero ámbar. Ahora uso ring-2 ring-inset ring-amber-400 para que la fila tenga un borde dorado en lugar de pelearse con el fondo del #1. Si tú eres la #1, ves fondo dorado + ring dorado (doble énfasis ganado).

Tests (LeaderboardControllerTest.php) — 2 casos nuevos:

  • devuelve bono_lider desde Configuracion — set 750.50, espera 750.5 en JSON.
  • bono_lider default es 0 cuando no hay config — sin Configuracion::set, espera 0.

Resultado: 8/8 pasan. Pint OK sin cambios.

Archivos tocados en esta iteración (6 archivos, todos modificaciones — sin nuevos):

  • app/Http/Controllers/LeaderboardController.php (+5 líneas)
  • app/Http/Controllers/ConfiguracionController.php (+2 líneas)
  • resources/js/Components/Leaderboard.vue (reescrito)
  • resources/js/Layouts/AuthenticatedLayout.vue (-1 condición)
  • resources/js/Pages/Configuracion/Index.vue (+1 campo form)
  • tests/Feature/LeaderboardControllerTest.php (+2 tests)

Sin migraciones. Configuracion ya es key/value, así que el bono no requiere schema change.

Pendiente Sergio:

  • Autorizar push a origin/main → dispara deploy.yml.
  • Tras deploy: setear bono_lider_semanal en /configuracion (solo visible si el usuario es admin). Default 0 = banner del bono no aparece.

2026-05-13 (PM-3) — Feature flag para preview de Aaron

Pidió Sergio: que Aaron pueda darle un vistazo al leaderboard antes de soltarlo a todos los usuarios. Le ofrecí 4 opciones (feature flag por rol / whitelist por user_id / tunnel local / push directo) y eligió feature flag por rol.

Implementación:

  • Key nueva leaderboard_activo (boolean) en Configuracion, default false. Agregada a ConfiguracionController::index (lista de keys) y a store con validación required|boolean para admin.
  • Compartido vía Inertia HandleInertiaRequests::share — expone leaderboardActivo: (bool) Configuracion::get('leaderboard_activo', false) en todos los page props. 1 query extra por request; trivial. Si pega el rendimiento, agregar Cache::remember.
  • Mount del widget AuthenticatedLayout.vue — condicional: v-if="auth.user && (leaderboardActivo || auth.user.rol === 'admin')". Resultado:
    • Flag OFF + rol admin → widget visible (Aaron preview).
    • Flag OFF + rol asociada/gerente → NO ven nada.
    • Flag ON + cualquier rol auth → todos lo ven.
  • Badge “PREVIEW” Leaderboard.vue — chip ámbar bg-amber-400 text-amber-950 en el header del panel, visible solo cuando admin Y flag off (computed modoPreview). Tooltip aclara: “El leaderboard está oculto para asociadas y gerentes. Actívalo en /configuracion cuando esté listo.” Aaron se da cuenta visualmente de que está en preview y no se confunde pensando que ya está live.
  • Toggle en config Configuracion/Index.vue — checkbox “Leaderboard visible para asociadas y gerentes”, v-if rol=admin, ocupa md:col-span-2. Texto helper explica el modo preview. Se castea Boolean(Number(...)) al inicializar porque Configuracion::get puede devolver “0”/“1” como string.

Tests: existentes ConfiguracionControllerTest actualizado para mandar bono_lider_semanal + leaderboard_activo en el payload (ahora son requeridos para admin). LeaderboardControllerTest sigue 8/8. Total relevante 10/10.

Flujo operativo para Sergio:

  1. Push del commit (sin esta autorización, no hay deploy).
  2. Tras deploy: el flag empieza en false (default cuando la key no existe en configuraciones). Aaron entra como admin → ve el widget con badge “PREVIEW” en bottom-left. Asociadas y gerentes NO ven nada.
  3. Aaron juega con el widget, decide si quiere ajustes; si pide cambios, iteramos.
  4. Cuando Aaron diga OK, en /configuracion activas el toggle “Leaderboard visible para asociadas y gerentes” + setear bono_lider_semanal. Save. Inmediatamente todas las cuentas auth lo ven.
  5. Si Aaron decide apagarlo después, toggle off y vuelve al modo preview (solo admins).

Archivos modificados (5):

  • app/Http/Controllers/ConfiguracionController.php — agregar key + regla.
  • app/Http/Middleware/HandleInertiaRequests.php — compartir flag a Inertia.
  • resources/js/Layouts/AuthenticatedLayout.vue — condición de montaje.
  • resources/js/Components/Leaderboard.vue — badge PREVIEW + computeds.
  • resources/js/Pages/Configuracion/Index.vue — toggle UI.
  • tests/Feature/ConfiguracionControllerTest.php — payload actualizado.

Sin migraciones (Configuracion es key/value).

2026-05-13 — Pendientes capturados sin implementar (esperando imágenes)

Sergio pasará imágenes que muestran el diseño que Aarón quiere para 2 features nuevas. No implemento nada hasta verlas. Captura inicial:

  1. Captura de aumentos de optometría por cliente. Flujo: cliente compra lentes con optometría → va a óptica externa → óptica devuelve datos del examen (aumento, etc.) al admin → admin captura en sistema. En la siguiente compra, POS muestra los datos a la asociada (validar con el cliente) e imprime en el ticket. Modelado tentativo (a confirmar con imagen):

    • Tabla cliente_optometria_aumentos (FK a clientes.id, capturado_por = user_id, fecha del examen, campos del examen).
    • UI captura en Clientes/Show.vue o tab nuevo (depende del form que muestre la imagen).
    • Lectura en PosController::index al seleccionar cliente → pasar a Vue.
    • Render en pos/ticket/{venta} si el cliente tiene aumentos vigentes.
    • Preguntar a Sergio antes de empezar: ¿los aumentos caducan? ¿historial completo o solo el último? ¿imprime SIEMPRE en ticket o solo cuando es nueva compra de optometría?
  2. Emails de recordatorio de cupones por fidelidad. Cliente alcanza nivel de fidelidad → se genera CodigoDescuento con vigencia. Aarón quiere mandar correos recordatorio antes de que el cupón venza. Implementación tentativa (a confirmar con imagen):

    • Comando artisan + scheduler (app/Console/Kernel.php o bootstrap/app.php en Laravel 12) que corre diario y busca CodigoDescuento con usado=false próximos a expirar.
    • Mailable nuevo + plantilla blade siguiendo el diseño de la imagen.
    • Decisiones operativas: ¿cuántos días antes mandar? (típico: 7 días antes + 1 día antes). ¿Un solo correo o serie? ¿Reactivar LoyaltyBreakpointMail (ya existe) o crear nueva? Verificar con imagen.

Cuando Sergio mande las imágenes, las leo (vía drag-drop a Antigravity CLI o ruta absoluta) y armamos diseño técnico antes de tocar código.

2026-05-13 (PM-4) — Leaderboard: mostrar todas, scrollable

Pidió Sergio: que aparezcan todas las empleadas (no solo top 5), sin que crezca el componente — lista scrollable.

Implementación: quitado slice(0, 5) y la fila duplicada de “tú al final” (ya no necesaria si todas aparecen). El contenedor de la tabla pasó de max-h-80 a max-h-60 para mantener el panel compacto, y thead ahora es sticky top-0 con z-10 + shadow-sm para que el encabezado quede visible al scrollear. Si hay 3 vendedoras se ve la lista entera sin scroll; si hay 20, la lista scrollea dentro del panel.

1 archivo: resources/js/Components/Leaderboard.vue. 8/8 tests siguen pasando.

2026-05-13 (PM-5) — Bonos premium configurables desde UI (CRUD)

Pidió Sergio (cliente Aarón): las comisiones por unidades premium ya no caben en los 3 escalones fijos (4/6/8). Quiere una tabla configurable con n escalones; deshacerse de los inputs fijos.

Modelo nuevo: BonoPremium (app/Models/BonoPremium.php) con unidades_minimo (int, único) + monto (decimal 10,2). Casts apropiados.

Migración (2026_05_13_180000_create_bonos_premium_table.php) — tabla bonos_premium con unique index en unidades_minimo. Inmediatamente después corre migración de datos (2026_05_13_180100_seed_bonos_premium_from_configuracion.php) que:

  • Lee comision_4_premium, comision_6_premium, comision_8_premium de configuraciones (defaults 300/500/800).
  • Hace BonoPremium::updateOrCreate para 4, 6, 8 con esos montos.
  • Borra las 3 keys de configuraciones (ya migradas).
  • down() restituye las 3 keys desde las filas (reversible).

Cálculo en reporte (ReporteController::comisiones):

  • Antes: if/elseif/elseif con $bono4/$bono6/$bono8 hardcoded.
  • Ahora: $bonosPremium = BonoPremium::orderBy('unidades_minimo', 'desc')->get() + $bonosPremium->first(fn ($b) => $premiumVendidos >= $b->unidades_minimo)?->monto ?? 0. Mismo comportamiento “escalón más alto alcanzado, no acumulativo”, pero ahora soporta N escalones arbitrarios.
  • El esquema.bono_premium que se pasa a Inertia ahora viene de las filas (sortBy unidades_minimo asc). Comisiones.vue ya iteraba dinámicamente sobre ese array — no cambió.

Controller CRUD (app/Http/Controllers/BonoPremiumController.php) — store, update, destroy. Validación: unidades_minimo integer >=1, unique (ignorando self en update). monto numérico >=0. Patrón idéntico a NivelFidelidadController.

Rutas (routes/web.php) — POST /bonos-premium, PATCH /bonos-premium/{bonoPremium}, DELETE /bonos-premium/{bonoPremium}, todas en el grupo role:admin,gerente.

UI (resources/js/Pages/Configuracion/Index.vue):

  • Quitados 3 inputs hardcoded “Bono 4/6/8 Premium” del formulario de Variables Generales.
  • Sección nueva “Bonos por Lentes Premium” entre Variables Generales y Niveles de Fidelidad, con el mismo layout de NivelFidelidad: form en columna izquierda (unidades + monto) + lista en columna derecha (escalón + botón eliminar). Explicación en header: “Bono escalonado por la cantidad de lentes premium vendidos en la semana. La asociada recibe el monto del escalón más alto alcanzado (no acumulativo). Ej: 4 unidades = $300, 6 = $500, 8 = $800, 10 = $1200…”
  • Limitación actual: no hay edición inline — para cambiar el monto de un escalón hay que eliminar + agregar. El controller ya soporta update, pero la UI lo dejé out-of-scope para mantenerlo simple. Si Aarón lo pide, agregar botón “Editar” que rellena el form arriba.

ConfiguracionController — quitadas las 3 keys del array $keys y de las reglas de validación. Pasa la lista de BonoPremium ordenada asc a Inertia.

Tests (tests/Feature/ReporteControllerTest.php):

  • Existing calcula comisiones base y bonos premium correctamente — actualizado para usar BonoPremium en lugar de Configuracion, y agregada es_premium=true a la categoría (faltaba). Antes fallaba como string vs int preexistente; ahora pasa.
  • Nuevo: bono premium usa el escalón más alto alcanzado y la tabla bonos_premium es configurable — crea 4 escalones (4, 6, 10, 12), vende 11 unidades premium, espera bono $1200 (escalón 10). Cubre el caso de N escalones arbitrarios.

Resultado: 23/23 pasan en suite relevante. Suite global: 35 fails (down 1 desde baseline 36) / 106 passes (up 4). 0 regresiones nuevas.

Pint ajustó imports + trailing commas + agregó use Inertia\Testing\AssertableInertia al test (estaba fully qualified).

Archivos modificados:

  • Nuevos: app/Http/Controllers/BonoPremiumController.php, app/Models/BonoPremium.php, 2 migrations.
  • Modificados: routes/web.php, app/Http/Controllers/ConfiguracionController.php, app/Http/Controllers/ReporteController.php, resources/js/Pages/Configuracion/Index.vue, tests/Feature/ReporteControllerTest.php.

Cuidado operativo:

  • Mismo principio del ANTIGRAVITY.md de Holbox: si Aarón cambia escalones en vivo, el delta del reporte semanal en curso se recalcula con la nueva config. Aarón debe avisar antes a las asociadas si modifica algo que las afecte.
  • La migration borra las 3 keys viejas de configuraciones. Si por alguna razón hay que hacer rollback, php artisan migrate:rollback --step=2 restituye keys + dropea la tabla.

2026-05-13 (PM-6) — Fix: Leaderboard ilegible en dark mode

Pidió Sergio: en dark mode el texto del tbody del leaderboard no se distingue. Regla nueva permanente: “para cambios estéticos, siempre tomar en cuenta el dark mode”.

Causa raíz: bg-white dark:bg-gray-900 definido en el contenedor del panel pero sin emparejar text-* con variante dark:. En light hereda el negro default del navegador y se ve bien; en dark queda negro sobre gris oscuro → ilegible.

Fix (Leaderboard.vue):

  • Contenedor: añadido text-gray-800 dark:text-gray-100 al panel expandido.
  • Tbody: idem, para que las filas del ranking hereden texto legible.

Regla guardada en memoria persistente: feedback_dark_mode_aesthetics — aplicable a TODOS los cambios estéticos de aquí en adelante. Emparejar fondos y texto con sus variantes dark: siempre.

2026-05-13 (PM-7) — Captura de optometría por cliente

Pidió Aarón: form para que el admin capture la info de optometría del cliente (recibida de la óptica externa). En la próxima compra, mostrar a la asociada para validar + imprimir en el ticket. Sergio pasó imagen formaoptometria.jpeg con el diseño.

Decisiones confirmadas con Sergio (AskUserQuestion):

  • Historial completo (no un solo registro). Tabla con FK a clientes, lista en UI admin.
  • Ticket imprime graduación SOLO si la venta incluye optometría (tipo_optometria !== null en algún detalle). Tickets de productos sin optometría salen limpios.
  • Todos los campos del examen son opcionales.

Schema (2026_05_13_190000_create_optometrias_table.php):

  • optometrias: id, cliente_id (FK cascadeOnDelete), fecha (date nullable), folio_referencia (string nullable).
  • OD: od_esfera/cilindro/adicion (decimal 5,2 nullable, permite negativos), od_eje (smallint nullable 0-180).
  • OI: idem 4 columnas.
  • DP: dp_binocular, dp_monocular (decimal 5,2 nullable, mm).
  • observaciones (text), capturado_por (FK users nullOnDelete).
  • Index en (cliente_id, fecha) para ORDER BY del último examen.

Modelo Optometria.php con casts y relación cliente, capturadoPor. Cliente recibe hasMany(Optometria) ordenada por fecha desc, e ultimaOptometria() helper.

Controller + rutas (OptometriaController.php) bajo middleware role:admin:

  • GET /clientes/{cliente}/optometrias — vista Inertia con form + historial.
  • POST /clientes/{cliente}/optometrias, PATCH /optometrias/{optometria}, DELETE /optometrias/{optometria}.
  • Validación: todos opcionales, eje 0-180, esferas/cilindros entre -30 y 30 (rango clínico amplio).

UI (Optometrias/Index.vue) replica el form de la imagen: header slate-800 con título “PLANTILLA OPTOMETRÍA — POS”, grids OD/OI lado a lado, sección DP, observaciones, botones Cancelar/Guardar/Actualizar. Dark mode incluido en cada elemento (regla nueva). Edit inline (botón “Editar” en historial → llena el form arriba). Acceso desde Clientes/Index.vue con icono de lentes (solo visible para admin).

Integración POS (PosController.php + POS/Index.vue):

  • Controller eager-loadea ultima_optometria por cliente.
  • Cuando hay cliente seleccionado y tiene optometría → banda ámbar entre header y contenido del POS muestra OD/OI/DP en formato compacto con el mensaje “Graduación · validar con cliente”. Solo aparece en pasoActivo=‘venta’, no estorba selección de cliente ni cobro.

Integración Ticket (PosController::ticket + POS/Ticket.vue):

  • Controller calcula $tieneOptometria viendo si algún detalle tiene tipo_optometria !== null. Si sí, pasa ultimaOptometria del cliente.
  • Vista: sección nueva entre productos y totales, tabla compacta OD/OI con SPH/CYL/AXIS/ADD, línea DP, observaciones si las hay. Solo aparece cuando optometria !== null.

Tests (OptometriaControllerTest.php) — 6 casos:

  1. Solo admin accede (asociada y gerente reciben 403).
  2. Store guarda con todos los campos.
  3. Todos opcionales — guarda registro vacío.
  4. Eje fuera de 0-180 falla validación.
  5. Update + destroy.
  6. Ticket incluye optometría solo si la venta tiene tipo_optometria.

6/6 pasan. 0 regresiones nuevas.

2026-05-13 (PM-8) — Recordatorios de cupones por email

Pidió Aarón: cuando un cliente tiene cupón de descuento por fidelidad, mandar correos recordatorios. Sergio pasó imagen recordatoriosdescuentos.jpeg con 5 mockups (días 3/7/15/25/30) con asuntos y cuerpos.

Decisiones confirmadas con Sergio:

  • Base de tiempo: días desde que se generó el cupón (3/7/15/25/30 días tras created_at). Asume vigencia estándar 30 días. Si Aarón usa vigencias variables en el futuro, switch a “días antes de expirar” — pero por ahora no aplica.
  • Branding: reusar el layout existente de emails (emails/layout.blade.php) para mantener consistencia con LoyaltyBreakpointMail y los demás transaccionales.

Schema (2026_05_13_200000_add_recordatorios_enviados_to_codigos_descuento.php):

  • Columna recordatorios_enviados (JSON nullable) en codigos_descuento. Guarda array de los días ya notificados, ej [3, 7]. Idempotencia.
  • Modelo CodigoDescuento agrega recordatorios_enviados al fillable + cast array.

Mailable (DescuentoRecordatorioMail.php):

  • Recibe CodigoDescuento + int $dia.
  • Método estático contenidoPorDia(int) retorna ['asunto', 'encabezado', 'cuerpo', 'callout', 'cierre'] con los textos LITERALES de la imagen para cada día. Throws en día no soportado.
  • Método estático diasRecordatorio() lista los 5 puntos.

Plantilla blade (emails/descuento_recordatorio.blade.php):

  • @extends('emails.layout') — branding HOLBOX completo.
  • Card central con código de cupón en monospace + % descuento + vigencia.
  • Card beige con sección “RECUERDA” usando el callout específico del día (ej. “No lo dejes para después. Tu beneficio está por expirar.” para día 25).

Comando artisan (EnviarRecordatoriosDescuentos.php):

  • Signature: descuentos:enviar-recordatorios {--dry-run}.
  • Query: usado=false AND DATE(valido_hasta) >= hoy_tz (TZ del negocio America/Denver, no UTC).
  • Para cada cupón: calcula diffInDays(created_at_tz, hoy_tz) como entero, valida que esté en [3,7,15,25,30] y NO en recordatorios_enviados. Filtra clientes sin email.
  • Manda Mailable, agrega el día al array recordatorios_enviados y save(). Try/catch para errores SMTP individuales.
  • --dry-run: lista qué mandaría pero no envía ni marca.

Scheduler (bootstrap/app.php):

  • $schedule->command('descuentos:enviar-recordatorios')->dailyAt('09:00')->timezone(config('app.report_timezone')). Una hora después de cortes:notify-pendientes.

Tests (EnviarRecordatoriosDescuentosTest.php) — 10 casos:

  1. Manda en cada uno de los 5 días.
  2. NO manda en día 4 (entre 3 y 7).
  3. NO duplica si ya está marcado en recordatorios_enviados.
  4. Marca el día tras envío exitoso (con re-corrida que confirma idempotencia).
  5. Ignora cupones usados.
  6. Ignora cupones expirados (TZ-aware: usa Carbon::today(report_timezone)->subDays(2)).
  7. Ignora clientes sin email.
  8. Contenido por día — verifica encabezados literales.
  9. Día no soportado → InvalidArgumentException.
  10. --dry-run no envía ni marca.

10/10 pasan.

Gotcha de TZ documentado: durante tests encontré que la columna valido_hasta se serializa con datetime ('2026-05-13 00:25:45') en lugar de date pura, y la app corre en America/Denver mientras los tests usan now() UTC default — diferencia de ~6h que puede colocar “ayer UTC” en “hoy Denver”. Fix en el comando: usar whereDate(...) para comparar fechas puras. Fix en tests: usar Carbon::today(config('app.report_timezone')) cuando se quiere “ayer business” determinístico.

Pint: reformateó imports + trailing commas en 3 archivos. Sin cambios funcionales.

Suite global tras estos 4 commits acumulados: 35 fails (igual baseline) / 122 pass (+16 vs antes = 6 optometría + 10 recordatorios). 0 regresiones nuevas.

2026-05-13 (PM-9) — Fix: quick-add de cliente en POS sin mensajes de validación

Pidió Sergio: “En holbox: No se puede registrar a los clientes despues del ultimo cambio”. Tras inspección dijo “Parece ser un problema de validacion, puedes incluir mensajes de validacion en la forma de agregar cliente?”.

Diagnóstico: POS/Index.vue tiene dos formas que usan useForm:

  • formEditarCliente (modal de edición) — ya renderizaba formEditarCliente.errors.* con <p v-if class="text-red-500"> bajo cada input.
  • formNuevoCliente (quick-add lateral en el paso “cliente” del POS) — no renderizaba ningún error. Cuando el backend respondía 422, Inertia poblaba formNuevoCliente.errors pero la UI no lo mostraba y la asociada veía el form quedarse igual sin pista de qué falló.

Causas más probables del 422 silencioso:

  1. Email duplicado — la regla email|nullable|email|max:255|unique:clientes en PosController::storeCliente rechaza si ya existe un cliente con ese correo. Caso típico: la asociada intenta registrar un cliente que ya está en la BD bajo otro nombre.
  2. Email mal formado — la regla email rechaza strings sin @ o con espacios.
  3. Teléfono >20 caracteres (raro pero posible si pegan un texto).
  4. Nombre/apellido vacíos — el botón ya está disabled si faltan, así que no debería llegar al server, pero por completitud lo cubrimos.

Fix aplicado:

Bajo cada input (nombre, apellido, telefono, email) y bajo el par de selects de cumpleaños:

  • <p v-if="formNuevoCliente.errors.X" class="text-[11px] font-bold text-red-600 dark:text-red-400">{{ ... }}</p> — mensaje literal del backend.
  • :class="[...]" array binding que toggle el border-color a border-red-500 cuando el campo tiene error (con dark:border-red-500/70 para dark mode, siguiendo la regla de [[feedback_dark_mode_aesthetics]]).

Arriba del form, banner global:

<div v-if="formNuevoCliente.hasErrors"
     class="rounded-xl border-2 border-red-300 bg-red-50 p-3 text-xs font-bold text-red-700
            dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
    Revisa los campos marcados antes de continuar.
</div>

Para que no se escape si el panel está scrolleado.

Nota sobre Inertia useForm: useForm.hasErrors y useForm.errors se resetean automáticamente cuando el siguiente POST tiene éxito o cuando se llama form.clearErrors(). No hay que limpiar a mano en onSuccess.

Build local falló por Node 18 (Vite v7 requires Node 20+). CI (Ubuntu latest) usa Node 20.x según GHA defaults, así que el deploy compila bien. Cambio es 100% template, sin imports nuevos ni cambios en <script setup>.

Pendiente al pushar: confirmar con Sergio que ya ve los errores cuando intenta registrar el cliente que estaba fallando. Si el error es “email already taken”, el siguiente paso lógico es ofrecer “Ya existe un cliente con ese email, ¿lo seleccionas?” en lugar de solo rechazar — pero eso es una mejora futura, no parte de este fix.

Pushed: commit 856e8aa directo a main, deploy GHA arrancado. Al cierre de sesión Sergio aún no había confirmado con la sucursal — quedó como pendiente.

2026-05-27 — #161 Entrega 1: Reporte semanal por empleada

Scope: 10 KPIs por asociada (esta semana vs semana pasada, variación %), LeaderboardService extraído, migración productos.es_addon, UI admin en Create/Edit de productos.

Archivos nuevos (5):

  • app/Services/LeaderboardService.php — extrae lógica de ranking de LeaderboardController. Firma: rankingForWeek(Carbon $startUtc, Carbon $endUtc): Collection[user_id => posicion].
  • app/Http/Controllers/Reportes/SemanalEmpleadaController.php — índex con filtros ?week=YYYY-Www&empleada_id=N. 3 queries por periodo (ventas/detalles/conversión premium) para evitar duplicados por JOIN.
  • database/migrations/2026_05_27_130000_add_es_addon_to_productos_table.php — boolean es_addon default false, after puede_ser_regalo. Reversible.
  • resources/js/Pages/Reportes/SemanalEmpleada.vue — tarjetas por empleada, tabla 3 columnas (esta/pasada/Δ%), selectores week (input HTML5) + empleada.
  • tests/Feature/Reportes/SemanalEmpleadaTest.php — 7 tests Pest, todos verdes.

Archivos modificados (7):

  • app/Models/Producto.phpes_addon en $fillable + cast boolean.
  • app/Http/Controllers/LeaderboardController.php — refactorizado para usar LeaderboardService; comportamiento y respuesta JSON idénticos.
  • app/Http/Controllers/ProductoController.phpes_addon en reglas de validación de store y update.
  • app/Support/LocalDateRange.php — nuevos métodos weekRange(?Carbon) y previousWeekRange(?Carbon).
  • resources/js/Pages/Productos/Create.vue y Edit.vue — checkbox “Es add-on / upsell” en panel de configuración, dark mode pareado (violet).
  • routes/web.php — ruta GET /reportes/semanal-empleada en grupo role:admin,gerente.

Gotcha resuelto: el primer diseño del controller hacía JOIN venta_detalles + ventas + productos y calculaba SUM(ventas.total) ahí — lo cual duplicaba el total de cada venta por N líneas. Se separó en 2 queries: Q1 ventas (totales/tickets) + Q2 detalles (artículos por categoría). También, Categoria::create(['id' => 9]) es ignorado en SQLite por autoincrement; los tests usan DB::table('categorias')->insert(['id' => 9, ...]) para forzar el id.

Suite: 345 passed / 28 failed. Baseline era 322/38 (los 28 fallos son subset de los 38 preexistentes, sin regresiones). Commit: 1350caa. Push pendiente de autorización de Sergio.

Mejora paralela detectada: mensajes de validación en inglés. Hoy APP_LOCALE=en y no hay lang/, así que el banner muestra literal lo que Laravel devuelve (The email has already been taken). Apuntado como tarea pendiente — ver sección “Tareas pendientes” arriba.

2026-05-29 (PM) — Rediseño estético y responsivo (Fase 1 CRM)

Sergio pidió: Mejorar de forma integral el diseño de toda la Fase 1 del CRM de Holbox, asegurando adaptabilidad móvil completa (comportamiento WhatsApp), optimización en envío de mensajes, apariencia intuitiva, premium y profesional, y con estricta compatibilidad con Chrome 109 / Windows 7 (evitando OKLCH).

Cambios e Implementación:

  1. Estructura Responsiva en /admin/conversaciones (Index.vue):

    • Agregada variable reactiva mostrarFicha (Desktop/Móvil) y método volver().
    • Grid responsivo dinámico: en móvil, si una conversación está seleccionada, la columna de la bandeja (lista) se oculta automáticamente para dar espacio completo al hilo. Al pulsar “Volver” (), se deselecciona y se muestra de nuevo la lista.
    • En móvil, si mostrarFicha está activo, se renderiza la FichaContacto como un drawer/cajón absolute flotante por encima del chat, permitiendo su cierre dinámico. En desktop, la ficha actúa como la tercera columna colapsable, permitiendo al usuario ocultarla para centrarse al 100% en el chat.
  2. Bandeja con Búsqueda Reactiva (ConversacionLista.vue):

    • Incorporada barra de búsqueda de chats en tiempo real con un botón de borrado dinámico (filtra por nombre, teléfono o vista previa del último mensaje sin peticiones al servidor).
    • Sustituidos listados planos por tarjetas flotantes redondeadas con márgenes, dotadas de animaciones de hover y selección premium.
    • Avatares dinámicos basados en iniciales con un generador de gradientes determinísticos por cadena (bg-gradient-to-br) adaptados a sRGB.
    • Cabeceras de estado pegajosas con puntos dinámicos (ámbar pulsante para “Esperando”, violeta para “Tomadas” y pizarra para “Archivadas”).
    • Animación de pulso continuo en el indicador de mensajes no leídos.
  3. Hilo del Chat Moderno (ConversacionHilo.vue):

    • Añadido botón de retroceso () para navegación móvil y clic en el nombre en el header para abrir/cerrar ficha.
    • Cuadrícula de fondo sutil tipo dot-grid (chat-grid-bg) autoadaptable a modo claro y modo oscuro.
    • Burbujas de chat rediseñadas con formas orgánicas, cortes específicos en esquinas (rounded-tr-none para salida y rounded-tl-none para entrada) y sombras suaves.
    • Estatus en texto crudo reemplazados por checks SVG dinámicos idénticos a WhatsApp (⏱, ✓ simple gris, ✓✓ doble gris y ✓✓ doble esmeralda/azul para leído, y alerta de error).
    • Textarea rediseñada con botón de envío circular que incorpora un avión de papel rotado dinámico. Envío por Enter y multilínea por Shift+Enter.
    • Polida la tarjeta de advertencia por expiración de la ventana de 24h Meta.
  4. Ficha Dashboard Premium (FichaContacto.vue):

    • Agregado botón de cierre () para móvil en la cabecera.
    • Nivel de fidelidad, compras y montos estructurados en grids elegantes con tipografías legibles y bordes curvos.
    • Historial de compras formateado en tabla con tipografía monoespaciada de números tabulares para evitar saltos visuales.
    • Botón “Convertir a cliente” estilizado en azul cielo, con spinner animado e iconos minimalistas para llamadas asíncronas.

Verificación:

  • Ejecutado npm run build en entorno de producción local a través de Laravel Sail con éxito total (compilación limpia del cliente y SSR de Inertia en 11.26s y 3.10s respectivamente).
  • Las pruebas Pest heredadas mantienen los mismos códigos de error del entorno sin introducir ninguna regresión.
  • Se mantiene el diseño sRGB optimizado para compatibilidad total con Windows 7 y Chrome 109.