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:
- Comisión base =
ventas_totales × comision_porcentaje(config keycomision_porcentaje, default 3%). - 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)
- 4-5 lentes premium →
- “Premium” se identifica por flag booleana
categorias.es_premium = trueen 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'⇒ ambosNULL.tipo='por_paquete'⇒unidades_por_paquete NOT NULLy> 0.tipo='por_unidad'⇒unidades_por_paquete NULL.
Las 4 reglas a poblar
| # | Regla | Trigger | FK | Tipo | Monto | Paquete | Cálculo |
|---|---|---|---|---|---|---|---|
| 1 | Optometría | addon_optometria | — | por_unidad | $100 | — | $100 × líneas-de-venta con cualquiera de los 3 tipos de optometría aplicados |
| 2 | Categoría Golden | categoria | categoria_id=<Golden> | por_unidad | $50 | — | $50 × unidades vendidas |
| 3 | Categoría Bamboo Clásico | categoria | categoria_id=<Bamboo Clásico> | por_paquete | $100 | 6 | $100 × floor(unidades / 6) |
| 4 | Modelos Ixchel / Kin | producto | 3 filas, una por SKU | por_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-001A— IxchelCJ-0001B— Ixchel SunriseCJ-006C— Kin 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=3Bamboo Golden, id=6Bamboo Premium Golden(Sergio confirmó “Golden” aplica a ambas). - (2026-05-11) Confirmar los 3 SKUs Ixchel/Kin → confirmados en prod: id=42
CJ-001AIxchel, id=41CJ-0001BIxchel Sunrise, id=36CJ-006CKin Sunrise. - (2026-05-11) Identificar cómo se modela el add-on de optometría → columnas
tipo_optometria(nullable string) +precio_optometria(decimal) enventa_detalles(PosController.php:185). - (2026-05-11) Confirmar los 3 tipos de optometría →
antireflejante,tinte,transitioncon 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, GHA26499410106. 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/edity/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í:
- Sale price “intra-categoría” — los productos de la cat #9 aplican su
precio_salesólo cuando la venta lleva 2+ unidades de esa misma categoría. Una sola unidad de #9 (sin acompañante intra) queda enprecio_regular. (Hoy el disparador genérico deprecio_salese basa entotalLentesCount ≥ 2de cualquier categoría concuenta_para_precio_sale=true; eso ya no aplica para cat #9.) - 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
categoriassólo tieneprecio_sale. - Override por producto — un modelo en particular dentro de la cat #9 debe tener
precio_regularpropio + 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
categoriasconprecio_sale_mixto(decimal nullable) que se aplica en venta mixta; mantenerprecio_salepara la regla 1. Migración + UI mínima en/categorias/{id}/edit. - Opción B — tabla nueva
categoria_precios_condicionalescontipo ∈ {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_regularpor producto (PosController::calcularSubtotalCarrito). Confirmar si soporta tambiénprecio_salepor producto o sóloprecio_regular; si no, agregarproductos.precio_sale_propio(yprecio_sale_mixto_propiosi Opción A).
- Opción A — extender
- 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 catcuenta_para_precio_sale=true?) y resuelva qué sale aplicar por línea de venta.- Frontend POS
subtotalProductosespejea la lógica del backend (cliente no debe ver un precio mientras backend cobra otro). cargarSolicitudAlPOSy cualquier path que reusenuevaLineaProducto(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:
- ¿Cuáles son los dos sale prices (regla 1 vs regla 2)? Hoy
precio_salede la cat #9 ya tiene un valor — ¿el nuevo “mixto” es mayor o menor? - ¿Cuál es el modelo particular que debe tener precio propio? SKU + nombre +
precio_regular+ sus 2 sale prices. - ¿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.)
- 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.)
- 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.
- ¿Cuáles son los dos sale prices (regla 1 vs regla 2)? Hoy
- Interacción con
cuenta_para_precio_sale: la categoría #9 hoy tiene el flagtrue. 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 flagtrueel 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_v3con cuerpo enriquecido aprobado y deployado (saludo personalizado + modelo + folio + garantía). 3 commits deployados:ce93f6a(estructura inicial con modelo + folio, GHA26067189002),c76b96e(agrega nombre del cliente como 3a variable, GHA26068094831),d35174f(slug v2 → v3 tras aprobación de Meta, GHA26068398056). Cuerpo del v3 es idéntico al v2 — fue solo re-submisión. HelperVenta::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.
.envde DigitalOcean conWHATSAPP_FEATURE_ENABLED=true+WHATSAPP_DRIVER=meta+META_WHATSAPP_PHONE_NUMBER_ID+META_WHATSAPP_TOKEN, reloadphp8.3-fpm, sin redeploy. Validado end-to-end conVTA2-000164el 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/privacyy/privacy-policy. URL para Meta:https://holbox.val-soft.com/aviso-de-privacidad. Commit local21ed2f5. Detalle en bitácora 2026-05-20. -
(2026-05-20) Baja de WhatsApp (opt-out) para clientes — flag absoluto
whatsapp_opt_outseparado deacepta_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. Commit1bd5898, deployado a prod (GHA26187225103). -
(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-inrespeta el veto de opt-out. 10/10 tests. Commit64d0b2c, deploy en curso (GHA26187571082). -
(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_monocular→dpcon backfillCOALESCE(binocular, monocular). Cambio aplicado en form admin, tabla histórica, banda POS al elegir cliente, ticket interno y ticket público. Commit43bf8cb, 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, usarmedia_iden send. La arquitectura ya tolera este cambio sin refactor: el contractWhatsAppSendergana 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+937807bfix 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-logscon 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
35ac39fenorigin/main:giggsey/libphonenumber-for-php 9.0.30+ refactorPhoneNumber(nuevotoE164(?$region='MX')+toE164Mxdelega con pre-procesamiento de legacy01XXX/521XXXque 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 ytoE164Mxlo 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+1explícito, + heurística de seguridad solo para915(caso real reportado) sin extender a otras LADAs fronterizas. Verificado empíricamente que LADA MX 915 no existe (libphonenumber rechaza+529158380000como inválido), así que cero colisión con clientes mexicanos legítimos. Commit424bdc1, GHA26552660322verde, fix vivo en prod (9158380000→+19158380000;5551234567→+525551234567regression OK). Suite PhoneNumber 17/17 (+5 nuevos), WhatsApp+PhoneNumber 72/72. BD no requirió UPDATE: teléfonos se guardan raw 10 dígitos enclientes.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, GHA26202727769); 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 nuevanivel_alcanzadopara celebrar nuevo nivel de fidelidad. Canales email+WhatsApp en paralelo con tracking separado, categoría MetaMARKETING,{{1}}viene deCliente::nombre(ya guarda solo el primer nombre — fallbackcliente). - 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
cc7db96deployado GHA run26202727769). PM-9 = pivote a opt-in implícito según decisión de Aarón: defaultwhatsapp_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 + setearConfiguracion::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).
- Diseño cerrado 2026-05-20 (PM-5/PM-6): 6 plantillas separadas — 5 recordatorios (
-
(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 ES33dc608cierran 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íaphp artisan lang:publish(baseline 4 archivos).lang/es/{validation,auth,passwords,pagination}.phpescritos a mano,validation.phpcubre los ~120 keys de Laravel 12, secciónattributescon ~50 campos del codebase (nombre/apellido/email/teléfono/sku/precio_venta/sucursal_id/etc).config/app.phpdefault'locale'cambiado de'en'a'es'— efectivo aunque prod no actualice su.env..env.exampley.envlocal:APP_LOCALE=es. Atención: prod.env(DigitalOcean) sigue conAPP_LOCALE=en; Sergio lo cambia ahí o lo elimina (caerá al default'es').- Test Pest valida end-to-end (POST
/clientescon 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
formNuevoClienteenPOS/Index.vueno renderizabaformNuevoCliente.errors.*, así que un 422 (email duplicado porunique: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 variantesdark:para legibilidad. El form/clientes/createadmin ya usaba<InputError>, solo faltaba esto en POS. -
(2026-05-14) Buscador en lista de clientes —
ClienteController::indexahora acepta?q=y filtraCliente::query()conWHERE LIKE %q%sobrenombre,apellido,telefono,emailyCONCAT(nombre, ' ', apellido)(para soportar búsqueda “Luis Hernández”). Frontend enClientes/Index.vuecon input +MagnifyingGlassIcon+ botón limpiar,router.getconpreserveState+preserveScroll+replace, debounce 400ms (mismo patrón deReportes/Ventas.vue). Empty state diferenciado para “sin resultados” vs “sin clientes”. Paginator usawithQueryString(). 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. Commitsf4caf0a+d68c85a(step=“any”). 6/6 tests. -
(2026-05-13) Emails de recordatorio de cupones de descuento por fidelidad —
descuentos:enviar-recordatoriosartisan + 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 deemails/layout.blade.php. Idempotente vía columnarecordatorios_enviadosJSON. Commit092f076. 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 aorigin/mainel 2026-05-13:05f1f76→5cf4a6e→dadbef4→de67123. Deploy GHA run25832757521. Aprobado por el admin del cliente 2026-05-14. Falta solo la acción de UI: Sergio activa toggleleaderboard_activo+ seteabono_lider_semanalen/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(middlewarerole:admin). - Vistas Inertia:
Admin/ComisionReglas/Index.vue(tabla),Admin/ComisionReglas/Form.vue(crear/editar). - Controller:
Admin\ComisionReglasControllercon 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.).
- Ruta:
-
(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 invocawindow.history.back()con fallback arouter.visit(route('reportes.ventas')). Validado en prod 2026-05-12. Commit3bab159. -
(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 enAppServiceProvider(commit7cc0227); 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">conprops.sucursales, leyendoprops.filters.sucursal_id, en el watch defetchReport. Lo que faltaba era el backend, que Sergio completó enb563c7c(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):
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 enAvanzadosController.phpagregaSUM(CASE WHEN tipo_optometria IS NOT NULL THEN precio_optometria * cantidad ELSE 0 END) as monto_optometriasal select por empleada. Commitea9be52.Reportes/Ventas(reporte clásico): nueva 4ª KPI card “Optometría” (grid pasó alg:grid-cols-4) con monto facturado + cantidad de líneas. Backend enVentasController.phpagregacantidad_optometriasymonto_optometriasal resumen, calculados con subquerywhereInque respeta TODOS los filtros (fechas/sucursal/asociada/estado/metodo_pago/producto/categoría). Commit5938ba2. 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 run26122121308.
-
(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 namepos.misVentasHoy) que devuelveventas(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 porauth()->id()+ estadocompletada+ 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 estiloabrirHistorialcon 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). Commit79232f5. Deployado a prod 2026-05-19 vía GHA run26122121308. -
(2026-05-18) Excluir categorías del disparador de
precio_sale(bolsitas). Implementado y deployado a prod. Commit04ad770, GHA run26066193131(49 s, success). Verificado en prod: BOLSITA y ESTUCHE quedaroncuenta_para_precio_sale=false, las 6 categorías de lentes atrue. 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::comisionescomputa la líder global (asociada con mayor ticket_promedio en la ventana lun-dom, tiebreak por ventas_total — misma lógica queLeaderboardController) y agrega campobono_liderpor 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 acomision_totalpara 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). Commitf0eccd6, GHA run26316253665. -
#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 enprocesar()+ 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.envextendido 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 2ed531a9, Entrega 3259697a(GHA26553517448). Suite 371/28 (mismos 28 preexistentes, +14 verdes vs baseline 357). Tablacomentarios_semanal_empleada+ endpointguardarComentario+ vista A4 print-friendly enReportes/ImprimirReporteEmpleada.vuecon los 4 bloques (resultados+comparativa, meta próxima semana con frase fija, comentario coach, firmas). Sin librería PDF — usa@media printdel 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=trueenproductos(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/categoriascon dark-mode pareado y descripción explicativa, backendAvanzadosControllercon LEFT JOIN a categorias + partition() por flag, frontend con componente reutilizableSkuRankingTable.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). Commit2035963, GHA run26419939509. Falta de Sergio: prender el flag en/categorias/9/editcuando deploy esté verde + aprovechar para corregirreporte_equipo_nombre=nullde 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}/comprascon controllerCliente\ComprasController(controller separado siguiendo convención deOptometriaController). 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 filtroswhen()+boolean()parse + whitelist de sort + resumen pre-paginate conclone $query+ eager loading selectivowith(['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íaref(new Set())clonado para reactividad de Vue, dark-mode pareado en todo. Botón “Compras” en columna de acciones deClientes/Index.vue(iconoShoppingBagIconcolor emerald) + link “Ver historial de compras” en header deClientes/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. Commitbbdd35e, GHA run26421608104✅ 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.mdy 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, tablasconversaciones+mensajes_wa+ identificación cliente/prospecto, UI/admin/conversacionescon 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 enplan-crm-fase1.md(preguntas P1/P2/P3/P5 resueltas por Sergio, ver bitácora). 3 entregas deployables: ✅ E1 captura inbound ~11h DEPLOYADA A PROD (commita8ad89e, GHA26605651268verde — migraciones + backfilltelefono_e164corridos en prod); ✅ E2 bandeja read-only ~10h DEPLOYADA A PROD OCULTA (commits30632da+bdcf05e, GHA26659946880verde; sin link en menú a propósito para testing previo a entrega — acceso solo por URL/admin/conversaciones, role:admin; revertirbdcf05eal entregar); ✅ E3 atención+envío+conversión ~9h DEPLOYADA A PROD (commit9769533, push 2026-05-29 16:18 local; menú sigue oculto — revertirbdcf05eal entregar).tenant_idnullable desde día 1 (decisión #258). Prospecto = tabla nueva (no flag enclientes). 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
IARespondercon 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_idenconversaciones/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: dejartenant_idcomo 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_idynotasheredados. 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 enShow.vuecon tabla editable + selector de agregar + textarea de notas; backendupdate()conabort_if(!isAuth,403)+detalles()->delete()+recreate en transacción + auditoría (editado_at/editado_por_user_id). Bloqueado paraenviado|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:
holbox→164.90.246.248(DigitalOcean), usersergio. 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:dumpo 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.php—index(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, reseteano_leidosal abrir) ·marcarLeida·media(sirve los binarios que el JobDescargarMediaWade E1 guarda en discolocal, ruta protegidarole:admin).- Rutas (en
routes/web.php, dentro del gruporole:admin):admin.conversaciones.index/feed/show/leida/media. - Frontend (Inertia + Vue 3, polling estilo
Leaderboard.vue— lista 5s, hilo 4s,onUnmountedclear):resources/js/Pages/Admin/Conversaciones/Index.vue(orquestador 3 columnas) + componentesConversaciones/{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 delAuthenticatedLayout. - 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 deEnviarRecordatoriosWhatsappTestson preexistentes (verificado con stash A/B — fallan idéntico sin mis cambios), cero regresión. Pint OK.npm run buildcorrido (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 9769533 → origin/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 contenant_idnullable+index desde el día 1 para multi-tenant futuro #258) +clientes.telefono_e164materializado con backfill (P1). - Modelos
Prospecto/Conversacion/MensajeWacon consts de estado/tipo/dirección y relaciones.Clienterecibe hooksavingque mantienetelefono_e164en sync + relaciónconversacion(). PhoneNumber::fromWhatsAppId()— normaliza elwa_idque manda Meta; maneja el legacy mexicano521XXXXXXXXXX(el1de móvil que libphonenumber marca inválido) cayendo atoE164Mx. Sin esto, los clientes MX no matchearían.- Webhook
WhatsappWebhookController::receive()ahora procesamessages[]además destatuses[](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 porunique(wa_message_id), match cliente (portelefono_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) astorage/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:
- 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).
- Permisos: middleware
role:admin,gerente(= mismo del reporte completo). Coincide con el spec “tú o Alfonso”. - 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— tablacomentarios_semanal_empleadacon unique(user_id, semana_iso).app/Models/ComentarioSemanalEmpleada.php— modelo Eloquent espejando el patrón deMetasSemanalEmpleada.app/Http/Controllers/Reportes/SemanalEmpleadaController.php— refactor mínimo: extraídocalcularReportes()privado reutilizable entreindex()eimprimir(). Nuevos métodosguardarComentario(POST, body vacío → DELETE),imprimir(GET, devuelve Inertia A4).index()carga bulkcomentariosEsta(esta semana) +comentariosAnterioresAll(todos los previos, recortado a 8 por user en PHP).routes/web.php— 2 rutas nuevas bajo middlewarerole:admin,gerente:comentario.update(POST) yimprimir(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 susemana_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)alonMounteddispara el diálogo automáticamente.tests/Feature/Reportes/SemanalEmpleadaComentarioTest.php— 9 tests Pest verdes: crea/sobrescribe/vacía-borra/422/403/gerente-OK; index exponecomentario_esta_semana+comentarios_anterioresordenado 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):
- Validar en prod
holbox.val-soft.com/reportes/semanal-empleadaque aparece bloque comentario coach + botón “Imprimir reporte” + que abre la vista A4 conCtrl+Plista para imprimir/guardar PDF. - 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é:
9158380000con region=MX →valid=false(LADA MX 915 no existe en plan de numeración).9158380000con 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.telefonocon 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— tablametas_semanal_empleadacon unique(user_id, semana_iso).app/Models/MetasSemanalEmpleada.php— modelo Eloquent con casts y relacionesuser()/createdBy().app/Services/MetaSugerenciaService.php— servicio purosugerirParaProximaSemana(). 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 nuevosnextWeekRange()yweekIso().app/Http/Controllers/Reportes/SemanalEmpleadaController.php—index()extendido conmetas_esta_semana,metas_proxima_semana,metas_sugeridas_proxima_semana,proxima_semana_iso,cumplimiento(cumplio/cerca/lejos) por empleada. Nuevos métodoseditarMetasGET +guardarMetasPOST conupdateOrCreate.routes/web.php— 2 rutas nuevas bajo mismo middlewarerole: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-empleaday 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 commit8f68a0ccomoDropdownLink“Reporte Coach por Empleada (Semanal)” + suResponsiveNavLinkmobile, push a main, deploy auto. - Arranque inmediato de Entrega 2 (metas editables + comparativa meta-vs-logrado) delegado a
laravel-fixeragent 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). MetaSugerenciaServicepuro 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 VueEditarMetasEmpleada.vue. - Tests Pest +12 esperados (7 controller + 5 service).
- Permisos: mismo middleware
role:admin,gerentedel 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á enorigin/main, GHA verde, deploy en prod. Local sincronizado. - #197 cerrado en checklist + PENDIENTES.md con Resuelto inline. Bandera viva: observar
/admin/whatsapp-logspor 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 ennivel_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.
- 131026 “Message undeliverable” — 28 fallos (90%): 22 en
- 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_idde muestra es5213034985013→ nacional303-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_outvíaWhatsappMensaje::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— nuevoconst ERROR_EXPLANATIONS(mapa código Meta → {categoria, motivo} en español),const ERROR_CATEGORIA_LABELS, y método estáticoexplicarError(?int, ?string): ?array. Categorías: numero / marketing / opt_out / sesion / sistema / otro.app/Http/Controllers/Admin/WhatsappLogsController.php— cada mensaje exponemotivo_falla+categoria_falla; nuevoresumenFallos(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, enEnviarRecordatoriosWhatsappTest, 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/edity el modelo especial en/productos/{id}/edit): cuando agregaba el modelo especial (cat #9 conusa_precio_propio=truey overrides) + 1 producto de otra categoría, el producto de la otra cat NO recibía suprecio_saleaunque conceptualmente hubiera 2 lentes en el ticket. El modelo especial sí agarraba su override mixto correctamente. - Causa raíz: el contador
totalLentesCount(tanto backend encalcularSubtotalCarritocomo frontend computed) excluía a TODOS los productos conusa_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íaprecio_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=trueY (nousa_precio_propioO 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:- Regression del bug exacto reportado: 1 modelo especial cat condicional + 1 producto otra cat → especial agarra mixto override, otro agarra
precio_sale. - Retrocompat: producto con
usa_precio_propioen 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.
- Regression del bug exacto reportado: 1 modelo especial cat condicional + 1 producto otra cat → especial agarra mixto override, otro agarra
- 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, GHA26500168349(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.
- Schema: Opción A — columnas extra (
- Decisión de generalización: en vez de hardcodear “cat #9 = Holbox I.A TR-90”, una cat con
precio_sale_mixto IS NOT NULLactiva 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.phpdatabase/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: exponeprecio_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 — incluyendousa_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: nuevoprecioAplicableDeLinea(item)espeja el backend;subtotalProductos/isSalePriceApplied/getPrecioAplicablelo usan.nuevaLineaProductopropaga los campos nuevos al carrito.resources/js/Pages/Categorias/{Create,Edit}.vue: campoprecio_sale_mixtocon texto explicativo + dark-mode pareado.resources/js/Pages/Productos/{Create,Edit}.vue: bloque ámbar con overridesprecio_sale_propio/precio_sale_mixto_propioque aparece sólo cuandousa_precio_propio=trueY la cat seleccionada tieneprecio_sale_mixtodefinido (computedcatTieneReglasCondicionales).
- 2 migraciones nuevas siguiendo el patrón del repo (1 columna por migración):
- Tests: nuevo archivo
tests/Feature/PosPrecioSaleCondicionalTest.phpcon 9 casos:- cat condicional + 2 unidades propias → intra (precio_sale).
- cat condicional + 3 unidades → intra aplica a todas (no sólo a 2).
- cat condicional + 1 unidad + 1 unidad de otra cat con flag ON → mixto.
- cat condicional + 1 sola unidad → precio_regular.
- cat condicional + bolsita (flag OFF) → NO activa mixto.
- override por producto en intra (modelo especial con
precio_sale_propio). - override por producto en mixto (
precio_sale_mixto_propio). - retrocompat: cat sin
precio_sale_mixto→ lógica vieja (2+ → precio_sale histórico). - endpoint
/posexponeprecio_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, GHA26499410106(Deploy Holbox App). Migración corre como parte del deploy script estándar. - No tocó datos en prod. Cualquier cat existente sin
precio_sale_mixtose comporta idéntico (lógica histórica intacta). - Pendiente operativo de Sergio (NO bloquea deploy):
- Cuando Aarón pase los 2 montos sale para cat #9, capturarlos en
/categorias/9/edit(camposSale Price (2+)ySale Price mixto). - Cuando Aarón identifique el modelo especial, crear/editar el producto en
/productos/{id}/editconusa_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.
- Cuando Aarón pase los 2 montos sale para cat #9, capturarlos en
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_salese 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):
- Sale “intra-categoría” — productos de cat #9 aplican
precio_salesólo si la venta lleva 2+ unidades de esa misma categoría. Una unidad sola de #9 queda enprecio_regular. - 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).
- Override por producto — un modelo en particular dentro de cat #9 debe tener
precio_regularpropio + sus propios dos sale prices (regla 1 y regla 2), siguiendo siendo de la cat #9.
- Sale “intra-categoría” — productos de cat #9 aplican
- Estado del modelado actual del codebase (lo que ya existe y se reusaría):
categoriastieneprecio_regular,precio_sale,cuenta_para_precio_sale(flag genérico que decide si una cat participa del disparador “2+ lentes”). Cat #9 hoy tienecuenta_para_precio_sale=true(bitácora #163, 2026-05-25).productostieneusa_precio_propio+precio_regularpropio (PosController::calcularSubtotalCarrito) — pero no hayprecio_salepor producto todavía.- El disparador actual en
calcularSubtotalCarritoestotalLentesCount ≥ 2contando líneas de cualquier cat concuenta_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 nuevacategoria_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 enoklch(); Chrome ≤109 (Win7) ignora silenciosamente esas reglas. El botón usabg-pink-600 hover:bg-pink-700y la paletapinkno estaba en la lista de overrides hex delresources/css/app.css(sí estaban gray, red, blue, purple, rose, slate, etc., pero pink no). Audit: 23 usos depink-*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..950completa en hex al@theme {}deresources/css/app.css, justo antes derose. Mismo formato que las demás. Build Vite verde, hex#db2777confirmado presente en bundle final. Sin cambios funcionales. - Commit + deploy:
9b500aaen~/code/holbox→ push autorizado per-sesión por Sergio → GHA dispara deploy a prod. - Memorias actualizadas:
reference_holbox_win7_tailwind_v4expandida: 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_authnueva: 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.mdsiguiendo 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 solostatuses). - 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.
- 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
- 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):
- Webhook inbound extendiendo
/webhooks/whatsapp/metadel #149. 2-3. Tablasconversaciones(con estado activa_ia/activa_humano/archivada + asignación) ymensajes_wa(direction, body, enviado_por, escalation_reason). 4-5. ServicioIARespondercon Antigravity Haiku 4.5 + prompt editable desde/admin/ia-configcon variables {nombre_negocio}/{catalogo}/{promociones}. - Lógica de escalación (intent “humano”/fuera de scope/flag
escalate:trueen output). - UI bandeja
/admin/conversacionescon chat view + tomar/devolver conversación. - Notificaciones push+email cuando IA escala (reuso del sistema de fidelidad).
- Pipeline de prospect (número desconocido crea
prospecto, se promueve aclienteal primer pago). - Multi-tenant ready (
tenant_iden 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:
-
Snapshot de datos de Holbox prod (autorizado por memoria
feedback_prod_read_only_diagnostics_authorized, víassh 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.
-
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.
-
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.
-
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. -
Documento entregado:
projects/research-crm-2026-05.mdcon 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-4xlvsmax-w-7xldel resto del sistema.- SVG inline para back arrow vs
ArrowLeftIconheroicon. - Cards
rounded-lg shadow-smsin border definido vsrounded-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-600vsbg-primary-600/bg-emerald-600. - Badge de estado sin dot indicator vs chip con dot
animate-pulseparaenviado(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ítulotext-2xl font-bold tracking-tight+ subtítulo “Movimiento de inventario entre sucursales”. - Card cabecera única:
ArrowsRightLeftIconen burbujabg-primary-50 dark:bg-primary-900/30 rounded-xl, ruta visual conMapPinIconen cada extremo, separadorArrowsRightLeftIconcentral. Creado por conUserIcon. Chip de estado a la derecha con dot indicator del color del estado (azulanimate-pulsepara enviado). - Timestamps reagrupados en grid de 3 con iconos color-coded:
PaperAirplaneIconazul (enviado),CheckCircleIconesmeralda (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, headerbg-gray-50 dark:bg-gray-700/50 uppercase tracking-wider, rows condivide-yyhover:bg-gray-50, SKU como chipbg-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
PencilSquareIconen lugar de texto plano. Inputs confocus:ring-primary-500. “Quitar línea” pasa de texto a icon buttonXMarkIconrojo. Sección “Agregar producto” + “Notas” con labelsuppercase 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.vueen lugar de pantalla/traslados/{id}/editaparte. 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 arraydetallescompleto 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+ FKeditado_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.php—editado_at(nullable timestamp) +editado_por_user_id(FK nullable a users,nullOnDelete).app/Models/Traslado.php— fillable + castdatetimeparaeditado_at+ relacióneditor().app/Http/Controllers/TrasladoController.php:update(Request, Traslado): estado pendiente check (redirect+error si no),abort_if(!$isAuth, 403)para auth, validatedetallesarray min:1 +producto_idexists +cantidadinteger min:1 +notasnullable max:500, transacción borra todos los detalles existentes y recrea desde el payload, seteaeditado_at=now()+editado_por_user_id=auth()->id(), redirige atraslados.show.show()eager-loadea relacióneditory ahora pasacanEditar(solo si pendiente + admin/gerente origen) +productos(solo cuandocanEditar).
routes/web.php—Route::resource(...)->only([..., 'update']).resources/js/Pages/Traslados/Show.vue— reescrito con dos modos (lectura/edición) gobernados poreditingref. Modo edición: banner ámbar “los cambios no afectan inventario hasta enviar”, tabla con inputv-model.number="d.cantidad"y botón “Quitar” por row, fila “Sin líneas” cuando vacío, sección “Agregar producto” con select que filtraproductosUsadosIdspara 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.php— 11 tests verdes, 54 assertions:- admin edita cantidad de línea
- admin agrega + quita líneas en una sola edición
- gerente origen puede editar
- gerente destino NO puede editar (403)
- estado
enviadobloqueado - estado
recibidobloqueado - inventario origen y destino quedan intactos
editado_at+editado_por_user_id+notasse persisten- detalles vacío rechazado por validator
- cantidad cero rechazado por validator
showexponecanEditar=trueyproductossolo 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ónAarona 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 anombre 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, nombreAlom, skuST2801C2. - 5 líneas encontradas en traslados pendientes con
id >= 42:td#364traslado#00042CEDIS → MISIONES I cant=2td#456traslado#00045CEDIS → MISIONES II cant=2td#542traslado#00046CEDIS → MISIONES III cant=2td#628traslado#00047CEDIS → SENDERO cant=2td#714traslado#00048CEDIS → 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
85lí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 extenderClienteController, convención del proyecto:OptometriaControllerya está separado para sub-recursos del cliente). - Ruta nested
/clientes/{cliente}/comprasdentro del bloquerole:admin,gerente. - Vista Inertia separada
Clientes/Compras.vue(no tabs en Edit; consistente conclientes.optometrias.index).
Decisiones de producto (vía AskUserQuestion):
- Scope gerente: TODAS las sucursales. Contrario al patrón de
Reportes/VentasControllerque sí restringe. Necesario para reclamos cruzados (cliente compró en MISIONES y reclama en SENDERO). - Canceladas ocultas por default + checkbox “Incluir canceladas”. Cuando se incluyen, badge rojo + folio tachado.
- Expandible inline + link “Ver ticket” a /ventas/{id}. Vista rápida sin perder navegación al ticket completo (cambios/garantías).
- Sin default de fecha (cliente típico pocas ventas; filtros opcionales).
- 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/finvíaLocalDateRange::inclusive,sucursal_id,categoria_idconwhereHas('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 $query3 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 enReportes/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:SolicitudDescuentoEmpleadotiene columnaporcentaje, noporcentaje_descuentocomo 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” aclientes.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/setTimeout400ms 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
ShoppingBagIconopacidad-30 y texto contextual según si hay filtros activos. - Dark-mode pareado en todo (
bg-*+text-*+border-*+dark:*). - Paginación vía
Pagination.vueexistente.
Links de entrada:
Clientes/Index.vue: botón “Compras” en columna de acciones (iconoShoppingBagIcon, 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):
asociada no puede acceder; admin y gerente sí(403 vs 200).solo lista ventas del cliente solicitado(aislamiento por route-model binding).gerente ve ventas de sucursales distintas a la suya(validación de la decisión #1 de producto).filtros sucursal y categoría aplican correctamente.incluir_canceladas default oculta canceladas; true las incluye(cubre elboolean()parsing).resumen refleja optometría y cancelada respetando filtros.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:
- Header del cliente con nivel fidelidad correcto.
- 4 KPI cards mostrando datos del rango filtrado.
- Filtros con debounce que actualizan URL sin recargar.
- Click en chevron expande/colapsa detalle.
- Link “Ticket” abre
/ventas/{id}(vista completa). - Como gerente: ver ventas de otras sucursales.
- 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 descaunque el usuario aplique sort visual; el rank es estable.
Cambios (9 archivos):
| Archivo | Cambio |
|---|---|
database/migrations/2026_05_25_180000_add_separar_en_top_skus_to_categorias.php | bool default false, after cuenta_para_precio_sale |
app/Models/Categoria.php | $fillable + $casts |
app/Http/Controllers/CategoriaController.php | validación store/update |
app/Http/Controllers/Reportes/AvanzadosController.php | LEFT 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.vue | checkbox con descripción y dark-mode pareado |
resources/js/Pages/Reportes/Avanzados.vue | reemplaza 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'), computedskuViewOptions(lista con conteo de SKUs por pill) yskuViewActiveGrupo(resuelve el grupo activo desdecategoria_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):
- Cuando GHA quede verde, ir a
/categorias/9/edity prender el checkbox “Separar en bloque aparte en el reporte de TOP SKUs”. - Aprovechar para corregir
reporte_equipo_nombre=null(poner nombre corto tipo “TR-90” o desactivarreporte_equipo_mostrarsi Aarón no quiere que aparezca aún en Desempeño Equipo). - 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):
| ID | Nombre |
|---|---|
| 1 | CEDIS |
| 2 | MISIONES I |
| 3 | MISIONES II |
| 4 | MISIONES III |
| 5 | SENDERO |
| 6 | RIO 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 | #00048 | 86 |
| #00043 | #00049 | #00050 | #00051 | #00052 | 3 |
| #00044 | #00053 | #00054 | #00055 | #00056 | 3 |
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_mensajescon PKmessage_idstring (= wamid de Meta), FKs nullable aventas/clientes/codigos_descuento,template_name,to_e164, enumstatus∈ {sent, delivered, read, failed},error_code/error_title/error_detail, timestamps por estado (sent_at/delivered_at/read_at/failed_at),es_previewboolean (para no contaminar reportes con disparos del simulador/preview),raw_status_payloadJSON para forensics. Índices:(status, sent_at),template_name,sent_at. - Modelo
WhatsappMensajecon$incrementing=false,keyType=string, casts datetime/array/bool, constantesSTATUSESyOPT_OUT_ERROR_CODES = [131047, 131049, 131050](códigos de Meta que indican que el cliente ya no quiere mensajes). MetaCloudWhatsAppSenderampliado: helper privadorecordSentMessage()inserta el row inicial constatus=sentcada vez que Meta acepta un envío. Persiste en los 5 puntos:sendTicket,sendPreview(cones_preview=true),sendFidelidad,sendNivelAlcanzado,sendOptica(2 rows). El insert va entry/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. Aceptahub.mode=subscribe, validahub.verify_tokencontraMETA_WHATSAPP_VERIFY_TOKENconhash_equals, devuelvehub.challengecomotext/plain. Lee ambas formashub_modeyhub.modepor defensividad contra proxies.POST /whatsapp/webhook→ status updates de Meta. SinMETA_APP_SECRETconfigurado responde 503 (mejor fallar ruidoso que aceptar sin verificar). ValidaX-Hub-Signature-256=sha256=+ HMAC-SHA256 del body crudo con el app secret (hash_equals). Iteraentry[].changes[].value.statuses[], actualiza row pormessage_id, llena el*_atcorrespondiente. Enstatus=failedguardaerror_code/error_title/error_detaildel primer error.- Auto opt-out: si llega
failedcon error code ∈OPT_OUT_ERROR_CODESy el row tienecliente_id, marcaCliente::whatsapp_opt_out=true. Idempotente (no toca si ya está activo).
config/services.phpganawhatsapp.meta.app_secretywhatsapp.meta.verify_token..env.exampledocumenta los dos vars.bootstrap/app.php:whatsapp/webhookagregado alvalidateCsrfTokens(except:)— los webhooks externos nunca traen CSRF.- Rutas en
routes/web.php(GET/POST públicas) + ruta adminGET /admin/whatsapp-logsdentro del gruporole:admin.
Admin UI:
- Nuevo controller
Admin\WhatsappLogsControllercon filtros porstatus,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 emparejadobg-*/text-*con variantesdark:. - Link en el menú admin de
AuthenticatedLayout.vuetanto desktop como responsive. Ventas/Show.vuegana una sección “Mensajes WhatsApp” con tabla mini (template / status chip / hora sent/delivered/read / error) cuando hay rows ligados a la venta. Excluyees_preview=trueautomáticamente (VentaController::showfiltra 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; statusdelivered/read/failedactualiza fila correcta; firma calculada con HMAC-SHA256 del body crudo (helpermetaSignature); 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 deSTATUSESse ignora con 200; wamid desconocido se persiste como fallback contemplate_name='unknown'.tests/Feature/WhatsappSenderPersistTest.php(5):sendTicketexitoso persiste constatus=sent+ venta/cliente;sendPreviewmarcaes_preview=true;sendOpticapersiste 2 rows (referencia + ubicación) conHttp::sequence;sendTicketcon 4xx NO persiste (Meta rechazó);sendFidelidadpersiste concodigo_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):
- Aarón da
META_APP_SECRETdesde Meta App → Settings → Basic → App Secret (NO es el access token). - Sergio decide
META_WHATSAPP_VERIFY_TOKEN(string alfanumérico random; sin usuario; sólo necesita ser stable y único). - SSH a prod (DigitalOcean): agregar
META_APP_SECRET=…yMETA_WHATSAPP_VERIFY_TOKEN=…al.env,php artisan config:clear, reloadphp8.3-fpm. php artisan migrate --forcepara crearwhatsapp_mensajesen prod.- 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 campomessages. Meta hace inmediatamente el GET handshake — si responde 200 con el challenge, la suscripción queda activa. - Validar end-to-end: una venta real → 4 rows nuevos en
whatsapp_mensajesconstatus=sent; minutos después deberían transitar adelivered/readcuando 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.
updateOrCreateenrecordSentMessage. 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_previewseparado 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_CODESpara acotar el blast radius. - 503 sin secret. Preferí fallar ruidoso a aceptar sin verificar, para detectar misconfiguraciones de
.envrá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:
- 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. git checkout main && git merge --ff-only feat/whatsapp-webhook-status— FF limpio (main no se había movido durante la sesión).git push origin main→ SHA4383abe→ trigger GHADeploy Holbox Apprun26379047033.git branch -d feat/whatsapp-webhook-status(cleanup local).- GHA verde en 50s. Migración
2026_05_24_180000_create_whatsapp_mensajes_table.phpcorrió 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.exampleque no estaban aún en el.envde 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
.envde prod concat >> .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/webhookrespondió 503 (= sin secret cargado), aunque.envsí tenía la var. Causa:php artisan optimizedurante el deploy haceconfig:cache, que persiste hasta el siguienteoptimize:clear. Elconfig:clearsolo borrabootstrap/cache/config.phppero hubo race con la regeneración. - Sergio ejecutó
php artisan optimize:clear && php artisan optimizey resolvió.config:show services.whatsapp.metamostró 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ó campomessages.
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-logsaparecieron los 4 rows. Tras leerlos en el celular: status transitósent → delivered → readen ~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/simuladorejecuta los envíos dentro deDB::beginTransaction()+DB::rollBack()enfinally. Eso revierte el insert que haceMetaCloudWhatsAppSender::recordSentMessageenwhatsapp_mensajes. Después Meta manda eldelivered/readpor webhook y mi handler caía al fallback “wamid desconocido se persiste contemplate_name='unknown'”, creando rows cones_preview=falsepor 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=truepor 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_mensajescones_preview=truefuera 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 rowen lugar del antiguose persiste con template=unknown). Suite 18/18 verdes. - Deploy: commit
937807b, push, GHA run26379906449verde en ~50s. Los 4 rows huérfanos previos se borraron conWhatsappMensaje::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/webhookactivo recibiendo updates de Meta. - Tabla
whatsapp_mensajespoblándose con cada venta real. /admin/whatsapp-logsaccesible al admin con filtros funcionando.Ventas/Showmuestra 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:
| Concepto | Hoy | Coexistence |
|---|---|---|
| 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ó):
- ¿
phone_number_idcambia tras activar coexistence? Si cambia, rompe nuestroMETA_WHATSAPP_PHONE_NUMBER_ID. Probable que NO porque WABA y número se preservan. - ¿Templates aprobados siguen vivos? Probable que sí — viven en WABA, no en número.
- ¿System User token sigue válido? Probable que sí.
- ¿Webhook actual sigue funcionando? Sí — recibe
statuses[]igual; gana eventosmessages[](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
WhatsappWebhookControllerpara procesar también eventosmessages[](hoy solo procesastatuses[]). 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ácticowhautomate.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
LeaderboardControllerpara la misma ventana lun-dom: GROUP BY user_id sobreVenta(estado=completada, ventana, role=asociada), calculaticket_promedio = ventas_total / tickets, ordenasortByDesc(fn ($r) => [$r['ticket_promedio'], $r['ventas_total']]), toma el primero contickets > 0.$leaderUserIdqueda con su id onull. - 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;$bonoLiderMontosi$user->id === $leaderUserId).comision_totalahora incluye el bono. esquema.bono_lider_semanalagregado 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++$Xcuando 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.
colspandel empty-state actualizado a 8 (era 7).
Tests Pest nuevos (ReporteControllerTest.php):
- 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=0para aislar. Asserts líderbono_lider=500, otrabono_lider=0,comision_totalcorrectos. - Default 0 sin config: sin set de
bono_lider_semanal, el reporte traeesquema.bono_lider_semanal=0y todas las filas conbono_lider=0. - 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, asociadabono_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 camposolicitud_descuento_empleado_id => nullable|exists|prohibits:codigo_descuento(mutex via validation con la reglaprohibitsen 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
Ventacon 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 verificaaffected_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_acumuladasymonto_acumuladoSÍ 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.
- Los acumuladores
-
EnviarRecordatoriosDescuentos: query principal extendida conwhereHas('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(signaturesolicitudes-empleado:expirar):- Usa el scope
aprobadasExpirablesdel 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.batchcon count + ids. - Flag
--dry-runpara 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 cron0 8 * * *(02:00 Cd. Juárez = 08:00 UTC).
- Usa el scope
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.valuedesde el snapshot hidratando conproductosPorId(precio actual del producto, no del snapshot — el cobro recalcula precios) → seteasolicitudConsumida.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
totalmodificado: sisolicitudConsumidaactivo, aplicasolicitudConsumida.porcentajedirectamente (no toca el cupón). El watch del cupón aborta sisolicitudConsumidaestá activo. Cuando se submite la venta, elcodigo_descuentose manda comonullsi hay solicitud (defensa adicional). - Submit
pos.procesaradjuntasolicitud_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
solicitudConsumidaactiva (mutex de UI).
- Chip rosa “Empleada” al lado del nombre cuando
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; mutexprohibitsconcodigo_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-runno 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; comandodescuentos:enviar-recordatorios --dry-runno menciona cupones de empleados; JobSendWhatsappNivelAlcanzadoaborta 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:listmuestra0 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:
- Admin marca cliente como Empleada — desde
/clientes/create(alta nueva) o/clientes/{id}/edit(existente). Filtro?es_empleado=1para descubrir empleadas en el listado, link “Empleadas” en menú admin. (PR1) - 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)
- 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)
- 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) - 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)
- Venta queda con descuento aplicado + 3 campos de auditoría llenos. Solicitud pasa a
consumidaconventa_idyconsumida_at. (PR3) - Empleadas quedan fuera del programa de fidelización — sus acumuladores
ventas_acumuladas/monto_acumuladoSÍ 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) - 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): arraydesdeprocesar(). 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 losdetallespara armardetallesToInsertcon 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): validacliente.es_empleado=true, calcula subtotal con el helper, toma$porcentaje = Configuracion::get('descuento_empleado_porcentaje', 50)como snapshot al momento, persisteSolicitudDescuentoEmpleadocon estadopendiente. Log estructuradodescuento_empleado.solicitada.cancelarSolicitudDescuento(SolicitudDescuentoEmpleado): 403 si no es de la asociada autenticada; 422 si el estado no espendienteoaprobada. Update acancelada+cancelado_at.solicitudesAprobadasParaAsociada(Request): JSON con las solicitudesaprobadasdel asociada + sucursal kiosk, items hidratados conproducto.nombre. Lo va a consumir el modal “Solicitudes aprobadas” del PR3.
-
Admin\DescuentoEmpleadoControllernuevo:index(Request): filtra por?estado=(defaultpendiente) y?sucursal_id=, paginate(20), eager-load relaciones. Mapea cada solicitud a array con cliente/asociada/sucursal/aprobador/rechazador + items hidratados. También devuelveconteoPorEstado(groupBy estado) para los badges de las tabs.pendientesCount(): JSON simple{count: N}para el polling del sidebar.aprobar(Solicitud): doble guard — estado debe serpendienteYcliente.es_empleadodebe seguir siendotrue(defensa en profundidad si admin desmarcó al cliente entre solicitud y aprobación). Update conaprobado_por_user_id+aprobado_at.rechazar(Request, Solicitud): motivo opcional max 500. Update conrechazado_por_user_id+rechazado_at+motivo_rechazo.
-
Rutas: 4 admin (
role:admin) + 3 POS (kiosk). Total 7 nuevas. -
Admin/DescuentosEmpleado/Index.vuenuevo: 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.
- Nueva prop
-
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
fetchcada 30s al endpointpendientesCount, falla silencioso si el endpoint no responde.onMountedarranca el interval,onBeforeUnmountlo 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 akiosk.requiredsin 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 defaultpendiente, 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 tinkerconSolicitudDescuentoEmpleado::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_empleadoboolean indexed; B) tablasolicitudes_descuento_empleadocon 16 columnas, enum 6 estados (pendiente/aprobada/rechazada/cancelada/consumida/expirada), 3 índices; C) 3 campos de auditoría enventas(descuento_empleado_solicitud_id+_autorizado_por_user_id+_autorizado_at). - Modelo
SolicitudDescuentoEmpleadocon 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_porcentajeadmin-only en/configuracioncon validation0-100, default 50. Mismo patrón quebono_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/updateagreganes_empleadoa rules + strippean si auth no es admin.PosController::storeCliente/updateClientejamás incluyenes_empleadoen 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')devuelveNOT_SETen BD pero el controller usa fallback 50 desde código — Aarón verá “50” al abrir/configuracionpor 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 tocaprocesar()todavía. Estimado 8-10 h. - PR3: consumo en
PosController::procesar()(lockForUpdate + validaciones + descuento aplicado + UPDATE estado→consumida con check de affected_rows), endpointsolicitudesAprobadasParaAsociada, modal POS de “Solicitudes aprobadas”, comandosolicitudes-empleado:expirar+ schedule diario, guards de fidelización para empleadas (no genera CodigoDescuento por nivel, no dispatcha SendWhatsappNivelAlcanzado, filtros enEnviarRecordatoriosDescuentos), UI Clientes ocultando sección fidelidad sies_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 nombrecomo 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
PosControllerque 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étodosendOptica(Venta $venta): void.app/WhatsApp/MetaCloudWhatsAppSender.php— implementasendOpticaque internamente manda los 2 templates secuencialmente con su lógica de error correcta; agregabuildPayloadOpticaReferenciaybuildPayloadOpticaUbicacion; constructor recibearray $opticacon la config.app/WhatsApp/LogWhatsAppSender.php— log no-op simétrico.app/Jobs/SendWhatsappOptica.php(nuevo) — mismo patrón queSendWhatsappTicket: 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 deSendWhatsappTicket::dispatch($venta)condicionado a que algúnventa_detalletengatipo_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
- Sergio recorta foto del local horizontal (1.91:1) y la hostea con URL pública estable.
- Somete los 2 templates a Meta Business Manager (categoría UTILITY, idioma es_MX).
- Cuando Meta apruebe (típicamente 24-48h), llena las 7 env vars en prod.
- 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
f023b51enmaincon 8 archivos (7 .php + 1 .jpg), 280 inserciones. - Deploy GHA run
26251922592verde. .envde prod: appendeadas las 7 vars (META_WHATSAPP_OPTICA_*) víasudo tee -aejecutado por Sergio (mi usuariosergioen holbox no tiene NOPASSWD, así que se requirió que pusiera el password manualmente con! ssh holbox ...desde su terminal). Backup.env.bak.YYYYMMDD_HHMMSScreado en/var/www/holbox/antes del append.php artisan config:clearejecutado 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_OPTICAdel.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 local | Evento de log | Resultado |
|---|---|---|
| 20:47:15 | whatsapp.nivel_alcanzado.sent (nivel BRONCE, primer nivel del cliente) | ✅ aceptado |
| 20:47:16 | whatsapp.ticket.sent | ✅ aceptado |
| 20:47:17 | whatsapp.optica.referencia.sent | ✅ aceptado |
| 20:47:18 | whatsapp.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 middlewareSimuladorAccessque checaauth()->user()->email === config('services.simulador.email'); cualquier otro user →404(no403, para no revelar la existencia). - Default del email autorizado:
sevaor@gmail.com(config varSIMULADOR_EMAIL). - Acceso a Sergio: requiere que exista un user admin con email
sevaor@gmail.comen prod. Pendiente: Sergio lo crea desde/usuarios/createcon 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()→ creaCliente+Venta+VentaDetalle(folioSIM-YYMMDDHHMMSS) →dispatchSyncde cada job marcado →DB::rollBack()siempre enfinally. Cada canal envuelto ensafeRun()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:
0ventas SIM- en BD post-disparo. - Mailpit recibió el correo “Tu Ticket de Compra - Holbox” en
sevaor@gmail.com.
Deploy
- Commit
5a4bfa3en holbox/main. - GHA run
26257088362verde. - Sin tocar
.envde prod — el defaultSIMULADOR_EMAIL=sevaor@gmail.comaplica víaenv(..., 'sevaor@gmail.com')enconfig/services.php. Solo se edita.envsi Sergio quiere apuntar a otro email.
Pendiente para cierre del feature
- Sergio crea user admin
sevaor@gmail.comen prod desde/usuarios/createcon password de su elección. - Sergio se loguea con ese user, abre
/admin/simuladory 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: usarssh -ten 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=IDagregado al SimuladorVentaController para consultar Meta Graph API (/{WABA}/message_templates?name=...) y ver la estructura aprobada del template. Commit5ec7472+fb057a0. - WABA correcto resultó ser
2252218095292703(el primero que Sergio pasó,946974731256993, era otro de su Business Manager). - Confirmado que
optica_ubicacion_v1está aprobado conHEADER format=LOCATIONdinámico (correcto). - Debug temporal del payload completo en
whatsapp.optica.ubicacion.sent(commit52bcb06, revertido end727037). El payload va perfecto, Meta respondemessage_status: acceptedcon 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 middlewaresimulador). - Variable opcional
META_WHATSAPP_BUSINESS_ACCOUNT_IDagregada aconfig/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.vuemodal Nuevo Cliente: quitado del formNuevoCliente + checkbox.POS/Index.vuemodal Editar Cliente: quitado del formEditarCliente + checkbox.POS/Index.vuebanner ámbar “Activar WhatsApp”: eliminado por completo (computedmostrarBannerOptInWhatsApp, funciónactivarWhatsAppCliente,opOptInLoading,pageProps, importusePage, 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.optInremovida deroutes/web.php. - Método
PosController::whatsappOptInremovido. - Validaciones
acepta_whatsappremovidas deClienteController::store/updateyPosController::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 seteaConfiguracion::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:
- 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.
- Aviso en el form de captura — ya implementado bajo el campo Teléfono.
- Aviso de privacidad accesible —
holbox.val-soft.com/aviso-de-privacidadya 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
- Deploy del commit (
git push→ GHA). - 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). .env:WHATSAPP_FEATURE_ENABLED=true+sudo systemctl reload php8.3-fpm.- Smoke test con
php artisan descuentos:enviar-recordatorios --dry-runantes 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_templatesmap 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): voidsendNivelAlcanzado(Cliente $cliente, NivelFidelidad $nivel, string $codigoStr): void
Las dos implementaciones del contract actualizadas:
LogWhatsAppSender— log estructurado conwhatsapp.fidelidad.sendywhatsapp.nivel_alcanzado.sendpara driver default.MetaCloudWhatsAppSender—sendFidelidad+sendNivelAlcanzado+ susbuildPayloadFidelidad/buildPayloadNivelAlcanzado. Mismas reglas quesendTicket: 4xx silenciado con log, 5xx throw para reintento del job. Constructor extendido con 2 params nuevos (fidelidadTemplatesarray,nivelAlcanzadoTemplatestring).
Sender binding AppServiceProvider — pasa los nuevos campos de config al constructor del Meta sender.
Jobs nuevos:
App\Jobs\SendWhatsappFidelidad— tries=3, backoff 60/300/900s. handle aplica 3 guardas: feature flag + opt_out + delega al sender.App\Jobs\SendWhatsappNivelAlcanzado— mismo patrón.
Comando descuentos:enviar-recordatorios — refactorizado:
handle()itera cupones una sola vez, despacha email y WA en pasos separados.- Métodos privados
procesarEmail()yprocesarWhatsapp()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_inrespetado vsacepta_whatsapp). - Trackers JSON separados:
recordatorios_enviados(email) vsrecordatorios_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)
- Commit local con los 12 archivos modificados/nuevos.
- Push a main → GHA workflow ejecuta deploy (pull + composer + npm + migrate + horizon:terminate).
- Validar la migración aplicó en prod: SSH read-only
SELECT recordatorios_whatsapp_enviados FROM codigos_descuento LIMIT 1(debe responder NULL). - Agregar al
.envde prod:
Solo son los slugs default — si Aarón los nombró así en Meta no hace falta poner los envs explícitamente.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 - Reload
php8.3-fpmpara que tome los envs nuevos. - (Opcional) Mantener
WHATSAPP_FEATURE_ENABLED=falsepor 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. - Smoke test: en prod después de prender, generar un cupón nuevo manualmente y correr
php artisan descuentos:enviar-recordatorios --dry-runpara 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_alcanzadopor 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ía | Encabezado email | Frases ancla del cuerpo |
|---|---|---|
| 3 | Tu 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” |
| 7 | Es 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” |
| 15 | Aú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” |
| 30 | Hoy 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):
| Pos | Origen | Formato | Sample |
|---|---|---|---|
{{1}} | $codigo->cliente->nombre (fallback "cliente") | string libre | María |
{{2}} | $codigo->codigo | string mayúsculas | FIDELIDAD15 |
{{3}} | $codigo->porcentaje | entero sin signo | 15 |
{{4}} | $codigo->valido_hasta->locale('es')->isoFormat('D MMM') | fecha corta ES | 19 jun |
Template nivel_alcanzado (5 variables):
| Pos | Origen | Formato | Sample |
|---|---|---|---|
{{1}} | $cliente->nombre (fallback "cliente") | string libre | María |
{{2}} | strtoupper($nivel->nombre) | mayúsculas | GOLD |
{{3}} | (int) $nivel->porcentaje_descuento | entero sin signo | 15 |
{{4}} | Carbon::now()->addDays($nivel->vigencia_dias)->locale('es')->isoFormat('D MMM') | fecha corta ES | 19 jul |
{{5}} | código del cupón generado al alcanzar el nivel | string mayúsculas | BIENVENIDA15 |
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:
- Category:
Marketing - 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. - Languages: agregar una sola,
Spanish (MEX)(código internoes_MX). - Header: None.
- Body: pegar el texto correspondiente respetando los
{{1}} {{2}} {{3}} {{4}}(y{{5}}solo ennivel_alcanzado). - 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.
- Para
- Footer: vacío.
- Buttons: ninguno.
- 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_v3y aprobó a la 2da. nivel_alcanzadopuede 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
Clientecambia de nivel (mismo evento que disparaLoyaltyBreakpointMail). Encontrar el listener / observer que despacha el email y agregar despacho paralelo del jobSendWhatsappNivelAlcanzado. - Job:
App\Jobs\SendWhatsappNivelAlcanzado— recibeCliente,NivelFidelidad,string $codigoStr. Mismas guardas queSendWhatsappFidelidad(opt-out, opt-in respect, feature flag, teléfono normalizable). - Sender: método nuevo
sendNivelAlcanzado(Cliente $cliente, NivelFidelidad $nivel, string $codigoStr): voiden el contract + Meta sender. Su propiobuildPayloadNivelAlcanzadocon 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ón | Resolució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)
| Pos | Origen | Formato | Ejemplo |
|---|---|---|---|
{{1}} | $codigo->cliente->nombre (fallback "cliente") | string libre | María |
{{2}} | $codigo->codigo | string mayúsculas | FIDELIDAD15 |
{{3}} | $codigo->porcentaje | entero sin signo | 15 |
{{4}} | $codigo->fecha_vencimiento->locale('es')->isoFormat('D MMM') | fecha corta ES | 19 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:
| Variable | Sample 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:
- Category:
Marketing - Name:
fidelidad_dia_03(o_07,_15,_25,_30según corresponda — exacto, en minúsculas, con guiones bajos) - Languages: agregar una sola —
Spanish (MEX)(código internoes_MX) - Header: dejar vacío (None)
- Body: pegar el texto del bloque correspondiente arriba, respetando los
{{1}}{{2}}{{3}}{{4}}(no remplazar con valores) - 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 - Footer: dejar vacío
- Buttons: ninguno (a diferencia de
ticket_holbox_v3que tiene botón URL — estos no llevan botón porque el cliente usa el código en sucursal, no online) - 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:
- Migración:
2026_05_XX_add_recordatorios_whatsapp_enviados_to_codigos_descuento.php— columna JSON nullable. Paralela arecordatorios_enviadosque ya existe. Reversible. - Job:
App\Jobs\SendWhatsappFidelidad implements ShouldQueue— constructor recibeCodigoDescuento+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
WhatsAppSendercontract:sendFidelidad(CodigoDescuento $codigo, int $dia, string $templateName, array $parameters): void. O atajo: agregar parámetros opcionales asendTickety renombrar asend(más invasivo — preferible método nuevo).
- Early-return si
- Sender (Meta cloud): método
sendFidelidadanálogo asendTicketpero con su propiobuildPayloadFidelidadque arma los 4 parameters del body sin botón URL. - 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_enviadosJSON (no tocarecordatorios_enviadosque es del email). - Dispatch
SendWhatsappFidelidad.
- Filtra
- Scheduler: ya en
dailyAt('09:00')con el comando email — al extender queda gratis. - 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).
- Riesgo cuidado: los 10 tests existentes de
EnviarRecordatoriosDescuentosTestdeben 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_atdelCodigoDescuento), 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 (helperCliente::primerNombre()oVenta::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
- ¿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.
- ¿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.
- ¿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).
- ¿Quién es el primer nombre del cliente? El
Clientemodel tienenombre(string completo). Si el cliente registró “María Guadalupe”, el saludo “Hola María Guadalupe 👋” se siente formal. HelperprimerNombre()que parta por espacio y tome el primer token. Sinombreestá vacío, fallback “Hola 👋”.
Implementación (alto nivel, cuando se desbloquee)
- Schema: columna
recordatorios_whatsapp_enviados(JSON nullable) encodigos_descuento, paralela arecordatorios_enviadospara que email y WhatsApp se rastreen por separado y un canal no inhiba al otro. - Job:
SendWhatsappFidelidadanálogo aSendWhatsappTicket— recibeCodigoDescuento+int $dia, resuelve template name (fidelidad_dia_{$dia:02d}), armaparameterscon[primerNombre, codigo, porcentaje, vigenciaCorta], delega aWhatsAppSender::send(). Early-return sicliente->whatsapp_opt_out(defensivo) o si! cliente->acepta_whatsapp(respetando el toggle globalwhatsapp_respetar_opt_in). - Comando artisan: o extender
descuentos:enviar-recordatorioscon un loop adicional para WhatsApp, o creardescuentos:enviar-recordatorios-whatsappseparado. 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_enviadosJSON 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 tocarrecordatorios_enviadosdel 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/previewmandaban 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_v3en Meta Business Manager. El botón estaba como “Static URL” conhttps://holbox.val-soft.com/t/{{1}}como Website URL — Meta no reemplazaba el{{1}}(porque static no interpreta variables) y appendaba eltextparameter del sender al final, generandot/{{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ódigo —
MetaCloudWhatsAppSender::buildPayloadya 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/previewllega con URL limpio. - Verificado en prod (SSH read-only):
WHATSAPP_FEATURE_ENABLED=falsesigue 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 conphp8.3-fpm reloadcuando 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=falsepor 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.telefonono vacío!cliente.acepta_whatsapp!cliente.whatsapp_opt_outCuando 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 namepos.cliente.whatsapp.optIn). Dentro del grupokiosk. Devuelve 404 si feature flag apagado. Si el cliente tienewhatsapp_opt_out=true, ignora el opt-in (la baja explícita es veto duro — se debe llamar primero awhatsappAltapara reactivar). Log estructuradocliente.whatsapp_opt_inconorigen='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 conopOptInLoading. Actualiza in-placeclienteSeleccionado+router.reload({ only: ['clientes'] })para que el banner desaparezca sin recargar la página. - Detalle de implementación: tuve que importar
usePagede@inertiajs/vue3porque el computed accede apageProps.whatsappFeatureEnabledy$pagesolo existe en templates, no en script. El resto de los usos en este archivo seguía usando$page.props.xenv-ifdel template, así que no habíausePageimportado 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_outseparado deacepta_whatsapp. La razón:acepta_whatsappes opt-IN bypaseable por el toggle globalwhatsapp_respetar_opt_in=0, mientras que el opt-out solicitado por el cliente debe ser un veto absoluto que se respeta siempre. Si solo apagaraacepta_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_outboolean default falsewhatsapp_opt_out_attimestamp nullable (auditoría)whatsapp_opt_out_origenstring(16) nullable, valoresposoadmin
- Endpoints (POS, accesibles a la asociada vía middleware
kiosk):POST /pos/cliente/{cliente}/whatsapp/baja→PosController::whatsappBaja— pone flag en true, registranow()y origenpos, logcliente.whatsapp_opt_out.baja.POST /pos/cliente/{cliente}/whatsapp/alta→PosController::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.vuecabecera): badge rojo “Sin WhatsApp” al lado del nombre cuandowhatsapp_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.confirmantes de pegarle al endpoint. Loading spinner local conopOptOutLoading. Actualiza in-place tantoclienteEditandocomoclienteSeleccionadopara feedback inmediato sin recargar.
- Banda de cliente seleccionado (
- UI en
/clientes/{id}/edit(admin/gerente): mismo bloque rojo después del checkbox de “Acepta recibir su ticket por WhatsApp”.ClienteController::updatevalidawhatsapp_opt_outy, si el flag cambió de estado, escribewhatsapp_opt_out_at/whatsapp_opt_out_origencon origenadmin. - Guardas en el sender: dos capas defensivas, ambas con log informativo cuando bloquean:
PosController::procesarahora chequea! $cliente->whatsapp_opt_outantes de despacharSendWhatsappTicket— evita encolar trabajo inútil.SendWhatsappTicket::handleearly-return sicliente?->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, checkboxacepta_whatsapp(admin + POS create + POS edit), y el sufijo “(WhatsApp)” del label de Teléfono en los forms del POS. Endpointspos.cliente.whatsapp.{baja,alta}devuelven404si el feature está apagado para evitar invocación directa por curl. Testendpoints de baja/alta devuelven 404 cuando el feature está apagadovalida 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 origenadmin - endpoints baja/alta devuelven 404 cuando el feature está apagado
- job handle no llama al sender (Http::assertNothingSent) cuando hay opt_out
- opt_out bloquea dispatch incluso con
- Aviso de Privacidad actualizado en el mismo día: correo cambiado a
info@holbox.store(era placeholdercontacto@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:
Whatsappfilter → 19/19 verdes. Build Sail → 1.20s,POS/Indexahora 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íaprefers-color-scheme, sin assets externos. - Ruta:
Route::view('/aviso-de-privacidad', 'legal.privacy')->name('legal.privacy');con redirects 301 desde/privacyy/privacy-policypara máxima compatibilidad con lo que Meta espera. - URL final que entregar a Meta:
https://holbox.val-soft.com/aviso-de-privacidad(tambiénhttps://holbox.val-soft.com/privacy).
- Vista:
- Contenido del aviso (10 secciones, ajustado a LFPDPPP mexicana):
- Identidad y domicilio del responsable
- Datos personales recabados (incluye optometría)
- Finalidades primarias/secundarias
- 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”
- Transferencias de datos (Meta + proveedor de infra)
- Derechos ARCO con dirección de contacto
- Revocación del consentimiento
- Conservación de datos
- Medidas de seguridad
- 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:listmuestra 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 USDen 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— formateabamonto_acumuladocontoLocaleString('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.vuelí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.
- Único bug:
- Cambio aplicado: helper local
formatCurrencyenClientes/Index.vuecon el patrón estándares-MX/MXNy 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 run26132413697(success).
2026-05-19 (PM-4) — Reporte de ventas oculta canceladas por default
- Pidió Sergio: dio de alta por error la venta
VTA4-000247en 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 endpointVentaController::cancelarya repuso+1eninventariosparaproducto_id=3 / sucursal_id=4). - Cambio de código (local, no commiteado):
app/Http/Controllers/Reportes/VentasController.php: cuando el filtroestadoviene vacío, se aplicawhere('estado', '!=', 'cancelada'). Esto resuelve la inconsistencia previa donde$totalVentassumaba canceladas perocantidad_completadasno. 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 enventas.data+resumen.totalycantidad_completadasreflejan solo la completada; (b) con?estado=canceladasolo 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 filtranestado=completadapor 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 run26131224699(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 conlockForUpdatey guard final (if post > 0 abort); 1 detalle + 1 venta borrados. Tras la operación,MAX(folio)en suc 4 vuelve aVTA4-000246, así que la próxima venta tomaráVTA4-000247. Inventario sigue correcto porque la cancelación previa ya había restituido +1 alproducto_id=3antes del DELETE. No quedó rastro enmovimientos_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): usabaSUM(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.
- Reporte de Ventas por Categoría (Semanal) (
- 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:
Sidescuento_linea = subtotal_linea × (venta.descuento / venta.subtotal)venta.subtotal == 0(caso degenerado, venta solo de regalos), no aporta descuento. En SQL se protege conNULLIF(ventas.subtotal, 0). - Archivos tocados (commit
8e706a9, pusheado aorigin/main, deployado a prod):app/Http/Controllers/ReporteController.php— métodosemanalCategorias. Cada línea ahora cargaventa:id,subtotal,descuento,total; el cálculo va en PHP vía closure$descuentoLinea+ helper$reduceCategoriapara no repetir lógica entre las categorías normales y “Sin Categoría”. Cada fila traeunidades,subtotal,descuento,total.app/Http/Controllers/Reportes/AvanzadosController.php— el select por empleada renombraventas_total→ventas_subtotal, agregadescuentos_aplicados(víaCOALESCE(SUM(subtotal * descuento / NULLIF(subtotal, 0)), 0)en SQL), y reconstruyeventas_totalen PHP comoventas_subtotal - descuentos_aplicados. Asíventas_totalmantiene 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 conSUM(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 degh run list) — completed/success. Cambios visibles en prod inmediatamente. - Pendiente preexistente no relacionado:
CajaCorteTest > gerente puede ver reporte de cortessigue 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-hoydentro del grupokiosk(mismo middleware que el resto de/pos/*), nombre de rutapos.misVentasHoy. Filtra porauth()->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 queLeaderboardController); 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.leftJoinpara no perder productos sin categoría (los mapeo a “Sin categoría”). Cantidades por unidad vendida (suma deventa_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
Leaderboardwidget que ya ocupa bottom-left). Modal estiloabrirHistorialcon 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”.
- Endpoint:
- Archivos tocados (no commiteado todavía):
app/Http/Controllers/PosController.php— nuevo métodomisVentasHoy()después dehistorialCliente. UsawithCount('detalles')para evitar lazy-load del array de detalles cuando solo necesito contar líneas en la lista.routes/web.php— rutapos.misVentasHoydentro del grupokiosk.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|| truelo hace visible a todos los roles — admin que use el POS también lo puede consultar. - Validación: suite local
PosMisVentasHoyTest2/2 verde (17 assertions). Pint pass. Build via Sail 3.00s,Index-C5FWJEID.jsahora 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-3alg:grid-cols-4. Card en ámbar (consistente con el badge del avanzado) usandoEyeDropperIcon, 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— agregadocantidad_optometriasymonto_optometriasal arrayresumendel 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 deEyeDropperIcon, grid alg:grid-cols-4, nueva card conformatCurrency(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=Acuando 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
$querydespués de$query->orderBy(...)->paginate(50). Resultado: MySQL1235 LIMIT & IN/ALL/ANY/SOME subqueryporquepaginateaplica limit/offset al builder y ese builder con limit se metió como subquery en elwhereIn. Fix: muevo TODOS los cálculos del resumen (incluyendo el ya existente$totalVentas = $query->sum('total'), que sobrevivía solo porqueaggregate()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 deVentasControlleren el repo. - Sanity check:
pest --filter=VentasOptometriaResumenTest3/3,pest --filter=AvanzadosOptometriaMontoTest2/2. Pint pass tras un fix automático (not_operator_with_successor_spaceen 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, noReportes/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— unDB::rawadicional al array$selectsde 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 elmap. 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 yformatCurrency(emp.monto_optometrias)entext-xs text-gray-500 dark:text-gray-400debajo.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_optometriasusaint(400, 0) en vez defloat(400.0, 0.0) porquejson_encodeserializa floats sin decimales como ints y elassertInertia ->wherehace 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 esCajaCorteTest > 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 buildfalla por Node 18 host (crypto.hash is not a functionen 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 run26068398056): solo bump del slug en 4 sitios.config/services.php— default deMETA_WHATSAPP_TICKET_TEMPLATEpasa deticket_holbox_v2→ticket_holbox_v3.app/Providers/AppServiceProvider.php— fallback del sender productivo pasa deticket_link_v1(legacy del primer commit de WhatsApp Fase 1) →ticket_holbox_v3. Estaba inconsistente con el config default desde el commitce93f6a, ahora alineado.app/Http/Controllers/WhatsAppPreviewController.php— fallback delMetaCloudWhatsAppSenderinstanciado on-demand parasendPreview()→ticket_holbox_v3. Comentario delTEMPLATE_BODYactualizado a “ya aprobado por Meta”.app/WhatsApp/MetaCloudWhatsAppSender.php— comentario debuildPayload().
- Tests: suite WhatsApp 22/22 verde sin cambios (los tests que assertan slug usan
ticket_link_v1hard-coded en su propioconfig(['...' => 'ticket_link_v1']), así que son independientes del default). - Estado en prod: código deployado. El template
ticket_holbox_v3está aprobado en Meta, así que ya es la realidad operativa. Falta solo el switch del.envpara 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(nonombre + 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”.clientecomo fallback porque Meta rechaza variables vacías. - Archivos tocados (commit
c76b96e, deployado a prod via GHA run26068094831):app/Models/Venta.php— nuevo métodonombreClienteCorto():trim()del nombre, fallback'cliente'si la venta no tiene cliente asociado o el campo es vacío/whitespace.app/WhatsApp/MetaCloudWhatsAppSender.php—buildPayload()agrega el nombre comoparameters[0]del componentebody; el orden total ahora es[nombre, modelo, folio].loadMissingagrega'cliente'para garantizar la relación cargada.app/Http/Controllers/WhatsAppPreviewController.php—TEMPLATE_BODYcambia 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énnombre_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 paranombreClienteCorto: (a) cliente con nombre → devuelve nombre, (b) venta sin cliente → “cliente”, (c) nombre con solo espacios → “cliente”.tests/Feature/WhatsAppPreviewTest.php— payload assertions actualizadas:parametersahora 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.
- botón URL dinámico “Ver ticket” con base
- 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 componentebodyentemplate.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íaUTILITY. - “Garantía activa” hard-coded en el template, sin variable.
- Archivos tocados (commit
ce93f6a, deployado a prod via GHA run26067189002):app/Models/Venta.php— métodomodeloPrincipal()que filtradetallespores_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): arraypúblico, único, compartido entresendTicket(),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 componentebodycon[{type:'text', text:modelo}, {type:'text', text:folio}]antes del componentebutton.app/Http/Controllers/WhatsAppPreviewController.php—TEMPLATE_BODYactualizado al texto literal del v2 (placeholders{{1}}y{{2}}sin resolver). Cada venta del select exponemodeloya derivado. El modo simulado pasa por$sender->buildPayload()en vez de armar el array a mano.config/services.php— default deMETA_WHATSAPP_TICKET_TEMPLATEcambia aticket_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}}→foliocon la venta seleccionada, y muestra un bloque debajo con los valores explícitos para que el admin valide cada variable. Usav-preen 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]=bodycon 2 params,components[1]=buttoncon token).
- Validación local: suite WhatsApp 19/19 verde (53 assertions). Pint
pass.npm run buildverde tras destrabe del{{ '{{1}}' }}problemático conv-pre(Vue intentaba interpolar). - Estado en prod: código deployado, pero el template
ticket_holbox_v3aún no existe en Meta. MientrasWHATSAPP_FEATURE_ENABLED=falsey/oWHATSAPP_DRIVER=logel 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:
- Aarón crea el template
ticket_holbox_v3en Meta Business Manager con el cuerpo literal deWhatsAppPreviewController::TEMPLATE_BODY(texto, 2 variables{{1}}y{{2}}en body, botón URL “Ver ticket” con{{1}}= token sobrehttps://holbox.example.com/t/). - Sergio valida en
/whatsapp/preview(modo simulación) que el mockup se ve OK con varias ventas distintas. - Cuando Meta apruebe: Sergio actualiza
.envde prod conMETA_WHATSAPP_TICKET_TEMPLATE=ticket_holbox_v3(o quita la línea, default ya apunta al nuevo) +sudo systemctl reload php8.3-fpm. Sin redeploy. - Smoke test real con cuenta de pruebas (test recipient) antes de levantar
WHATSAPP_FEATURE_ENABLED=trueglobalmente.
- Aarón crea el template
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
holboxSSH): categorías 1-8 listadas. Las 2 candidatas claras a quedar fuera sonid=7 BOLSITA(reg=40, sale=35) yid=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_saleencategorias, defaulttrue(compat). Migración hace backfill afalsepara 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):database/migrations/2026_05_18_220000_add_cuenta_para_precio_sale_to_categorias.php— nueva.up()agrega columna + backfill BOLSITA/ESTUCHE.down()drop columna.app/Models/Categoria.php— fillable + castboolean.app/Http/Controllers/CategoriaController.php— validaciónbooleanen store/update.app/Http/Controllers/PosController.php— 3 puntos: (1) catálogo del POS no exponeprecio_salesi el flag está OFF (línea ~53), (2)totalLentesCountno suma productos cuya categoría tiene flag OFF (línea ~212), (3) aplicación deprecio_saletambién requiere flag ON (línea ~233). Se exponecuenta_para_precio_saleen cada producto del map para que el frontend pueda replicar la lógica.resources/js/Pages/POS/Index.vue— espejo client-side:nuevaLineaProductocarga el flag,totalLentesCountlo filtra,subtotalProductos/isSalePriceApplied/getPrecioAplicablelo verifican antes de aplicarprecio_sale. Asegura que el preview del carrito coincida con el cálculo del backend.resources/js/Pages/Categorias/Create.vueyEdit.vue— nuevo bloque con switch “Cuenta para promoción 2x lentes (precio_sale)” + helper que explica cuándo apagarlo (bolsitas/estuches). Default true en Create, lee del modelo en Edit.tests/Feature/PosControllerTest.php— 4 tests nuevos: (a) lente+bolsita no dispara sale en lente → 740; (b) 2 lentes+bolsita: lentes a sale, bolsita a regular → 1240; (c) default del flag estrue(compat); (d) endpoint/posexpone el flag y omiteprecio_salecuando OFF.
- Detour: tests pre-existentes rotos. El suite
PosControllerTesttraía 18 fallas / 4 verdes en baseline. Causa: 11 tests POST/GET a rutas POS no pasabankioskSession(), requerido porOperativeSucursaldesde algún commit reciente. Como mis tests sí necesitankioskSession, 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$sucursalcapturado en variable (helpers diferentes), fuera de scope. Ganancia neta: +11 verdes, 0 regresiones. - Build + Pint: Pint
{"result":"pass"}.npm run buildexitoso. - 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, ahoraprecio_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.
04ad770pusheado aorigin/main, GHA run26066193131(49 s, success). Verificación en prod via tinker: 8 categorías OK, BOLSITA/ESTUCHE enfalse, las 6 de lentes entrue. 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):
- Totales de optometría en reporte de ventas (cantidades y montos).
- Que las asociadas vean su historial de venta del día en curso + totales de lentes por categoría (validar primero si ya existe).
- 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/Avanzadosya cuenta líneas con add-on (commit del 2026-05-13, bitácoraAvanzadosarriba), pero solo conteo. Falta elSUM(precio_optometria * cantidad)para el monto. - #2 — NO existe. El
historialVentasenPOS/Index.vuees del cliente seleccionado, no de la asociada. Los reportes existentes (ReporteController::ventas,AvanzadosController) están detrás derole:admin|gerentey no rompen el agregado por categoría per-asociada-del-día. - #3 — confirmado como bug latente.
PosController::storeincluye entotalLentesCountcualquier categoría que no use precio propio, así que las bolsitas pueden estar disparandoprecio_salede los lentes reales con solo “1 lente + 1 bolsita”.
- #1 — parcialmente existe: la columna “Optometría” en
- 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
f4caf0adel 2026-05-13) y puede haber registros capturados. Para no perderlos: agregar columnadp, backfillear conCOALESCE(dp_binocular, dp_monocular)(prefiere binocular si existen ambos, cae a monocular), drop ambas viejas. Migración reversible: eldown()recrea las dos columnas viejas y copiadpadp_binocular. - Archivos tocados:
database/migrations/2026_05_18_180000_consolidate_optometria_dp_columns.php— nueva.app/Models/Optometria.php— fillable + casts pasan adpúnico.app/Http/Controllers/OptometriaController.php— regla validacióndp(between:0,200), reemplaza las dos viejas.resources/js/Pages/Optometrias/Index.vue— card DP queda con un solo input; cabecera de tabla “DP (Bin · Mon)” → “DP” y la columna deja de tenercolspan=2.resources/js/Pages/POS/Index.vue,POS/Ticket.vueyPOS/PublicTicket.vue— “DP: X bin / Y mon” → “DP: X” en los tres.tests/Feature/OptometriaControllerTest.php— payload del test usadpúnico.
- Validación: migración corrida en local OK (97 ms), 9/9 tests filtro Optometria verde, rebuild de assets OK.
- Commit + deploy:
43bf8cbpusheado aorigin/main, GHA run26058182934completed success. El workflowdeploy.ymlcorrephp artisan migrate --forceasí 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 gruporole:adminenroutes/web.php. NO accesible para gerente/asociada (test cubre 403). - Sin tocar el camino productivo: el contract
App\Contracts\WhatsAppSender::sendTicketqueda intacto y el jobSendWhatsappTicketsigue resolviendo el binding del container como antes (Log o Meta segúnWHATSAPP_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 quesendTicketpero usa el teléfono que captura el admin, captura excepciones, y retorna{success, status, request_payload, response_body, message_id, error}. Loggea con prefijowhatsapp.preview.*para separarlo del tráfico productivowhatsapp.ticket.*. App\Http\Controllers\WhatsAppPreviewController:show: trae últimas 10 ventas con cliente, pasadriver,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: validaventa_id + telefono, normaliza conPhoneNumber::toE164Mx. Si no normaliza → 422 conwithErrors('telefono'). SimetaConfigured=false(driver=log o falta token) → modo simulación: arma el payload teórico y lo devuelve sin pegar a Meta. SimetaConfigured=true→ instanciaMetaCloudWhatsAppSendercon la config actual y llamasendPreview. Resultado víasession('preview_result')para que la página lo muestre tras redirect-back.
- Ruta nueva
- 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.messagedel JSON de Meta) para diagnóstico. - Cumple regla
feedback_dark_mode_aesthetics— todos losbg-*tienen sudark:bg-*y lostext-*también.
- Link desde
/configuracion: bloque emerald nuevo visible solo para admin (independiente del feature flag) enConfiguracion/Index.vuecon CTA “Abrir pantalla de prueba →”. - Tests
tests/Feature/WhatsAppPreviewTest.php— 7/7 verde:- asociada → 403.
- gerente → 403.
- admin → 200 + componente Inertia
WhatsApp/Preview. - driver=log +
Http::fake()→ no se manda nada (Http::assertNothingSent), resultado simulado tiene elticket_tokencorrecto en el payload. - driver=meta +
Http::fakecon response OK → POST llega a/{phone_id}/messagescontooverride y template correcto; resultado tienemessage_id. - Teléfono
"abc"→assertSessionHasErrors('telefono'). - Meta responde 400 con
error.message→ resultado tienesuccess=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
.envde 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:
WhatsAppPreviewTest7/7 +WhatsappTicketDispatchTestprevio no roto. El único test que falla en el filtro (ConfiguracionControllerTest > guardar configuracion) ya fallaba enmainantes de mis cambios (verificado congit 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=truepero NO eran accesorios — los dos conceptos estaban enredados por accidente. Decisión: separar en flag independiente + crear UI dedicada. - Hice (Fase A —
966b6b8): migraciónproductos.puede_ser_regalobool (default false) con backfilltruepara productos que ya estaban encategoria_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 elprecio_regularde 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).RegaloControllercon 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)” deCategorias/Create+Edit— elCategoriaControllerya no toca la pivotcategoria_accesorios. Test de regresión confirma quePUT /categorias/{id}no wipea regalos. 9 tests nuevos enRegaloControllerTest. Manual reescrito completo. Nav link en Administración. - Pidió Sergio (2 — post-deploy): fix de
1 × $NaNque aparecía en cada línea del ticket público. - Hice (
fa8193b): corregidod.precio_venta→d.precio_unitario(campo real del schema).fmt()blindado conNumber(n) || 0para 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=metaen 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) yventas.ticket_token(varchar 40 unique). Backfill de tokens para ventas existentes en la misma migración. - Modelos:
Venta::booted()auto-generaticket_tokenalcreate. HelperVenta::ticketUrl()devuelve la URL pública. - Util:
App\Support\PhoneNumber::toE164Mx()— normaliza teléfono MX a E.164 (52XXXXXXXXXX), maneja+52,52,1legacy,01,00, espacios y basura. - Service abstraction: contract
App\Contracts\WhatsAppSender. DriversLogWhatsAppSender(default, escribe alaravel.log) yMetaCloudWhatsAppSender(POST a/v20.0/{phone_id}/messagescon template + body parameter para el link). Binding enAppServiceProvider::registerporconfig('services.whatsapp.driver'). Mientras WHATSAPP_DRIVER=log, todo corre sin pegarle a Meta — switch real es solo.envcuando se apruebe. - Config: bloque
whatsappenconfig/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\SendWhatsappTicketqueued, 3 tries, backoff[60s, 5min, 15min]. ResuelveWhatsAppSenderdel 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.procesarvalidaenvio_whatsappbool. Sienvio_whatsapp && cliente && cliente->telefono && (!respetar_opt_in || cliente->acepta_whatsapp)→SendWhatsappTicket::dispatch($venta). - UI: checkbox
acepta_whatsappenClientes/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.
- DB: migración agrega
- Plantilla Meta que Sergio debe aprobar antes de activar driver=meta:
- Categoría:
UTILITY - Nombre:
ticket_link_v1(config) — cambiable vía envMETA_WHATSAPP_TICKET_TEMPLATE - Idioma:
es_MX— cambiable víaMETA_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ónVer ticket, URL TypeDynamic, Website URLhttps://<dominio-prod>/t/{{1}}. El{{1}}se rellena conticket_tokende la venta (NO con la URL completa). - El sender (
MetaCloudWhatsAppSender) ya manda solo elticket_tokencomo parámetro del botón encomponents[0].parameters[0].text. Meta concatena con el prefijo del Website URL configurado en el template.
- Categoría:
- Activar en prod cuando Meta apruebe: en el
.envde DigitalOcean cambiarWHATSAPP_DRIVER=log→WHATSAPP_DRIVER=meta+ meterMETA_WHATSAPP_PHONE_NUMBER_IDyMETA_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 adicionaltoggle(POST /comision-reglas/{regla}/toggle)para flipearactivasin abrir el form. Validación inline (patrónSucursalController) conRule::requiredIfpara los tres constraints lógicos (categoria_idsi trigger=categoria,producto_idsi trigger=producto,unidades_por_paquetesi tipo=por_paquete). Antes de persistir, normaliza FKs irrelevantes anullpara que un cambio de trigger no deje basura colgando.- Rutas:
Route::resource('comision-reglas', …)->except(['show'])+ ruta extra del toggle, dentro del gruporole:adminexistente. - Vistas Inertia bajo
resources/js/Pages/ComisionReglas/(Index, Create, Edit) — siguen el patrón de Sucursales (noAdmin/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íaApp\Http\Controllers\Admin\ComisionReglaControlleryPages/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í —
destroyborra real, mientras que el toggle (separado) flipaactivapara apagar sin perder historia. Frontend mantieneconfirm()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.
- Sin
- 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}.phpa mano.validation.phpcon secciónattributescubriendo ~50 campos del codebase para que:attributesalga legible.config/app.phpdefault'es'(de modo que aunque la env del server diga otra cosa, el código cae en ES si la env falta)..env.exampley.envlocal también aes.- Test Pest end-to-end: POST
/clientescon 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.phptambién cambiado: si alguien borraAPP_LOCALEdel.env, default sigue siendo ES — protege contra deploys mal configurados. - 36 tests pre-existentes fallan en la suite completa pero verifiqué con
grepque 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.
- Sin dep externa (no
- Falta: después de deploy, Sergio actualiza el
.envde prod (DigitalOcean,holboxSSH alias) aAPP_LOCALE=eso 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=, hacetrim()del input, aplicawhere()cerrado con grupoorWheresobrenombre/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énfilters.qa la vista.resources/js/Pages/Clientes/Index.vue— agregadafiltersprop,q = ref(props.filters?.q),watchcon debounce 400ms →router.get('clientes.index', { q }, { preserveState, preserveScroll, replace }). UI: input conMagnifyingGlassIcon+ botónXMarkIconpara 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 apellidoconcatenado 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.
- Debounce a 400ms (no 300ms como dijo el doc original) — alineado con el patrón ya en producción en
- 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 enAvanzados.vue. Modelada como columna fija (como tickets/arts_otros), no como categoría dinámica — consistente con ComisionReglasCalculator::addon_optometria. Commiteado y pusheado:8caf658enmain. - 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/leaderboarden 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. Calculatickets,ventas_total,ticket_promedio = ventas_total/ticketspor asociada en la ventanaCarbon::now($tz)->startOfWeek(MONDAY)…endOfWeek(SUNDAY)(misma TZ y mismo patrón queReporteController::comisiones). Asociadas sin ventas se incluyen contickets=0, ticket_promedio=0(la cliente pidió que arranquen en $0 el lunes). Ordena desc por promedio (tiebreak por ventas_total) y asignaposicioncon ranking estilo competitivo (1, 2, 2, 4) si hay empate. - Ruta
routes/web.php—GET /leaderboard→leaderboard.index, dentro del grupoauth(cualquier usuario logueado puede consumir). - Frontend
resources/js/Components/Leaderboard.vue— widget Vue 3<script setup>conaxios. Fetch on mount +setIntervalcada 5 min. Estado colapsado/expandido enref(false)local. Botón “Refrescar” manual. Indicador “Actualizando…” + timestamp de última actualización.print:hiddenpara no salir en tickets impresos. Resalta la fila del usuario actual conbg-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:
- Sin auth → 401.
- Ordena por promedio desc y asigna
posicion(1,2,3) correctamente con 3 asociadas con distintos promedios. - Asociadas sin ventas aparecen con tickets=0 y promedio=0.
- Ventas canceladas + ventas fuera de la ventana lun-dom se excluyen.
- Admin y gerente no aparecen en el ranking (aunque tengan ventas asignadas).
- 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→ disparadeploy.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
asociadareal: 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 sobreusers). Si crece el catálogo de asociadas/volumen, agregarCache::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.procesarví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_reglaspara soportar un 3er triggerproducto(además decategoriayaddon_optometria). Agreguéproducto_id(nullable, FK aproductos.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 inconsistente001Avs0001Bvs006C). - 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 enPosController.php:185). Modelados como columnas enventa_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)
- id=2 →
- 3 SKUs Ixchel/Kin confirmados en prod: id=42
CJ-001AIxchel, id=41CJ-0001BIxchel Sunrise, id=36CJ-006CKin 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 (condetalles.productoeager-loaded) y devuelve['total' => float, 'aportes' => [...]]. Trigger por trigger:addon_optometria→ cuenta líneas contipo_optometria !== null× montocategoriapor_unidad → cuenta unidades × montocategoriapor_paquete →floor(unidades/paquete) × monto(sobrantes se pierden, como pidió Sergio)producto→ mismo patrón pero filtrado porproducto_id
- Seeder
database/seeders/ComisionReglasSeeder.php— busca categorías por nombre exacto y SKUs por string exacto en prod, haceupdateOrCreatepor(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. Agregareglas_aportesyreglas_totalal payload de Inertia.comision_totalahora sumacomision_base + bono_premium + reglas_total. - Vista
resources/js/Pages/Reportes/Comisiones.vue: agregada columna “Reglas Extra” con total + tooltiptitleque 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:
- Sin reglas activas → total 0
- addon_optometria suma por línea (los 3 tipos cuentan igual)
- categoria por_unidad cuenta unidades del producto en esa categoría
- categoria por_paquete aplica
floor(unidades / paquete); sobrantes se pierden - producto por_unidad cuenta unidades del SKU específico
- Múltiples reglas se suman (caso especial: categoria + producto sobre el mismo SKU)
- Reglas inactivas se excluyen
- Integración: el reporte de comisiones incluye
reglas_totaly se suma acomision_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
esquemaconcomision_porcentaje(deConfiguracion),bono_premium[](4/6/8 con sus montos actuales) yreglas[](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 alComisionReglasSeederdesde una migration para que las 7 filas iniciales se siembren automáticamente conphp 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 usaupdateOrCreate).
Deploy ejecutado 2026-05-11:
- Commit:
ea70491(“feat: add commission rules system with multiple triggers”) - Push a
origin/main→ GitHub Actions corre el workflowdeploy.ymlque hacegit pull+composer install+npm run build+php artisan migrate --forceen 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/comisionesen 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:
- Push a git del branch con los cambios (5 archivos nuevos + 2 modificados).
- 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 - Verificar en UI: entrar a
/reportes/comisiones, ver columna “Reglas Extra” y tooltip con desglose. - 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 desdeprops.filters.X || ''(líneas 30-37). props.filtersviene 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
watchcon debounce de 400ms llama afetchReport()(Ventas.vue:43-59), que hacerouter.get(route('reportes.ventas'), {filtros...}, { preserveState: true, replace: true }). replace: truereemplaza 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
ArrowLeftIcondel header) es:
Es decir, navega a<Link :href="route('reportes.ventas')" ...>/reportes/ventassin pasar ningún query param. El controller usa los defaults (fecha_inicio = hoy-30, fecha_fin = hoy, todo lo demás vacío) y devuelvefilterscon esos defaults → losrefs se inicializan con defaults → el usuario ve el reporte “limpio”. - No es un
<Link>conpreserve-stateni unwindow.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 usareplace: truey 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 enhistory.state, useRemember faltante, etc.).
Otros lugares que apuntan a route('reportes.ventas') (búsqueda exhaustiva):
- AuthenticatedLayout.vue:112 — menú dropdown (“Reportes → Ventas”). Es entrada al reporte, sin filtros, correcto.
- AuthenticatedLayout.vue:317 — menú mobile responsive. Idem.
- Ventas.vue:44 — el propio
fetchReport(). Correcto. - Show.vue:40 — el botón con bug.
Opciones de fix (orden de preferencia):
-
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. -
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 propback_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.
- Backend: en
-
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:
Link→router(no había más usos deLinken el archivo, verificado con grep). - Agregada función
goBack()que hacewindow.history.back()si hay history, orouter.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.
- Import:
- 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 enReporteController.phpque estaba en origin). - Commit:
3bab159(“fix: preserve sales report filters when returning from sale detail”). - Push a
origin/mainautorizado por Sergio → GitHub Actions ejecutadeploy.yml(git pull + composer +npm run build+php artisan migrate --force). El build de Vite regenera el chunk de Show.
- Pull fast-forward previo trajo
- 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étodocomisiones(), no aventas(). Ahora sisucursal_idviene 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
ComisionReglasCalculatorrecibe 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:
- public/manifest.webmanifest — manifest global.
- public/app_icon.jpeg — el ícono nuevo. Dimensiones reales: 1526×1600 px, no cuadrado (vertical 95.4×100).
- public/sw.js — service worker, ya bumpeado a
CACHE_VERSION = 'v5'. - app/Http/Controllers/ManifestController.php — manifest dinámico por sucursal (ruta
/manifest/s/{token}). - resources/views/app.blade.php — tags
<link>de íconos y meta tags PWA.
Causas raíz identificadas:
-
🚨 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. -
🚨 Crítica para iOS (Safari): el
<link rel="apple-touch-icon" href="/app_icon.jpeg">apunta a un JPEG. iOS Safari ignora JPEG enapple-touch-icon; solo acepta PNG. Cuando Sergio reinstaló en su celular, iOS cayó al favicon vacío (favicon.icopesa 0 bytes en el repo) o un placeholder. Esto explica el problema #2 para iOS. -
🚨 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.
-
Calidad — maskable mal definido: el ícono
maskabledebe tener un safe zone interno de ~10% porque Android puede recortar/redondear los bordes según la forma del launcher. Aquí se reusaapp_icon.jpeg(raster sin safe zone declarado) parapurpose: "maskable". Aun arreglando los tamaños, en Android se va a ver cortado. -
JPEG es subóptimo para PWA: no soporta transparencia (los bordes redondeados quedarán con fondo blanco/del JPEG). Lo estándar es PNG.
-
Í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). -
El SW (commit
28137bd) ya está bien:CACHE_VERSION = 'v5'fuerza invalidación, precachea/app_icon.jpeg, agrega.jpeg/.jpgaisStaticAsset. 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. -
ManifestControllertambié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 imagemagickopip install pillowpara 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-titlecomo 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 devo 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:- Padding transparente para cuadrar a 2380×2380 (
/tmp/icon-square.pngintermedio). public/icon-192.png— 192×192, RGBA, transparente.public/icon-512.png— 512×512, RGBA, transparente.public/icon-maskable-512.png— 512×512, RGB, ícono escalado a 410×410 (~80%) centrado sobre fondo blanco opaco (safe zone correcta para Android).public/apple-touch-icon.png— 180×180, RGB, fondo blanco opaco (iOS Safari requiere PNG; redondea esquinas automáticamente).
- Padding transparente para cuadrar a 2380×2380 (
- Cambios en archivos:
- public/manifest.webmanifest — arreglo
iconsrehecho con 3 entradas (192 any, 512 any, 512 maskable) tipoimage/pngysizesconcretos.shortcutsactualizados aicon-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-iconpor PNG consizesdeclarados; quitadomask-icon(no usado). - public/sw.js —
CACHE_VERSIONv5 → v6,PRECACHE_URLSactualizado a los 4 PNG nuevos.
- public/manifest.webmanifest — arreglo
- Cleanup (autorizado por Sergio):
public/source-icon.png→ movido aresources/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 builddeberí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 aorigin/mainautorizado por Sergio → GitHub Actions corredeploy.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.webmanifestse sirve con el contenido NUEVO correcto:"name": "HOLBOX","short_name": "HOLBOX", íconos PNG consizescorrectos. ✅- Los 4 íconos PNG están en
/public/conContent-Type: image/pngcorrecto, todos200 OK. ✅ - ⚠️ Detalle pendiente:
/manifest.webmanifestse sirve conContent-Type: application/octet-streamen lugar del esperadoapplication/manifest+json. nginx no tiene.webmanifestregistrado 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:
El<meta name="application-name" content="Punto de Venta - Holbox"> <meta name="apple-mobile-web-app-title" content="Holbox POS">AppServiceProvider.phptiene unView::composer('app', ...)que inyectapwaAppNameypwaShortNamecon los nombres viejos hardcoded, sobrescribiendo los defaults'HOLBOX'que dejé enapp.blade.phpcon?? '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->nombreque 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/.webmanifestdel repo.
Deploy ejecutado 2026-05-12 (PM, segunda vuelta):
- Commit
7cc0227(“fix: align PWA meta tag names with new HOLBOX branding”) → push aorigin/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:
- 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.
- Cerrar todas las pestañas de Chrome (limpia SW residente).
- Site settings → clear all (ya hecho, refuerza limpieza).
- Reabrir el sitio en Chrome.
- Menú de Chrome → “Instalar app” (o “Add to Home screen” → opción “Install”).
- 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.typesno tiene mapping para.webmanifest, por eso nginx devolvíaapplication/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:
- Yo: escribí el conf modificado en local, lo subí vía
scpaholbox:/tmp/laravel.nginx.new(no requiere sudo). - 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 - 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):
- 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.
- Cerrar todas las pestañas de Chrome.
- 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:
- Manifest icons (commit
61615d0): JPEGsizes:"any"→ PNG 192/512/maskable + apple-touch PNG. Manifest, ManifestController, blade, SW v6. - PWA naming (commit
7cc0227):AppServiceProviderinyectaba “Holbox POS” / “Punto de Venta - Holbox” sobrescribiendo defaults nuevos del blade. - nginx Content-Type (server, no commit):
application/octet-stream→application/manifest+jsonpara.webmanifest. Backup en/etc/nginx/sites-available/laravel.bak.<timestamp>. - 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 #2563ebazul corporativo), se regenera con un comandoconverty 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):
- Gerentes y admin también lo deben ver (no solo asociadas).
- Más visible, sin requerir click — debe estar expandido por default.
- Que NO ocupe espacio sobre el carrito de compras (el carrito está fixed a la derecha en POS/Index.vue).
- 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-3→bottom-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)(antesexpanded = 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-500→from-slate-800 to-slate-900. Profesional, no brillante, buen contraste con el texto blanco. - Bono al #1:
- Backend:
LeaderboardControllerleeConfiguracion::get('bono_lider_semanal', 0)y lo retorna enbono_liderdel JSON. - Config: key
bono_lider_semanalagregada aConfiguracionController::index()(lista de keys leídas) y astore()(regla de validaciónrequired|numeric|min:0, solo admin, comotipo_de_cambio). - UI Config:
Configuracion/Index.vue— agregado campo “Bono semanal a la #1 del leaderboard ($)” en el grid de configuraciones, conv-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 pantallassm: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.
- Backend:
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-400para 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— sinConfiguracion::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_semanalen/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) enConfiguracion, default false. Agregada aConfiguracionController::index(lista de keys) y astorecon validaciónrequired|booleanpara admin. - Compartido vía Inertia
HandleInertiaRequests::share— exponeleaderboardActivo: (bool) Configuracion::get('leaderboard_activo', false)en todos los page props. 1 query extra por request; trivial. Si pega el rendimiento, agregarCache::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 ámbarbg-amber-400 text-amber-950en el header del panel, visible solo cuando admin Y flag off (computedmodoPreview). 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, ocupamd:col-span-2. Texto helper explica el modo preview. Se casteaBoolean(Number(...))al inicializar porqueConfiguracion::getpuede 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:
- Push del commit (sin esta autorización, no hay deploy).
- Tras deploy: el flag empieza en
false(default cuando la key no existe enconfiguraciones). Aaron entra como admin → ve el widget con badge “PREVIEW” en bottom-left. Asociadas y gerentes NO ven nada. - Aaron juega con el widget, decide si quiere ajustes; si pide cambios, iteramos.
- Cuando Aaron diga OK, en
/configuracionactivas el toggle “Leaderboard visible para asociadas y gerentes” + setearbono_lider_semanal. Save. Inmediatamente todas las cuentas auth lo ven. - 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:
-
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 aclientes.id, capturado_por = user_id, fecha del examen, campos del examen). - UI captura en
Clientes/Show.vueo tab nuevo (depende del form que muestre la imagen). - Lectura en
PosController::indexal 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?
- Tabla
-
Emails de recordatorio de cupones por fidelidad. Cliente alcanza nivel de fidelidad → se genera
CodigoDescuentocon 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.phpobootstrap/app.phpen Laravel 12) que corre diario y buscaCodigoDescuentoconusado=falsepró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.
- Comando artisan + scheduler (
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_premiumdeconfiguraciones(defaults 300/500/800). - Hace
BonoPremium::updateOrCreatepara 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/elseifcon $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_premiumque se pasa a Inertia ahora viene de las filas (sortBy unidades_minimo asc).Comisiones.vueya 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 usarBonoPremiumen lugar de Configuracion, y agregadaes_premium=truea 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=2restituye 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-100al 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 !== nullen 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_optometriapor 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
$tieneOptometriaviendo si algún detalle tienetipo_optometria !== null. Si sí, pasaultimaOptometriadel 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:
- Solo admin accede (asociada y gerente reciben 403).
- Store guarda con todos los campos.
- Todos opcionales — guarda registro vacío.
- Eje fuera de 0-180 falla validación.
- Update + destroy.
- 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 conLoyaltyBreakpointMaily los demás transaccionales.
Schema (2026_05_13_200000_add_recordatorios_enviados_to_codigos_descuento.php):
- Columna
recordatorios_enviados(JSON nullable) encodigos_descuento. Guarda array de los días ya notificados, ej[3, 7]. Idempotencia. - Modelo
CodigoDescuentoagregarecordatorios_enviadosal fillable + castarray.
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 enrecordatorios_enviados. Filtra clientes sin email. - Manda Mailable, agrega el día al array
recordatorios_enviadosysave(). 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 decortes:notify-pendientes.
Tests (EnviarRecordatoriosDescuentosTest.php) — 10 casos:
- Manda en cada uno de los 5 días.
- NO manda en día 4 (entre 3 y 7).
- NO duplica si ya está marcado en
recordatorios_enviados. - Marca el día tras envío exitoso (con re-corrida que confirma idempotencia).
- Ignora cupones usados.
- Ignora cupones expirados (TZ-aware: usa Carbon::today(report_timezone)->subDays(2)).
- Ignora clientes sin email.
- Contenido por día — verifica encabezados literales.
- Día no soportado → InvalidArgumentException.
--dry-runno 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 renderizabaformEditarCliente.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 poblabaformNuevoCliente.errorspero 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:
- Email duplicado — la regla
email|nullable|email|max:255|unique:clientesenPosController::storeClienterechaza 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. - Email mal formado — la regla
emailrechaza strings sin@o con espacios. - Teléfono >20 caracteres (raro pero posible si pegan un texto).
- 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 aborder-red-500cuando el campo tiene error (condark:border-red-500/70para 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— booleanes_addondefault false, afterpuede_ser_regalo. Reversible.resources/js/Pages/Reportes/SemanalEmpleada.vue— tarjetas por empleada, tabla 3 columnas (esta/pasada/Δ%), selectoresweek(input HTML5) + empleada.tests/Feature/Reportes/SemanalEmpleadaTest.php— 7 tests Pest, todos verdes.
Archivos modificados (7):
app/Models/Producto.php—es_addonen$fillable+ cast boolean.app/Http/Controllers/LeaderboardController.php— refactorizado para usar LeaderboardService; comportamiento y respuesta JSON idénticos.app/Http/Controllers/ProductoController.php—es_addonen reglas de validación destoreyupdate.app/Support/LocalDateRange.php— nuevos métodosweekRange(?Carbon)ypreviousWeekRange(?Carbon).resources/js/Pages/Productos/Create.vueyEdit.vue— checkbox “Es add-on / upsell” en panel de configuración, dark mode pareado (violet).routes/web.php— rutaGET /reportes/semanal-empleadaen gruporole: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:
-
Estructura Responsiva en
/admin/conversaciones(Index.vue):- Agregada variable reactiva
mostrarFicha(Desktop/Móvil) y métodovolver(). - 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
mostrarFichaestá activo, se renderiza laFichaContactocomo 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.
- Agregada variable reactiva
-
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.
-
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-nonepara salida yrounded-tl-nonepara 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
Entery multilínea porShift+Enter. - Polida la tarjeta de advertencia por expiración de la ventana de 24h Meta.
- Añadido botón de retroceso (
-
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.
- Agregado botón de cierre (
Verificación:
- Ejecutado
npm run builden 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.